# Grofomo API
> Build an event website on live festival data. Read-only JSON feeds for an event's published lineup, FAQs, news, announcements, ticket availability and merch, plus light writes for mailing-list signups and forms.
Three things to get right before writing any code:
- Send `?surface=web` on every request. It defaults to the mobile-app surface, which is gated on a different visibility setting, so omitting it can return `404` for a live event.
- Authenticate with an `X-Grofomo-Key` header. Publishable keys (`pk_live_…`) are safe in browser code; secret keys (`sk_live_…`) are server-only. Choose by where the code runs.
- Every response is `{ "apiVersion": 1, "data": … }`, or `{ "apiVersion": 1, "error": { "code", "message" } }` on failure. Branch on `code`, never on `message`.
Every page below is also available as HTML at the same path without `.md`.
## Start here
- [Overview](https://developers.grofomo.com/index.md): What this API is, and what you can build with it.
- [Quickstart](https://developers.grofomo.com/quickstart.md): Your first request, and a working lineup page in about ten minutes.
- [For organisers](https://developers.grofomo.com/organisers.md): Give your web developer a key, and choose which domains may use it.
## Concepts
- [Authentication](https://developers.grofomo.com/concepts/authentication.md): Publishable and secret keys, where each one belongs, and the two failure codes.
- [Surfaces](https://developers.grofomo.com/concepts/surfaces.md): Why every website request needs `?surface=web`, and what happens without it.
- [Responses and errors](https://developers.grofomo.com/concepts/responses.md): The response envelope, the error shape, and how to tell them apart.
- [Caching and rate limits](https://developers.grofomo.com/concepts/caching.md): How long each feed is cached, and how to poll cheaply.
- [CORS and allowed domains](https://developers.grofomo.com/concepts/cors.md): What the domain allowlist does, and the thing it deliberately does not do.
## API reference
- [All endpoints](https://developers.grofomo.com/reference.md): Index of every documented endpoint.
## Event
- [Event detail](https://developers.grofomo.com/reference/events/detail.md): Core event info: dates, venue, stages and brand.
- [Content version](https://developers.grofomo.com/reference/events/version.md): A cheap probe for "has anything changed?".
- [Organiser profile](https://developers.grofomo.com/reference/organisers/detail.md): Organiser bio, branding and their upcoming events.
## Lineup
- [Event lineup](https://developers.grofomo.com/reference/events/lineup.md): Published lineup: artists, stages and set times.
- [Artist detail](https://developers.grofomo.com/reference/events/artist.md): Full artist profile, plus their sets at this event.
## Content
- [Event FAQs](https://developers.grofomo.com/reference/events/faq.md): Categorised FAQs, with answers pre-rendered to HTML.
- [News index](https://developers.grofomo.com/reference/events/news.md): Published news articles, newest first.
- [News article](https://developers.grofomo.com/reference/events/news-article.md): A single published article, with its full body.
- [Announcements](https://developers.grofomo.com/reference/events/broadcasts.md): Announcements the organiser has sent, newest first.
## Tickets & merch
- [Ticket availability](https://developers.grofomo.com/reference/events/tickets.md): Visible ticket types and release tiers, with live availability.
- [Merch catalogue](https://developers.grofomo.com/reference/events/merch.md): Garments, colours, sizes, print zones and prices.
## Forms & signups
- [Mailing-list signup](https://developers.grofomo.com/reference/events/subscribe.md): Add an email to the organiser's mailing list from your own form.
- [Form definition](https://developers.grofomo.com/reference/forms/definition.md): The fields of a published form, so you can render it yourself.
- [Submit a form](https://developers.grofomo.com/reference/forms/submit.md): Send answers back for a published form.
## Discovery
- [Event search](https://developers.grofomo.com/reference/events/search.md): Search public events across all organisers.
## More
- [Webhooks](https://developers.grofomo.com/webhooks.md): Receive ticket sales and signups on your own endpoint, with signature verification.
- [Selling tickets](https://developers.grofomo.com/checkout.md): How to sell from your own site today, and the embed that is coming.
- [For AI agents](https://developers.grofomo.com/ai.md): Machine-readable entry points: llms.txt, markdown twins and an OpenAPI document.
- [Changelog](https://developers.grofomo.com/changelog.md): What changed, and how versioning works.
## Optional
- [https://developers.grofomo.com/llms-full.txt](https://developers.grofomo.com/llms-full.txt): every page above, concatenated into one file.
- [https://developers.grofomo.com/openapi.json](https://developers.grofomo.com/openapi.json): OpenAPI 3.1 for every documented endpoint.
---
# Grofomo API
Build an event website on live festival data.
Organisers run their festival in Grofomo — lineup, stages, set times, FAQs, news, tickets, merch. This API hands you that same data as JSON, already published and already public, so the site you build stays right without anyone re-typing a running order into a CMS.
## One request
```bash
curl -sS \
-H 'X-Grofomo-Key: pk_live_YOUR_PUBLISHABLE_KEY' \
'https://api.grofomo.com/v1/events/summer-fest/lineup?surface=web'
```
## Where to go next
- [Quickstart](/quickstart.md): Your first request, and a working lineup page in about ten minutes.
- [For organisers](/organisers.md): Give your web developer a key, and choose which domains may use it.
- [Authentication](/concepts/authentication.md): Publishable and secret keys, where each one belongs, and the two failure codes.
- [Surfaces](/concepts/surfaces.md): Why every website request needs `?surface=web`, and what happens without it.
- [Responses and errors](/concepts/responses.md): The response envelope, the error shape, and how to tell them apart.
- [Caching and rate limits](/concepts/caching.md): How long each feed is cached, and how to poll cheaply.
- [CORS and allowed domains](/concepts/cors.md): What the domain allowlist does, and the thing it deliberately does not do.
- [Webhooks](/webhooks.md): Receive ticket sales and signups on your own endpoint, with signature verification.
- [Selling tickets](/checkout.md): How to sell from your own site today, and the embed that is coming.
- [For AI agents](/ai.md): Machine-readable entry points: llms.txt, markdown twins and an OpenAPI document.
- [Changelog](/changelog.md): What changed, and how versioning works.
- [API reference](/reference.md): every documented endpoint, grouped.
---
# Quickstart
Your first request, and a working lineup page in about ten minutes.
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](/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
```bash
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-Key`** identifies the organisation. Most organisers now require it; some older ones do not yet. Send it either way — see [Authentication](/concepts/authentication).
- **`?surface=web`** tells 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 answer `404`. This trips up nearly everyone once. See [Surfaces](/concepts/surfaces).
## What comes back
Every response is wrapped in the same envelope:
```json
{
"apiVersion": 1,
"data": { "eventSlug": "summer-fest", "slots": [] }
}
```
Errors use the same envelope with `error` instead of `data`:
```json
{
"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.
```javascript
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();
return (
{lineup.slots.map((slot) => (
-
{slot.artist?.name ?? slot.placeholderLabel}
{' — '}
{slot.stage} · {slot.day} · {slot.startTime}
))}
);
}
```
Note `slot.artist?.name ?? slot.placeholderLabel`. An unannounced set has no artist yet, and rendering "undefined" on a public festival page is a bad afternoon.
## 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:
```javascript
```
## What to build next
- [Event detail](/reference/events/detail) — dates, venue, stages and brand colours. Usually the first call a site makes.
- [FAQs](/reference/events/faq) — answers arrive as sanitised HTML, ready to inject.
- [News](/reference/events/news) — announcement posts, newest first.
- [Ticket availability](/reference/events/tickets) — live prices and what is sold out.
- [Content version](/reference/events/version) — a cheap probe so you can poll for changes without re-pulling the lineup.
---
# For organisers
Give your web developer a key, and choose which domains may use it.
Your event data belongs to you. This page is how you hand a web developer the parts they need — and nothing else.
Everything here lives in **EventOS → Settings → Developers**.
## Give your developer a key
Two kinds of key exist, and the difference matters:
| | Publishable (`pk_live_…`) | Secret (`sk_live_…`) |
|---|---|---|
| Where it belongs | A website's front-end code | A server, or a build pipeline |
| Visible to the public? | Yes, by design | Never |
| Shown to you | Any time | Once, at creation |
**Hand over a publishable key.** It is meant to be readable in your website's page source: it identifies your organisation, lets us rate-limit and attribute traffic, and can be revoked the moment you want it to stop working. What it is *not* is a password. Anyone who views source can copy it — which is exactly why it can only ever read the data you have already chosen to publish.
A secret key is for a developer whose site fetches on their server. Treat it like a password.
## Choose which domains may use it
Add your developer's domains to **Allowed domains** — the live site, and their staging URL if they have one. Entries can be:
- `https://www.yourfestival.com` — that exact site
- `https://*.yourfestival.com` — any subdomain of it
- `*` — any site at all
This stops someone lifting your publishable key out of your page source and using it to power *their* site's festival listing. It is a browser-level control, so it does not affect calls made from a server.
## Two access modes
Under **Public API access** you have two settings:
- **Open** — the feeds answer without a key. This is where every organisation created before we introduced keys still sits, so nothing broke.
- **Key required** — every request must carry one of your keys. **New organisations start here**, with a publishable key already minted for you.
If you are on **Open** and want to move: mint a key, give it to whoever builds your site, confirm their site works with it, *then* switch. The order matters — flipping first will break a live site mid-sentence.
One side effect worth knowing: once you require a key, your feeds stop being cached on our shared CDN (a cached copy could otherwise be replayed to someone without a key). Your site will do slightly more work; your developer can cache on their side, and [we explain how](/concepts/caching).
## What to send your developer
Copy this:
```text
Event slug: summer-fest
API base: https://api.grofomo.com
Publishable key: pk_live_… (from Settings → Developers)
Docs: https://developers.grofomo.com
```
## Turning a key off
Revoke it in the same place. Revocation is immediate — the next request using it gets a `401`. Mint the replacement, get it deployed, then revoke the old one, so there is no gap.
---
# Authentication
Publishable and secret keys, where each one belongs, and the two failure codes.
Requests are authenticated with a key in the `X-Grofomo-Key` header.
```bash
curl -H 'X-Grofomo-Key: pk_live_YOUR_PUBLISHABLE_KEY' \
'https://api.grofomo.com/v1/events/summer-fest/lineup?surface=web'
```
## Two kinds of key
**Publishable — `pk_live_…`**
Safe to ship in browser code. Buys you attribution, a rate-limit budget of its own, optional scoping to a single event, and instant revocation. It buys you **no secrecy at all**: it is visible in page source, and that is the design, not a leak. It can only read what the organiser has already published.
**Secret — `sk_live_…`**
For a server or a build step. Stored as a hash, so it is shown once at creation and never again. If it ends up in a browser bundle, treat it as compromised and rotate it.
Which to use is decided by *where the code runs*, not by how sensitive the data feels. Front-end fetch → publishable. Server-side fetch, static build, or a scheduled job → secret.
## Passing the key
- `X-Grofomo-Key: ` — the header. Always works, for both kinds.
- `?key=` — the query string. **Publishable keys only.** A secret key sent this way is refused outright, because query strings end up in access logs, `Referer` headers and browser history. The refusal is deliberate: silently accepting it would leak the key while looking like it worked.
## When a key is required
Each organisation is in one of two modes:
- **`open`** — requests without a key are answered. Organisations that predate keys are still here.
- **`key_required`** — a valid key is mandatory. Every new organisation starts here.
You cannot tell which mode an organisation is in from the outside, and you should not try. **Always send a key.** In `open` mode it is accepted and ignored; the day the organiser switches, your integration is already correct.
## Failures
**`401 unauthorized`** — no key was sent and one is required, or the key is invalid, revoked, belongs to a different organisation, or is scoped to a different event. All of these return the same code on purpose: distinguishing them would let anyone probe which keys exist.
**`403 origin_not_allowed`** — the browser's `Origin` is not on the organiser's allowed-domains list. Only browsers can hit this; a server sends no `Origin` header. See [CORS and allowed domains](/concepts/cors).
Neither is retryable. Both mean a configuration conversation with the organiser, not a backoff loop.
## Rotation
Keys do not expire. When one needs replacing: mint the new key, deploy it, confirm traffic has moved, then revoke the old one. Revocation takes effect on the next request, so revoking first means downtime.
---
# Surfaces
Why every website request needs `?surface=web`, and what happens without it.
Almost every endpoint takes a `surface` parameter. Websites must send `?surface=web`.
```bash
https://api.grofomo.com/v1/events/summer-fest/lineup?surface=web
```
## Why it exists
The same feeds serve two very different consumers: the Grofomo mobile app, and websites like yours. Organisers control those independently — an event can be live in the app while its website content is still being prepared, or the reverse. `surface` says which of those two visibility settings applies to your request.
## Why omitting it bites
**The parameter defaults to `app`, not `web`.** So a request without it is not "unscoped", it is a mobile-app request. If the organiser has published the event to the web but not to the app, that request gets `404 not_found` — for an event you can see is live, with a key you know is valid, at a URL you know is right.
If a call 404s and you cannot see why, check for `?surface=web` before checking anything else.
## Where it is not needed
A few endpoints are web-only by nature and ignore the parameter — the news feeds, for instance, always serve web content. Each reference page states whether `surface` is required. Sending it where it is not needed is harmless, so the simplest correct habit is to send it every time.
---
# Responses and errors
The response envelope, the error shape, and how to tell them apart.
Every endpoint — success or failure — returns the same envelope.
## Success
```json
{
"apiVersion": 1,
"data": { }
}
```
## Failure
```json
{
"apiVersion": 1,
"error": {
"code": "not_found",
"message": "Event not found"
}
}
```
`code` is a stable, machine-readable string — branch on it. `message` is written for a developer reading a log, and its wording may change; never parse it.
## Handling both
Because the shape is uniform, one helper covers the whole API:
```javascript
async function grofomo(path) {
const res = await fetch(`https://api.grofomo.com${path}`, {
headers: { 'X-Grofomo-Key': process.env.GROFOMO_SECRET_KEY },
});
const body = await res.json();
if (!res.ok) {
throw new Error(`${body.error.code}: ${body.error.message}`);
}
return body.data;
}
```
Read the body once. `res.json()` consumes the stream, so calling it in both branches throws on the second call.
## Status codes
| Status | Code | What it means |
|---|---|---|
| 200 | — | Fine. |
| 400 | `bad_request` | The request body or a parameter did not validate. |
| 400 | `turnstile_failed` | The bot-check token was missing or invalid. |
| 401 | `unauthorized` | No key, or a key that is invalid, revoked, or out of scope. |
| 403 | `origin_not_allowed` | The browser's domain is not on the organiser's allowlist. |
| 404 | `not_found` | No such event on this surface. Check `?surface=web`. |
| 429 | — | Rate limited. Honour `Retry-After`. |
| 500 | `fetch_failed` | Our fault. Safe to retry. |
Retry `429` and `5xx`. Never retry `401`, `403` or `404` — they mean something is configured wrong, and a retry loop just turns one problem into a traffic problem.
## Versioning
`apiVersion` is `1` and will stay `1` while changes are additive. New fields can appear at any time, so **ignore fields you do not recognise** rather than validating strictly against a fixed shape.
A breaking change would arrive as a new version, announced in the [changelog](/changelog) before it lands.
---
# Caching and rate limits
How long each feed is cached, and how to poll cheaply.
## How long responses live
Most read endpoints are cached at our edge for 60 seconds, with a 5-minute stale-while-revalidate window:
```text
Cache-Control: public, s-maxage=60, stale-while-revalidate=300
```
So a lineup publish can take up to a minute to appear on your site. That is the trade for a feed that survives an on-sale.
Some endpoints differ and say so on their own page — [content version](/reference/events/version) is 15 seconds, and anything that changes by the second (merch stock, announcements, any write) is `no-store`.
## The one that surprises people
**When an organiser is in `key_required` mode, their responses are `no-store`.** Not slower — uncached entirely.
The reason is that a shared CDN keys on URL, not on credentials. If a key-holder's `200` were cached, the next anonymous caller would be served that cached copy and the key gate would be worth nothing.
The practical consequence: your own caching does the work. In Next.js, `next: { revalidate: 60 }`; anywhere else, a small in-process cache or a scheduled build. This is why the [quickstart](/quickstart) fetches on the server rather than from the browser — one cached server fetch serves every visitor, and a per-visitor browser fetch cannot be cached at all.
## Polling without the cost
Do not poll the lineup. Poll the version probe instead — it is tiny, and it moves whenever the event or its lineup is edited:
```javascript
const { data } = await fetch(
'https://api.grofomo.com/v1/events/summer-fest/version',
).then((r) => r.json());
if (data.scheduleVersion !== lastSeenVersion) {
// Only now pull the heavy feeds.
}
```
## Rate limits
Read endpoints allow roughly 60 requests per minute; writes (signups, form submissions) roughly 30. Over the limit you get `429` with a `Retry-After` header.
Two honest caveats:
- **These are guidance, not a contract.** The limiter is per-server-instance, so the effective ceiling in production is higher and not exactly predictable. Do not design something that depends on the precise number.
- **A key gets its own budget.** Without one, you share a bucket with everyone on your IP — which, behind a corporate proxy or a serverless egress pool, may not be only you.
If you cache as described above, you will not come near any of this.
---
# CORS and allowed domains
What the domain allowlist does, and the thing it deliberately does not do.
Organisers keep a list of domains allowed to call their feeds from a browser. A request whose `Origin` is not on that list is refused with `403 origin_not_allowed`.
## Entries
- `https://www.yourfestival.com` — exact origin
- `https://*.yourfestival.com` — any subdomain, same scheme
- `*` — any origin (an explicit opt-in)
The header we echo back is always the concrete request origin, never the pattern, so credential-less cross-origin reads work in every browser.
## What it does
It stops a lifted publishable key from working somewhere else. Your key is in your page source by design; the allowlist means someone who copies it still cannot use it to power their own site's festival listing from a browser.
## What it does not do
**CORS is not access control.** It is a rule browsers agree to follow. `curl` does not follow it, and neither does anything else outside a browser:
```bash
# No Origin header, so no CORS decision to make.
curl 'https://api.grofomo.com/v1/events/summer-fest/lineup?surface=web'
```
That is not a hole — it is what CORS is. It is also the reason API keys exist: the allowlist is the second layer, and the key is the first. An organiser who wants their feeds genuinely closed sets `key_required`; the allowlist alone would only inconvenience browsers.
## Server-side calls
A server sends no `Origin` header, so the allowlist never applies to it. If your site fetches during build or render, allowed domains are irrelevant to you — only your key matters.
## Preflight
`OPTIONS` preflight requests are never gated. A browser must be able to complete the handshake before it can send the credential the handshake is about.
---
# API reference
Every documented endpoint on the Grofomo public API, grouped by resource.
## Event
- [Event detail](/reference/events/detail.md): Core event info: dates, venue, stages and brand.
- [Content version](/reference/events/version.md): A cheap probe for "has anything changed?".
- [Organiser profile](/reference/organisers/detail.md): Organiser bio, branding and their upcoming events.
## Lineup
- [Event lineup](/reference/events/lineup.md): Published lineup: artists, stages and set times.
- [Artist detail](/reference/events/artist.md): Full artist profile, plus their sets at this event.
## Content
- [Event FAQs](/reference/events/faq.md): Categorised FAQs, with answers pre-rendered to HTML.
- [News index](/reference/events/news.md): Published news articles, newest first.
- [News article](/reference/events/news-article.md): A single published article, with its full body.
- [Announcements](/reference/events/broadcasts.md): Announcements the organiser has sent, newest first.
## Tickets & merch
- [Ticket availability](/reference/events/tickets.md): Visible ticket types and release tiers, with live availability.
- [Merch catalogue](/reference/events/merch.md): Garments, colours, sizes, print zones and prices.
## Forms & signups
- [Mailing-list signup](/reference/events/subscribe.md): Add an email to the organiser's mailing list from your own form.
- [Form definition](/reference/forms/definition.md): The fields of a published form, so you can render it yourself.
- [Submit a form](/reference/forms/submit.md): Send answers back for a published form.
## Discovery
- [Event search](/reference/events/search.md): Search public events across all organisers.
## Deliberately not documented
The mobile app's device endpoints, the door scanner's check-in surface, wallet passes, ticket capability links and the raw checkout flow (quote, reserve, pay) are internal. Selling tickets from your own site will arrive as a drop-in embed rather than a documented write API. These are omissions by decision, not gaps.
---
# Event lineup
`GET /v1/events/{slug}/lineup`
The published lineup for an event — every set, with its stage, start and end time.
Slots arrive as one flat `slots` array rather than pre-grouped, so you can group by `day`, by `stage`, or by both, depending on how your page is laid out. A back-to-back set carries its full roster in `artists`, with the lead act repeated in `artist`; an unannounced slot has `artist: null` and a `placeholderLabel` to render instead.
This reads the organiser's **published** snapshot, never their working draft. A lineup being rearranged in the console will not appear here until it is published, which is what makes it safe to render straight onto a public page.
On the web surface the response also carries `schemaOrg`: a ready-made `MusicEvent` JSON-LD block you can drop into a `