Quickstart
Your first request, and a working lineup page in about ten minutes.
Examples use a fictional festival, summer-fest. Sign in at events.grofomo.com and they switch to your own events.
You need two things before you start: an event slug and a publishable key. Both come from the organiser — if you are the organiser, see For organisers.
The slug is the last part of the event's Grofomo link. For grfm.to/e/summer-fest the slug is summer-fest.
Your first request
curl -sS \
-H 'X-Grofomo-Key: pk_live_YOUR_PUBLISHABLE_KEY' \
'https://api.grofomo.com/v1/events/summer-fest/lineup?surface=web'Two details in that command do real work:
X-Grofomo-Keyidentifies the organisation. Most organisers now require it; some older ones do not yet. Send it either way — see Authentication.?surface=webtells the API you are a website. Leave it off and you get the mobile-app surface, which is gated on a different visibility setting, so a perfectly live event can answer404. This trips up nearly everyone once. See Surfaces.
What comes back
Every response is wrapped in the same envelope:
{
"apiVersion": 1,
"data": { "eventSlug": "summer-fest", "slots": [] }
}Errors use the same envelope with error instead of data:
{
"apiVersion": 1,
"error": { "code": "unauthorized", "message": "A valid API key is required" }
}So the check is always: look at res.ok, then read body.data or body.error. Nothing else varies.
A lineup page
This is a complete Next.js server component. It runs on your server, so it uses the secret key and lets the framework cache the result — a busy festival page then costs you one API call a minute, not one per visitor.
async function getLineup() {
const res = await fetch(
'https://api.grofomo.com/v1/events/summer-fest/lineup?surface=web',
{
headers: { 'X-Grofomo-Key': process.env.GROFOMO_SECRET_KEY },
next: { revalidate: 60 },
},
);
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
return body.data;
}
export default async function LineupPage() {
const lineup = await getLineup();
// startTime is an ISO instant, not a wall-clock string — always format it
// in the EVENT's timezone (from event detail), or a Berlin visitor sees
// your Bristol set times shifted an hour.
const setTime = new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/London', // event detail: data.timezone
});
return (
<ol>
{lineup.slots.map((slot) => (
<li key={slot.id}>
<strong>{slot.artist?.name ?? slot.placeholderLabel}</strong>
{' — '}
{slot.stage} · {slot.day} ·{' '}
{slot.startTime ? setTime.format(new Date(slot.startTime)) : 'TBA'}
</li>
))}
</ol>
);
}Two details in the render worth copying:
slot.artist?.name ?? slot.placeholderLabel— an unannounced set has no artist yet, and rendering "undefined" on a public festival page is a bad afternoon.- Times are formatted in the event's timezone, never the visitor's. A set that crosses midnight keeps its
dayon the festival day it belongs to, so group bydayrather than by the date inside the timestamp.
Free SEO
On the web surface the lineup, FAQ and news responses each carry a ready-made schema.org block in schemaOrg. Drop it into the page and search engines can read your running order without you modelling any of it:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(lineup.schemaOrg) }}
/>What to build next
- Event detail — dates, venue, stages and brand colours. Usually the first call a site makes.
- FAQs — answers arrive as sanitised HTML, ready to inject.
- News — announcement posts, newest first.
- Ticket availability — live prices and what is sold out.
- Content version — a cheap probe so you can poll for changes without re-pulling the lineup.