Grofomo API
Browse the docs

Changelog

What changed, and how versioning works.

Examples use a fictional festival, summer-fest. Sign in at events.grofomo.com and they switch to your own events.

How versioning works

The API is at apiVersion: 1, and stays there while changes are additive — new fields, new endpoints, new optional parameters.

Which means: ignore fields you do not recognise. A client that validates strictly against today's exact shape will break on a change designed not to break anything.

A genuinely breaking change would ship as a new version, announced here first, with the old one kept alive during the overlap.

Changes

8 October 2026 - artist links reach your own ticket page

An artist's Grofomo link (grfm.to/e/<event>/a/<artist>) for an event that sells on your own site now arrives there with ?artist=<artistId> and the link's other tags, where before it arrived bare and the artist was lost. checkout.js 1.10.0 reads it: the drop-in lists the tickets kept for that artist's fans and credits the sale to them, and it now also sends the ambassador code when it loads the catalog, so an affiliate-gated ticket is listed from the start. A site with its own checkout gets GrofomoCheckout.artist(), and api.catalog takes the buyer's credentials as a second argument (see Selling tickets). Additive. Not part of the /v1 contract.

5 October 2026 - say whether the person who shared the link may see the buyer's name

The details step takes a new optional boolean, showNameToReferrer. When a buyer came through an artist's, Ambassador's or other affiliate's link, that person can now see who came through it by first name and surname initial ("Matt S"), with the ticket count, the time, and whether they finished buying. Send true only when your checkout printed a notice saying so and the buyer did not tick a box to hide it; leave it out otherwise and the order stays anonymous to them, exactly as before. Additive: a client that never sends it sees no change.

30 September 2026 - lineup artists say whether they are verified

Every artist on a lineup, and the artist detail response, has a new boolean, verified. It is true when the artist has claimed their Grofomo profile, so the name, photo and bio come from the artist themselves rather than the organiser's notes. Show a verified mark beside the name, as the hosted event sites now do. Additive: on the lineup it is always sent, and a client that ignores it sees no change.

30 September 2026 - checkout.js tags its pixel events with the event

checkout.js 1.9.2 adds grofomo_event (the event's slug) to the ViewContent and Purchase it reports to your page's Meta Pixel, and the purchase Grofomo sends server-side carries it too. The custom audiences a Grofomo campaign builds match on it, so people who viewed or bought through the drop-in on your own site now count in the event's retargeting and buyer audiences; before this those audiences only counted the Grofomo ticket page. A page firing its own events (pixel: false) should add it as well. Additive (see Selling tickets). Not part of the /v1 contract.

30 September 2026 - artists have a page of their own

Two additive shapes for the attendee app, documented here because they are part of the /v1 contract. GET /v1/artists/{slug} is the artist behind a free artist page (grfm.to/a/{slug}) outside any event: profile, socials, follower count, and their released upcoming shows in events the app can open. GET and PUT /v1/devices/me/artist-follows let a device follow an artist across events, on the signed device-token surface the app already uses for favourites. Neither needs an organiser API key.

30 September 2026 - search cards carry the logo's colour

Every result from event search, and each of an organiser's upcomingEvents, has two new optional fields. logoBackgroundColor is the hex colour to sit the card's logo on (the event's page background, else its first brand colour), so a listing can paint the thumbnail in its brand colour straight away instead of waiting for the event's own record. logoVectorUrl is the hosted SVG logo when the organiser uploaded one. Both are null when there is nothing to send.

29 September 2026 - prelaunch.registration says what it confirms

prelaunch.registration still fires once per proven channel, so a registrant who types the SMS code and later clicks the email link arrives twice. Two new message tags tell the two apart: {{confirmed_channels}} is the channel this notification confirms (where {{channels}} is everything granted so far), and {{registration_kind}} is New the first time and Updated after. The default message now uses both. A resubmission of the form that grants nothing new no longer fires at all. JSON subscribers get the same two facts as additive payload fields: registrationKind (new or updated) and confirmedChannels (e.g. ["whatsapp"]).

29 September 2026 - carer tickets

Four new endpoints let an organiser's own site offer free carer tickets: carer tickets says whether the form is open and carries its words, then match, upload proof and apply are the form's three steps. The organiser approves or denies each application in Grofomo; approval emails the carer a free ticket in the attendee's own order, so the door checks the pair in together. Three webhook events go with it, carer.applied, carer.approved and carer.denied (see webhooks). Additive: nothing changes unless the organiser switches carer tickets on.

29 September 2026 - a public MCP server for the event directory

https://api.grofomo.com/mcp/events is a read-only, keyless MCP server with three tools: search_events, get_event and get_lineup. It reads what the directory and /v1 already publish, so an assistant can answer from the source and cite the event page. Not part of the /v1 contract; see MCP server.

29 September 2026 - the server-side Purchase to Meta needs the buyer's consent

Reserve and details take adConsent: whether the buyer agreed to advertising measurement on your page. Grofomo posts the purchase to the organiser's Meta Pixel server-side only when it is true, now with the buyer's email hashed for matching, and posts nothing for an order without it. Before this the purchase was sent for every order. Additive on the wire, but a page that sends nothing stops being reported to Meta: checkout.js 1.9.1 reads your cookie banner and sends it for you (see Selling tickets).

29 September 2026 - checkout.js hands your page the amount paid

checkout.js 1.9.0 reports to a Meta Pixel the page has already loaded: ViewContent when the tickets are listed and Purchase when the order completes, with the order id as the event id so it dedupes against the purchase Grofomo sends server-side. It loads no pixel itself; pixel: false on mount turns the reporting off. It also adds totalPennies (what the buyer paid today, the deposit on a payment plan) and currency to the onComplete callback, so a page can count the sale on its own analytics without a second request. Additive (see Selling tickets). Not part of the /v1 contract.

24 September 2026 - quote prices every payment plan the cart can take

Quote now returns paymentPlans: each plan the cart can take right now, with the deposit, the balance and the schedule reserve would write, in the same shape as paymentPlan on reserve. Additive. A client that worked the figures out from the offer's terms could disagree with reserve: a fixed per-ticket deposit counts only paid units, so a ticket a buy-N-get-M deal makes free carries none, and counting it could push the instalments under the £5 floor and hide a plan reserve would have accepted. checkout.js 1.8.1 uses the quote's figures in the drop-in and in GrofomoCheckout.planOptions, falling back to its own estimate only against a server that does not send them.

24 September 2026 - checkout.js sells payment plans

checkout.js 1.8.0 offers an event's payment plans in the drop-in. When every paid tier in the basket lists a plan, the buyer chooses between paying in full and each plan, priced for their basket, and sees the exact schedule from reserve before paying the deposit. A plan that closes before reserve falls back to paying in full with an explanation. Sites that render their own checkout can price the same choice with the new GrofomoCheckout.planOptions(catalog, items, quote). No code change is needed on your side.

24 September 2026 - checkout.js carries tags on your links, and hands a custom page its funnel token

checkout.js 1.7.0 adds three things for a site that renders its own checkout on the client. GrofomoCheckout.withTags(href) returns a link to another of your pages with this page's tags added, so a sale later in the same visit is credited without storing anything. GrofomoCheckout.affiliate() returns the ambassador code to send as affiliateCode, sticky across pages once the visitor has agreed. GrofomoCheckout.funnelToken(event) returns the funnel token the drop-in uses, kept for the tab only with consent, and forgotten when consent is withdrawn. client.event() reads the event record, and a failed connection now throws GrofomoCheckout.Error with code network (status 0) instead of the browser's own error. Otherwise additive: nothing changes for a page that already loads the script.

24 September 2026 - checkout.js keeps its funnel token only with consent

checkout.js 1.6.1 keeps the funnel token for the tab only when it may remember visits (your cookie banner's analytics answer, or GrofomoCheckout.rememberVisits(true)). Without consent each load counts as its own visit. A new What needs consent section lists what needs asking about, what does not, and lines for your cookie notice. No code change is needed on your side.

24 September 2026 - checkout.js follows your cookie banner

checkout.js 1.6.0 switches on its memory of tagged visits by itself when your cookie banner grants analytics consent, read from Google Consent Mode, Cookiebot, OneTrust or CookieYes, and switches it off again (forgetting what it stored) if the visitor withdraws. GrofomoCheckout.rememberVisits() still works, and a call to it now takes over from the banner for the rest of the page. No code change is needed on your side.

24 September 2026 - checkout.js can remember where a buyer came from, and three new channels

checkout.js 1.5.0 can remember every tagged visit on any page that loads it, in the browser's storage for your site, and send the latest with the first as firstTouch on reserve, so a tag on your landing page survives the click through to your tickets page. It is off until you call GrofomoCheckout.rememberVisits(true), because that storage is yours to disclose and, where your cookie notice says so, to ask about. It now reads Grofomo's ?ref= (short links and poster QRs) and ?artist= as the hosted page does, and adds GrofomoCheckout.attribution() for sites that render their own checkout.

Three attribution channels are new on reserve: sms, ads (an ad campaign built through Grofomo, tagged utm_medium=paid_social with the campaign in utm_content) and discover (a click from the Grofomo directory, utm_medium=discover). Grofomo's short links and event links now pass ref, aff, artist, gclid and fbclid on to your site as well as utm_*. Additive: a client that validates channel against the old list should accept the new values.

23 September 2026 - An order card says why it was cancelled

Every order in GET /v1/devices/me/tickets and the ticket finder's order list can now carry cancellation: a reason (event_cancelled, payment_plan, refunded or cancelled) and at, when it happened, or null where no date is recorded. It is null while the order still holds a live ticket and its event is on. payment_plan is a plan that stopped before it was paid off: its tickets are voided, but the order's own status still reads completed, so check cancellation rather than status to tell the buyer. Absent on older servers.

23 September 2026 - New: the Ambassador programme for your own site

Two endpoints and a drop-in for a festival's "bring your mates" page. GET /v1/events/{slug}/ambassadors returns the published reward ladder, the organiser's rules, whether ticket buyers are enrolled automatically, and whether partner applications are open. POST /v1/events/{slug}/ambassadors/applications sends a promoter's application for a cash rate into the organiser's review queue. ambassadors.js renders the tiers, rules and form into your page; see the guide.

21 September 2026 - The abandoned-basket email returns the buyer to the page they left

Reserve takes an optional resumeUrl: the page running the checkout. When that basket is abandoned, Grofomo's nudge sends the buyer back to it, with ?basket= and ?promo= as before, instead of to the event's ticket link or the hosted page. It is kept only when it is https, on one of the organiser's allowed domains, and on the same origin as the calling page; anything else is ignored, not refused, and the email falls back as it always did. It is checked again when the email is sent, so a domain the organiser has since removed is not linked to. A buyer who abandoned on the hosted page now goes back to the hosted page even when the event names a ticket link. checkout.js 1.4.0 sends it automatically; see selling tickets.

21 September 2026 - A fixed plan deposit is no longer charged on a free ticket

A payment plan with a fixed deposit charges its amount per ticket. Until now that counted every ticket in the basket, including a £0 ticket and one a buy-N-get-M deal gives away. A buyer taking "buy one, get a free shuttle bus" was charged a deposit on the free bus, which also came off the balance being spread, so the instalments could drop under the minimum payment and reserve answered plan_unavailable for a basket that should have qualified.

The deposit is now charged only on tickets the buyer pays for, the same count the booking fee has always been priced on. For a basket with no free ticket nothing changes. For one with a free ticket, reserve can now accept a plan it used to refuse, and the depositPennies it returns is lower by one fixed deposit per free ticket. Show the deposit reserve returns rather than working it out yourself.

18 September 2026 - A reward claim takes a postal address in its own fields

POST /v1/devices/me/ambassador/reward-claims now takes deliveryAddress: recipient (optional), line1, line2 (optional), city, postcode and country (ISO 3166-1 alpha-2, defaulting to GB). line1, city and postcode are required once an address is sent at all, because a parcel cannot be posted without them; send deliveryAddress: null to clear it.

It replaces one free-text box that asked for "your address and postcode, or how to reach you" and so collected phone numbers and social handles too. Asking for the parts of an address separately means an app collects, and declares, exactly a postal address.

deliveryNote is unchanged and still accepted, so an app build already installed keeps working: it is free text up to 500 characters, blank to clear. When both are sent the address wins. The note is no longer only what someone typed, though – the server composes it from the address, one field a line, and that is what an organiser reads on their fulfilment screen. Do not assemble it yourself.

Every reward in GET /v1/devices/me/ambassador/rewards now carries deliveryAddress alongside deliveryNote, for prefilling the claim form. It is null on a reward claimed before these fields existed, or claimed through a surface that still writes free text, so fall back to deliveryNote when showing where a reward is going. Additive.

18 September 2026 - site.content_changed is batched per editing session

The website-rebuild notification now waits for an editing session to finish instead of firing on every change.

Before, changes coalesced only when they landed inside the same wall-clock minute. An organiser working through a lineup produces a change roughly every minute, which slips between those buckets almost every time: one session on 17 September sent sixteen notifications in sixty-six minutes, each one a full site rebuild.

A change now waits about two minutes for the next one and the notification goes out once they stop, so that session would send four. A session that never goes quiet is capped: the notification goes out at most fifteen minutes after the first change of a burst, and the batch starts again from there.

Nothing about the payload changes, and the last change of a session always produces a notification. The one thing to expect differently: a change made on its own now reaches you a couple of minutes after it is made rather than immediately. If your build is triggered by this hook, that delay is added to your build time, not hidden inside it.

Changes are still batched per event and per contentType, so a lineup change and a news article published together still arrive as two notifications, each naming what it covers.

18 September 2026 - A hidden tier counts in the sell-out ladder, and a closed rung releases the next

A tier set to start when the previous one sells out waits for the tier above it in sort_order even when that tier is hidden. Before, a hidden tier was dropped from the feed entirely, so the tier after it became the first rung and opened straight away: an event running a held-back pre-sale sold its public tier at full price from the moment it went live, against the organiser's explicit setting.

So a tier you were shown as purchasable may now arrive as upcoming: true, and reserve answers not_on_sale for it, for every requester alike. The ladder is a property of the event, not of who is looking, so holding the unlock code for the hidden tier does not open the tier above it either. Read upcoming rather than inferring that anything listed can be bought.

In the same pass, a rung is now DONE when it sells out or when its own sale-end passes, whichever comes first. Waiting only for the sell-out meant a tier that closed on its date without selling out shut every tier below it permanently, which an event running a held-back pre-sale could reach with no way back. This can only ever open a tier that was stuck, never close one that is open.

18 September 2026 - A promo code can now reveal a hidden TIER, not just a gated ticket

?promo= on GET /v1/events/{slug}/tickets already revealed ticket types whose visibility is gated and which list a promo code as an unlock. It now also reveals an individual hidden tier inside an otherwise ordinary ticket, when the code names that tier. Those tiers arrive in the usual releases array and are quoted and reserved like any other, as long as the same code rides on those calls.

The scoping rule differs from the one for gated types, deliberately. A gated type names the credential KINDS that open it, so any live unlock code opens it. A hidden tier names nothing, so it is opened ONLY by a code scoped to it: an unlock code with no tier scope reveals no hidden tier at all. Nothing an organiser has hidden becomes visible without them saying so tier by tier.

Nothing changes for a request without a code, and no field changed shape. If you list tiers yourself, a cart mixing an unlocked tier with public ones is still one order: send the code on quote and reserve exactly as you send it on the feed.

One consequence for a page on the checkout contract: the abandoned-basket recovery link now carries ?promo= when the basket was priced with a code. Load the feed with it BEFORE clamping the ?basket= seed, or the clamp drops the unlocked tier the buyer came back for. Selling tickets says how; checkout.js already does it.

17 September 2026 - The booking fee is now exactly linear in quantity

The fee-inclusive price you advertise is now never exceeded. n of a tier cost exactly n x the feeInclusivePricePennies on the ticket feed.

Before, the percentage component of the booking fee was rounded down once against the whole order, while the advertised per-unit price rounded down per ticket. Because floor(n*x) >= n*floor(x), an order's fee could exceed n x the advertised unit price by up to n-1 pennies. An organiser who set a face value so their tier advertised a round £30.00 found that two in the basket came to £60.01.

bookingFeePennies on quote and reserve therefore changes for carts of more than one, by pennies, always downward. If you show your own running total before calling quote, and you compute it as quantity x the advertised price, it now agrees with us exactly.

This covers a cart carrying a free extra, such as "buy one, get a free shuttle bus", because the paid items keep their advertised price and only the given-away unit changes. Those carts were charged the old way for a few hours after this shipped, and are not any more.

Two cases are unchanged, and neither has an advertised total left to hold to: a cart whose items were RE-PRICED, by a promo code or a tiered rule, and a policy whose per-order minimum fee fires, where an order pays less than n x the advertised figure. Less is always safe.

17 September 2026 - The prelaunch form's privacy link is now the organiser's own

privacyPolicyUrl on GET /v1/events/{slug}/prelaunch now resolves from the organiser's legal settings, the same source the ticket feed's checkout.legal.organiser.privacyPolicyUrl already used, rather than from a separate field on the prelaunch form.

Nothing about the field's shape changes. It is still a nullable string and you still link it beside the consent wording. What changes is that it is now reliably the ORGANISER's notice. The old field was pre-filled with Grofomo's own policy, and most forms kept it, so a registrant was frequently pointed at our privacy policy for data the organiser controls.

If you cache or hardcode this value, refresh it. For most organisers it is about to change to a different URL, and the one it changes to is the correct one. It is also what gets frozen into the registration's proof of consent, so a stale copy on your page and the recorded one will disagree.

17 September 2026 - Prelaunch consent wording v5, and a number that opts itself in

Two changes to GET /v1/events/{slug}/prelaunch and its register endpoint. formVersion is now prelaunch_form_v5.

The wording no longer promises a launch moment. v4's email notice said we would email you "the moment <event> tickets go live". That assumes a prelaunch list always comes before a sellable ticket, which is false for a priority release, where the form runs alongside a tier that is already selling and registrants go straight to checkout. It also described one event's ticket launch when the consent recorded has always been broader: registrants can be sent lineup news and news about the organiser's other events. The wording now matches what is actually collected, and carries no timing claim:

Registering signs you up for email updates about Twisted Roots 2027, plus other events from EVA Events. Unsubscribe anytime.

If you print consent.email verbatim, as you should, you get this automatically. Registrations already captured keep the wording they were shown.

fields.phone gains consentMode and required. consentMode is checkbox for every existing form, which is today's behaviour: a tick beside the number, sent back as consentSms.

An organiser can now choose implied instead, for a signup where the number is the priority-access check. There is no tick. Print consent.messaging as a notice immediately beside the number field, send no consentSms, and the number itself is the request: entering it and confirming the code we text is the affirmative act. Sending a number with no consentSms is refused under checkbox and accepted under implied, so branch on consentMode rather than assuming.

required is always false. Keep your own field optional too. A mandatory number makes the messaging consent a condition of registering when it is not needed to register, and a consent that is not freely given is not consent, so the list you build that way cannot be used.

The default submit-button label changed too, from "Email me when tickets go live" to "Email me about tickets", for the same reason: it is a timing promise that is false on a priority release. It is only a DEFAULT. An organiser who has set their own ctaLabel is unaffected, and any organiser can change it in the console. Render ctaLabel from the form endpoint rather than hardcoding it.

One behaviour note for direct callers: the consent-shape errors (phone_without_sms_consent, no_consent) now resolve after the event and form are looked up, because what a valid shape IS depends on the form's mode. A malformed body aimed at a slug that does not exist now answers 404 rather than 400.

17 September 2026 - No Turnstile token is required, and never was in production

Three write endpoints documented a Cloudflare Turnstile token as required and listed a turnstile_failed error: POST /v1/events/{slug}/subscribe, POST /v1/events/{slug}/prelaunch/register and POST /v1/forms/{key}/submissions.

The bot check is not enabled in production and has not been. The verifier returns success when it is unconfigured, so a turnstileToken is accepted and discarded, and turnstile_failed cannot be returned by any of the three. The reference said otherwise, the request examples told you to paste in a widget token, and at least one integration was built around a widget whose token was never checked.

You do not need to render a Turnstile widget to call these. If you already do, nothing breaks and you can leave it: the field stays in the request schema and is still ignored. If you were about to add one for these endpoints, do not bother yet.

What actually gates them is unchanged and is stated on each page: your publishable key, the organiser's allowed-domains list for browser calls, and a per-address rate limit. None of that is a bot check, so treat these endpoints as you would any unauthenticated public write on your own site.

We will announce it here before a token starts being required, because switching it on would break every browser integration that is not already sending one. The site key needed to render a widget is a public value and will be published here when there is one.

Also corrected on the prelaunch registration page: consentEmail was described as having to be true. The route requires at least one consent to be granted, and refuses a number without consentSms (or the tick without a number). Sending consentEmail: true remains what you want, since registering is the email consent under the wording you printed.

17 September 2026 - The organiser's own refund policy and privacy notice, on the ticket feed

GET /v1/events/{slug}/tickets?surface=web now carries a checkout.legal.organiser block. It is additive and optional: absent on older servers, and absent for an organiser who has set nothing yet.

It exists because a ticket sale is two supplies from two parties. Grofomo charges the booking fee and supplies the booking service; the ORGANISER sells the admission and is the data controller for everything your checkout collects. checkout.legal already carried Grofomo's half. This is theirs, and Grofomo's own policies do not discharge it.

{
  "checkout": {
    "legal": {
      "organiser": {
        "name": "Riot Promotions",
        "privacyPolicyUrl": "https://riot.example/privacy",
        "refundPolicyLine": "Free cancellation until 7 days before the event. You get the ticket price back; the booking fee is ours and stays with us.",
        "refundCancellationLine": "If the event is cancelled, you get everything back, booking fee included. If it moves to a new date or venue, or changes significantly, and you no longer want to go, you are entitled to a refund. That is your legal right, not the organiser's decision."
      }
    }
  }
}

Print the two refund lines as two lines. Not one paragraph. The booking fee comes back when an event is cancelled and does not when a buyer simply changes their mind, so the two sentences contradict each other when run together. refundPolicyLine is the organiser's discretionary position and is absent when they have set none; refundCancellationLine is the statutory floor, applies whatever the line above says, and is always present.

Print them at ticket selection, not on the payment screen. A refund policy is material information and has to reach the buyer in time to inform the decision to buy.

Neither line names a fee amount, because the booking fee is a percentage of the subtotal plus a per-ticket charge, then capped and floored, so it is not a single figure until the basket is known. Show the amount on its own line from the quote, as you already do.

privacyPolicyUrl is the organiser's own notice. Link it in addition to the Grofomo privacy link you already show, labelled so a buyer can tell the two apart.

A merchandise object is defined on the block for surfaces that sell goods. It is never sent on this endpoint: a ticket order cannot contain merchandise.

Using the drop-in? checkout.js 1.4.0 prints all of this for you: the two refund lines on the ticket step, and the organiser's privacy link in the footer beside ours. Older versions ignore the block and print neither, so upgrade if you rely on the drop-in to discharge this.

17 September 2026 - Event search answers a browser, from any origin

GET /v1/events/search returned a correct 200 with no Access-Control-Allow-Origin header and answered the browser preflight with a bare 204, so every browser call to it failed on every origin. It now carries CORS and answers OPTIONS.

Unlike every other feed, it grants any origin rather than resolving yours against an organiser's allowed-domains list. It is the one endpoint that spans organisations, so there is no single organiser whose allowlist could apply. You do not need to be allowlisted anywhere to call this one from a browser, and adding a domain to an organiser's list makes no difference to it either way.

That is a CORS grant and nothing more. It confers no credential and changes nothing about the rows returned: still only events whose organisers opted them in to public discovery, still the same ?surface= rules, still no key required.

The same caution as the five feeds above applies. The failure was invisible from the outside, so a fetch wrapped in a .catch() that falls back to placeholder content has been serving the placeholder and looking healthy. If you call search from a browser, confirm you are rendering what the API returned. If you call it from a server there was never anything to fix.

17 September 2026 - Event detail, version, updates, artist and organiser feeds answer a browser

Five endpoints returned a correct 200 with no Access-Control-Allow-Origin header, and answered the browser preflight with a bare 204 naming no allowed origin. Every browser call to them failed, on every origin, including allowlisted ones, while the same event's tickets, FAQ and lineup answered normally:

GET /v1/events/{slug}
GET /v1/events/{slug}/version
GET /v1/events/{slug}/broadcasts
GET /v1/events/{slug}/artists/{artistId}
GET /v1/organisers/{slug}

They now carry the same CORS headers as every other feed, resolved against the same allowed-domains list, and answer OPTIONS. Nothing about the response body, the visibility rules or the ?surface= parameter changed, and no key is needed that was not needed before: these five stay ungated.

Worth re-checking your own code. The symptom was silent from the outside. A browser refuses the response without the page ever seeing a failed status, so a fetch wrapped in a .catch() that falls back to placeholder content will have been serving the placeholder this whole time and looking healthy. If you call any of these five from a browser, confirm you are rendering what the API returned rather than your fallback, and make the fallback log when it fires.

If you call them from a server there was never anything to fix: a server sends no Origin, so CORS never applied.

GET /v1/events/{slug} and GET /v1/events/{slug}/version gated on the event's app visibility whatever surface was asked for, so an event published to the web, with its tickets, lineup and FAQ all answering, still said 404 not_found on both to the very site its ticket link pointed at. They now read the website visibility for ?surface=web, the rule every other event endpoint already applied. Send ?surface=web on these two calls too; the reference pages now say so.

17 September 2026 - The ticket feed carries the legal notices a checkout must print

The checkout block on GET /v1/events/{slug}/tickets gains a legal object, alongside the consent wording it already carried. Two parts, both required on any checkout you build: attestation, the 18+ and Grofomo Terms confirmation that goes beside the button committing the buyer to pay, and attribution, which names Grofomo on the page. attestation.linkText is a verbatim substring of attestation.text, so you wrap that substring in a link to termsUrl rather than reassembling the sentence.

A ticket order is two supplies: admission from the organiser as principal, and the booking fee charged by Grofomo in its own right. The hosted ticket page has always shown the buyer both. A checkout that omits these notices sells the buyer a service from a company they were never shown, under terms they were never given. The selling tickets guide has the rendering snippet.

Additive, so nothing breaks, but treat its absence from your checkout as a bug rather than as an option. legal is deliberately not covered by formVersion: that names the consent wording, which is evidence and is snapshotted onto the buyer's consent record, whereas these notices are notice and change independently.

17 September 2026 - Reserve enforces promo limits, hidden tiers and a hold quota

A limited-use promo code is now counted at reserve, under a lock, so a one-use code admits one live hold; a second cart on it gets 409 promo_used_up at reserve rather than at completion. A hidden tier is refused by id (400 unknown_release), like a deleted one. Too many live reservations on one event from one connection or app device answer 429 too_many_holds with a Retry-After; a hold lapses in 15 minutes. On the details step, a returning customer's marketing choices are now recorded when their order completes rather than when the form is submitted, and for the form versions this API publishes wording for, a consentTextSnapshot that differs from that wording is refused with 400 consent_text_mismatch.

17 September 2026 - The reserve response carries a checkout token, not the order id

POST /v1/events/{slug}/tickets/reserve now returns checkoutToken in place of publicId, and the details, pay, complete-free and order-status paths take {checkoutToken}. The token authorises this checkout only and expires 24 hours after the reserve. The order's public id was also the buyer's lasting management credential (the hosted order page, ticket transfers, payment-plan card changes, the receipt), so returning it to a page that only needed to sell was more than the page needed; a security review said so within a day of publication, and no integration had been built on it yet. checkout.js 1.1.0 uses the token. The checkout writes also now require a credential on every surface: an anonymous call that says ?surface=app gets the organiser's key policy like any other.

16 September 2026 - checkout.js prints the two required notices

checkout.js 1.3.0 reads checkout.legal off the ticket feed and renders both notices itself: the 18+ and Terms attestation immediately above the button that commits the buyer, and the "Ticketing by Grofomo · Terms · Privacy" attribution under every step. A site on the drop-in needs nothing; a site that renders its own UI still owes both, and selling tickets now opens with the full list of what a checkout page must show.

The words come off the wire rather than out of the script, so a change to them reaches an organiser's site without anyone redeploying it. If an older server omits the block, the drop-in prints its own copy rather than printing nothing: the notices are not optional.

16 September 2026 - Grofomo's emails link to your checkout, and checkout.js reads the basket deep link

When an event's ticket link points at your page, the abandoned-basket nudge now sends the buyer there instead of to the hosted page, with the basket they left as ?basket=<releaseId>:<qty>,... and the usual attribution tags. A page on this contract must honour both, and selling tickets now says how. checkout.js 1.2.0 reads the basket link itself, clamped against the feed, so a site using the drop-in needs nothing.

16 September 2026 - A free ticket no longer removes the payment plan

A payment plan offer has to be open on every tier in the basket. A free tier can carry no offer, because there is nothing to spread, so a basket with one in it shared no offer at all: adding a £0 under-5 ticket silently took the plan away from the adult tickets beside it, on the page and at reserve, with nothing to explain why.

Free tiers are now ignored by that rule, in paymentPlans on the feed and in the plan_unavailable check. A basket of nothing but free tiers still has no plan, because there is nothing to pay.

16 September 2026 - A free-ticket giveaway now combines with the deal that earned it

Automatic deals used to be single-winner: of every rule a basket qualified for, the one that saved most applied and the rest did not. That was right for two deals on the same tickets, and wrong for a giveaway of a different one. "5 adults for the price of 4" beat "buy 4 adults, get a child ticket free", so the child was charged for and the offer read as broken.

A rule with freeReleaseId now combines with the best deal on the tickets that earned it, when the two discount different lines. Deals on the same tickets still do not stack. The reserve response's quantityDiscount.name names both when both applied, and id is the one that gave more. Nothing changes for a basket that qualified for one rule.

16 September 2026 - checkout.js, the drop-in ticket checkout

One script tag renders the whole purchase on your own page: https://developers.grofomo.com/checkout.js, then GrofomoCheckout.mount('#tickets', { event, key }). Ticket choice, promo codes, the buyer's details and consent, the card through Stripe, and the confirmation, in a plain style your CSS overrides. The same file exposes GrofomoCheckout.client(...) for a site that renders its own cart. See selling tickets.

16 September 2026 - Selling tickets from your own site

Seven endpoints join the reference: quote, reserve, details, pay, complete free, order status and the funnel beacon. They are the write flow the hosted ticket page runs on, now a supported contract, gated like every event endpoint on the organiser's allowed domains and publishable key. The selling tickets guide walks the whole purchase, Stripe Payment Element included. Reference pages now show a request body table generated from the wire schema, and openapi.json carries typed request bodies.

16 September 2026 - The ticket feed carries the checkout consent wording

GET /v1/events/{slug}/tickets gains a checkout block on the web surface: the three consent labels a checkout must print (the organiser's email box, the organiser's SMS and WhatsApp box, Grofomo's own email box), how each is shown, and the formVersion to send back with the buyer's details. It is the wording the hosted ticket page prints, from the same source. Every field on the quote, reserve, details, pay and order-status schemas now carries a description, ahead of those endpoints joining this reference.

15 September 2026 - The prelaunch form is available to your own website

Three new event-scoped endpoints let a site render an organiser's prelaunch registration in its own design: GET /v1/events/{slug}/prelaunch returns the published form (copy, which fields to ask, and the exact consent wording to print), POST /v1/events/{slug}/prelaunch/register takes the registration, and POST /v1/events/{slug}/prelaunch/verify finishes its double opt-in (the SMS code and the resends). They are gated like every event endpoint, on the organiser's allowed domains and publishable key. The hosted page at events.grofomo.com/prelaunch/{slug} is unchanged.

15 September 2026 - Mailing-list signups are double opt-in

POST /v1/events/{slug}/subscribe and a mailing-list form submitted through POST /v1/forms/{key}/submissions still save the signup at once, but the address is not on the organiser's list until the visitor opens the confirmation email we now send. Both responses gain a verification object (email: sent, not_needed or failed; phone; phoneHint). Show "check your inbox" when verification.email is sent.

On the subscribe endpoint, attaching a number with { submissionId, phone } now texts it a code where phone verification is on (verification.phone is sent); post { submissionId, code } to confirm it, and { submissionId, resend: "email" | "code" } to send either again. The mailing_list.signup webhook fires when the email is confirmed rather than at submission, and its payload gains confirmedAt.

15 September 2026 - A deal can give a different ticket away

A buy_n_get_m entry in quantityDiscounts may now carry freeReleaseId: the release given free ("buy 1 early bird, get a free shuttle bus"). Every full group of buyQuantity paid units in the rule's scope frees freeQuantity units of that release, but only units the visitor has put in the basket. Nothing is added for them, so a site that advertises the deal should add the free ticket to the cart alongside the paid one. The label reads "Buy 1, get a free Shuttle bus". A rule without freeReleaseId is unchanged: the cheapest units in scope go free. The rule is omitted for a caller who cannot see the free release. Additive.

15 September 2026 - Cross-event offers on reserve

POST .../tickets/reserve now carries crossEventOffers: the offers this cart has earned on the organiser's other events (each with the target event's name and slug, the discount, and how long the code lives). Buying a qualifying tier mints the buyer a personal, single-use promo code for the target event once the order finalises, and emails it. New endpoint GET .../tickets/orders/{checkoutToken} returns those codes once minted (an empty list until then), so a confirmation page can show the code as well as the email. A code is bound to the buyer: collect-details on the target event refuses it for any other email address with promo_not_yours.

15 September 2026 - Prelaunch registrations are double opt-in

POST /v1/prelaunch-register no longer grants marketing consent at submission. A new email address gets a confirmation link and a new mobile number gets an SMS code; the matching consents are granted when the proof comes back. The response gains a verification object: a token for the follow-up calls, email and phone states (sent, not_needed, failed, and for phone unavailable), and a phoneHint. A registration with an address or number this registrant already proved is granted at once, as before.

New endpoint POST /v1/prelaunch-verify finishes the job with one of four action values: confirm_email (the link's token), check_code (the form token plus the code), resend_email and resend_code. Wrong and expired codes come back as an outcome, not an error.

If you build your own registration form on this endpoint, show the code step when verification.phone is sent and tell the person to check their inbox when verification.email is sent. The prelaunch.registration webhook now fires each time a channel is proven (the email link, the SMS code) rather than at submission, carrying the consents as they stand, so a consumer may see it twice for one registration.

16 September 2026 - Tracking can be added after a reward is posted

The organiser can now edit carrier and trackingNumber on a reward they have already marked sent, so an Ambassador whose parcel went before the number existed still gets it. Two consequences for a consumer of GET /v1/devices/me/ambassador/rewards: dispatch is no longer fixed once sentAt is set, so re-read it rather than caching it on first sight; and sentAt is now the instant the reward was marked sent and no longer moves when the courier or number is edited afterwards.

A FIRST tracking number pushes the Ambassador (type: 'ambassador_dispatched' again, with its own wording), at most once per reward for all time. A correction, a courier swapped, or a number cleared sends nothing. Additive: no field changed shape.

16 September 2026 - A posted reward carries its tracking

Every reward in GET /v1/devices/me/ambassador/rewards now carries dispatch: carrier (the courier's name, or whatever the organiser typed for one we have no page for), trackingNumber, trackingUrl (the courier's own tracking page for that number, null when there is none) and sentAt. It is null on every reward except one the organiser posts on, and until they mark it sent; both the courier and the number are optional, so a sent reward can carry a date and nothing else. Marking it sent also pushes the Ambassador (type: 'ambassador_dispatched', deep-linked to that event's ladder), once per reward, subject to their Event updates preference. Additive.

16 September 2026 - A reward sent on afterwards has a claim deadline

POST /v1/devices/me/ambassador/reward-claims now refuses with claim_closed (422) once the reward's claimUntil has passed: the organiser's own date, else a month after the event ends. claimUntil and claimUntilDate were already on every reward in GET /v1/devices/me/ambassador/rewards, so stop offering the claim form once claimUntil is in the past rather than letting someone fill it in for nothing. The organiser can still mark the reward sent by hand: the deadline bounds what the Ambassador can tell them, not the debt itself. Additive.

15 September 2026 - Ambassadors can claim a reward that is sent to them

New endpoint POST /v1/devices/me/ambassador/reward-claims (device token) claims a reward the organiser sends on afterwards: the answer to its question, such as a shirt size, and where to send it. Body { affiliateId, rewardDefinitionId, selectedOption?, deliveryNote? }. selectedOption is required when the reward has optionChoices and must be one of them exactly as offered, and is refused when it has none; deliveryNote is free text up to 500 characters, blank to clear. Nothing is minted, and both can be changed until the organiser marks the reward sent. Refusals: reward_not_found (404), reward_not_claimable (409, already sent or taken back), reward_not_sent_later (422, a reward collected at the event answers when its code is minted instead), option_required (422) and option_not_offered (422). Each reward in GET /v1/devices/me/ambassador/rewards now carries selectedOption and deliveryNote, what they said, or null. Additive.

15 September 2026 - A ticket refund reward says where its refund is

A reward of type ticket_refund in GET /v1/devices/me/ambassador/rewards now carries refund: status (pending until the event ends, then refunded with refundedAt, or failed), amountPennies and currency. Null on every other kind of reward. The refund itself is paid automatically after the event to the card the Ambassador bought with; their ticket stays valid. Additive.

15 September 2026 - Ambassador reward wallet carries the event's time zone

Each event in GET /v1/devices/me/ambassador/rewards now carries timezone, the event's IANA zone ("Europe/London"), so a client can show a reward's availableFrom, availableUntil and claimUntil as a wall-clock time at the venue rather than in the phone's zone. Additive.

15 September 2026 - Ambassador tiers can be named, and a used reward says when

Each tier in GET /v1/devices/me/ambassador/rewards now carries name, the organiser's name for it ("Ringmaster") or null, and every reward carries the same value as tierName. Read it as "<name> level"; when it is null, say what the tier pays instead. Every reward also carries lastUsedAt and lastUsedDate (YYYY-MM-DD in the event's own zone), the most recent scan, null until it has been used. Additive: existing fields are unchanged.

15 September 2026 - Ambassador rewards carry a use-by date

Each reward in GET /v1/devices/me/ambassador/rewards now carries claimUntil (an instant) and claimUntilDate (YYYY-MM-DD in the event's own zone): the last moment it can be used. When the organiser set no date, the event's own end fills it for anything collected at the event, and a month after the event for anything sent afterwards or a refund. availableFrom and availableUntil are now effective the same way, with the event's start and end filling blanks for anything collected at the event, so a code asked for after the event closes is refused as outside_window rather than served. Additive: existing fields are unchanged.

15 September 2026 - Ambassador reward wallet carries the event's dates

Each event in GET /v1/devices/me/ambassador/rewards now carries eventStartDate and eventEndDate (YYYY-MM-DD in the event's own zone, the same shape as dates on the event detail) and programmeDescription (the organiser's own words about the programme, or null). A client can head the ladder without fetching the event detail, which an event that is not yet public does not have. Each reward on a tier also carries cooldownMinutes, the organiser's gap between uses of that one reward (0 when none), so a client can say "one every 20 minutes" before the second tap is refused as cooling_down. Additive: existing fields are unchanged.

10 September 2026 - Old event slugs keep working

An organiser can rename an event, which changes its slug. Every /v1/events/{slug}/... endpoint, and the device endpoints that take an event slug, now accept a slug the event used to carry and answer exactly as they do for the current one. There is no redirect: the same request simply succeeds, so CORS and POST requests behave as before. On the public site, old grfm.to/e/{slug} links 308 to the current address.

Nothing breaks if you ignore this, but the docs used to promise a slug was fixed once created, and that was never enforced. If you store a slug, refresh it from the slug field of GET /v1/events/{slug}, which always carries the current one.

10 September 2026 - Set notes on line-up slots

Each slot in GET /v1/events/{slug}/lineup now carries setNote: a short note from the organiser on how that one set is played, such as "DJ set", "Live" or "Rave set", at most 80 characters. It is null when there is none, including on every line-up published before today. Show it beside the act's name, not in place of it: "Uncle Dugs · Rave set".

A note belongs to the set, not the artist, so the same act can be "Live" on Friday and "DJ set" on Sunday. It is additive, so a client that ignores it keeps working.

7 September 2026 - Payment plans are now offers on each price tier

Each release in GET /v1/events/{slug}/tickets (and the tickets block of GET /v1/events/{slug}) now carries paymentPlans: the plan offers a buyer can take that tier on, each with the deposit (percent of the face value in basis points, or fixed pennies per ticket), the number of monthly payments, payByAt and dueDates. The dates are the monthly anniversaries of an order placed now, at 09:00 in the event's timezone, so a buyer who signs up just after payday is charged on that day every month. Organisers set these per tier: a release on sale a year out can carry a ten-month plan, and the final release a fortnight out carries none.

An offer can disappear from a tier with no organiser action. Only offers that can still be repaid before payByAt are listed, so as the event approaches a tier's paymentPlans shrinks and eventually empties. Re-read it with the catalog, as you do availability, and never cache it across a session.

To buy on a plan, pass paymentPlanId on POST .../tickets/reserve (the old plan: true is no longer read). Every tier in the cart must list that offer; otherwise the reserve answers plan_unavailable (409). The reserved order's paymentPlan now carries the exact schedule the order will be charged on: installments[] with seq, amountPennies and dueAt, plus payByAt and the offer's id and name. It is snapshotted at reserve, so what you show the buyer is what the plan is created with.

The type-level paymentPlan field is kept for older clients but is now derived and deprecated: set only when every purchasable tier of the type shares one open percent-deposit offer, and null otherwise (including when the tiers differ). Read the release field. Additive; apiVersion stays 1.

4 September 2026 - Artist photos now agree across every surface

When an organiser sends an artist a retouched photo for an event and the artist adopts it, that photo is what the event shows. It already worked that way on GET /v1/events/{slug}/lineup; it now works the same on GET /v1/events/{slug}/artists/{artistId}, which used to return the artist's own profile photo instead. Tapping a lineup card and landing on a different face was the visible symptom.

No shape changes and apiVersion stays 1: imageUrl is the same field, it is now resolved the same way in both places. Worth knowing if you cache artist images: the value can change without the artist editing their profile, and the URL always changes with it. Key any local image cache on the URL rather than on the artist id, or you will serve whichever photo you fetched first.

The photo is scoped to the event it was approved for, and it only applies when the artist chose to adopt it as their photo. An artist who files it in their press kit without adopting it, or who adopts it only until the event is over, keeps their own photo on the bill.

1 September 2026 - New webhook: whatsapp.campaign_held

A scheduled WhatsApp campaign no longer sends if Meta has marked its message down since it was scheduled. Instead the campaign is held: nobody is messaged, it stays scheduled, and the organiser is told so they can change the message or cancel it. The new whatsapp.campaign_held event fires when that happens, carrying template_name, language, scheduled_for, recipient_count and campaign_url.

Nothing about /v1 changes and apiVersion stays 1. It is listed here because the webhook surface is part of what we document, and an integration watching for a campaign to complete now has a state that means "waiting on the organiser" rather than "sent" or "failed". Subscribe to it the same way as any other event; existing subscriptions are unaffected.

26 August 2026 - News article detail: bodyHtml is now always populated

GET /v1/events/{slug}/news/{articleSlug} documented bodyHtml as an optional pre-rendered projection of bodyMarkdown, but in practice it was always null: nothing populated it, so consumers rendering it (including our own event pages) showed articles with no body. It now falls back to a server-side render of the canonical markdown (headings, paragraphs, lists, emphasis and links, escaped-first), so it is always a non-empty projection of bodyMarkdown. The field keeps its nullable type on the wire; treat null as absent as before. Additive; apiVersion stays 1.

26 August 2026 - Event detail: capacity is the enforced venue capacity

GET /v1/events/{slug} still returns capacity in the same place and the same shape, but it now reads the venue-capacity figure the ticketing system actually enforces sales against, rather than a separate display field an organiser could edit independently. In practice the two already agreed on every live event, so no response changes today. From here the number cannot drift from the one that stops ticket sales. Same for the maximumAttendeeCapacity in schemaOrg. No shape change; apiVersion stays 1.

24 August 2026 - Event detail: ready-made schemaOrg, and a word on null

GET /v1/events/{slug} now carries a schemaOrg field on the web surface: a complete schema.org MusicEvent block with the description, the full venue address, the organiser and one Offer per ticket tier, priced fee-inclusive with sale windows and availability. Serialise it straight into a <script type="application/ld+json"> tag instead of hand-building your own. Null on the app surface. Additive; apiVersion stays 1.

Empty fields on the event are dropped from the block rather than published blank, so what Google sees is always valid, just thinner until the organiser fills things in.

Which is a good moment to state a convention that has always held: a null field anywhere in this API means the organiser has not filled it in. Drop it; never render it blank. An address with an empty street reads worse than no address, on a page and in a search result alike.

21 August 2026 - Booking-fee minimum is now 50p per order (was £1.00)

The per-order floor on the platform default booking fee drops from £1.00 to 50p: bookingFeePolicy.minPennies on the tickets endpoint now reports 50 where it reported 100. The rate itself is unchanged at 7.5% + 25p per ticket.

The floor fires whenever 7.5% of face plus 25p falls under it, and at £1.00 that was every ticket under £10: an £8 early-bird ticket paid a 12.5% fee while a £40 ticket paid 8.1%. At 50p it still clears the platform's cost on the cheapest possible order, so lowering it changed nothing for most carts and removed the worst number on the pricing page.

No field changed shape; the number inside one did. If you compute fees client-side, keep reading minPennies from the payload rather than hard-coding it: as with the rate, the figure legitimately differs from one event to the next.

21 August 2026 - a ticket given away by a deal no longer pays a booking fee

The per-ticket component of the booking fee is charged on units the buyer pays for. A buy_n_get_m rule's free unit no longer counts.

It used to. The free ticket lost its face value but kept its per-ticket fee, so a cart of five under "5 for the price of 4" cost slightly more than a cart of four - on the fee-inclusive basis this API tells you to advertise on. The label said one thing and the till said another. Now five costs exactly what four costs.

Only buy_n_get_m is affected. A tiered rule discounts units that are all still paid for, and bundle_price charges one price for the whole bundle, so neither changes.

If you compute fees client-side from bookingFeePolicy, ticketCount is now "units paid for": exclude £0 tiers, as before, and exclude the units a buy-N-get-M rule frees.

21 August 2026 - New: label on every quantity discount

GET /v1/events/{slug}/tickets now returns a label on each entry in quantityDiscounts - ready-to-display wording like "5 for the price of 4". Additive: existing fields are unchanged and apiVersion stays 1.

Use it instead of composing a string from the numbers. buy_n_get_m is the one that catches people: buyQuantity: 4, freeQuantity: 1 looks like "buy 4, get 1 free", but the rule needs five in the basket before the cheapest goes free. A visitor who adds four gets nothing. Our own storefront shipped that mistake, which is why the server now states the deal rather than leaving every site to derive it.

For bundle_price the label is fee-inclusive, so "5 for £80" is what the buyer actually pays - the same drip-pricing reasoning as feeInclusivePricePennies.

21 August 2026 - writes are no-store, as documented

Every write endpoint under /v1 now returns Cache-Control: no-store.

That was always the documented behaviour (see caching), but the implementation did not match: writes inherited the default read header, public, s-maxage=60. Because a CDN keys on the URL alone, a cached reply to one POST was replayed for the next POST to the same path - a different body, and a different caller. On the ticket checkout that meant a cart quote could show the previous cart's fee and total for up to a minute.

Nothing about the request or response shape changes. If you cached or de-duplicated write responses yourself on the assumption they were stable, stop: each write now answers for itself.

20 August 2026 - New: the photo gallery feed

GET /v1/events/{slug}/gallery returns up to 25 photos an organiser hand-picked in Grofomo, in the order they arranged them. Additive: a new endpoint, nothing existing changes, and apiVersion stays 1.

The gallery belongs to the event series, so every edition of a festival answers with the same photos. An event with no series, or an organiser who has picked none, answers photos: []. Treat empty as a normal state.

Two things worth building for rather than around. Each photo carries an optional focalX/focalY (fractions of width and height) so you can crop to any shape without losing the subject; null means centre. And credit, where set, is the photographer the organiser asked to be named: show it wherever you show the photo.

Photos can vanish between requests without warning. Eligibility is re-checked on every read, so a photo whose consent was withdrawn or which the organiser deleted stops being served at once. Do not cache the image URLs beyond your own build, and do not treat a shrinking gallery as a fault.

20 August 2026 - ticketUrl falls back to the Grofomo checkout

GET /v1/events/{slug} previously returned ticketUrl: null whenever the organiser had not typed a link of their own, even while their Grofomo releases were on sale. The field was documented as "null when no tickets are on sale", so the honest reading of a live event was that it was not selling.

ticketUrl now resolves in two steps: the organiser's own link if they set one, otherwise their Grofomo checkout (/e/{slug}/tickets) when the event has published ticket types. It is null only when neither exists. This matches what Grofomo's own hosted event pages have always done.

ticketUrl is still not a statement about whether a release is open right now. A tier between sale windows, sold out, or gated keeps the destination, because the checkout explains that state better than a missing link. Gate a buy button on soldOut and upcoming from the tickets endpoint, as before.

Additive in shape, and apiVersion remains 1: the field's type is unchanged, but a site that treated null as "not selling" will now see a URL where it previously saw nothing.

20 August 2026 - Booking fee is now 7.5% + 25p per ticket

The platform default booking fee moves from 3% + 75p per ticket to 7.5% + 25p per ticket, with the per-order minimum rising from 75p to £1.00. It stays uncapped, and it is still calculated on the post-discount subtotal, so a promo never inflates it.

The shape has changed as well as the level: the fee is now weighted toward the percentage rather than the per-ticket amount, so it scales with the value of what is being sold rather than with the number of tickets. Whether a given order pays more or less than before depends on its face value, so recompute rather than assuming a direction.

No field changed shape; the numbers inside them did. bookingFeePolicy on the tickets endpoint reports the new figures, every release's feeInclusivePricePennies moves to match (a £20.00 face value now advertises £21.75, where it advertised £21.35), and bookingFeePennies on reserve and quote responses changes accordingly.

Events that were already selling keep the previous fee. Advertised prices include the booking fee, so raising it mid-campaign would move the displayed price of a ticket someone has already promoted. Any event that had taken a sale, or had a release on sale, when this shipped stays on 3% + 75p for its lifetime. New events get the new rate.

That means the fee legitimately differs from one event to the next, and two events can report a different bookingFeePolicy and a different feeInclusivePricePennies for the same face value. This is not a bug and it needs no new field: the tickets endpoint has always resolved the policy per event, so read the fee from the event you are pricing and never carry one event's policy over to another.

Re-read quotes rather than caching them. A total you computed or stored before this change may no longer match what checkout will charge, in either direction, and the server stays authoritative at reserve.

Organisations and events on a negotiated fee override are unaffected: they keep the rate that was agreed with them.

The booking fee is VAT-inclusive and Grofomo accounts for the VAT on it, so no separate tax line is added on top at checkout. Face value continues to be the organiser's, handled under the organiser's own VAT position.

This changes values only and apiVersion remains 1.

18 August 2026 — Event search: web surface gate + websiteUrl

GET /v1/events/search accepts an optional surface=web parameter: web consumers (like the Grofomo event directory) then see events whose website visibility is public, while the default continues to gate on app visibility, so existing app clients are unchanged. Search results (and an organiser's upcomingEvents) gain an optional nullable websiteUrl — the event's own site when the organiser runs one; link there in preference to a Grofomo-hosted page.

18 August 2026 — Free tickets pay no booking fee

A zero-subtotal order now pays no booking fee at all (previously the per-order minimum still applied, so a "free" ticket checked out at £0.75), and free tickets in a mixed cart no longer attract the per-ticket fee component. A release's feeInclusivePricePennies therefore equals 0 for a free tier.

Because a fully free order has nothing to charge, the pay endpoint still responds nothing_to_pay for it - instead, POST the new /v1/events/{slug}/tickets/orders/{checkoutToken}/complete-free endpoint after collecting details. It finalizes the order without a payment step (tickets issued, confirmation email sent) and returns { completed: true }. Replay-safe: retrying after a timeout returns the same success. Orders with any amount owed, and payment-plan orders, are refused with payment_required.

This is additive and apiVersion remains 1.

18 August 2026 — Fee-inclusive ticket prices

The tickets endpoint now states the price a buyer actually pays. Each release gained feeInclusivePricePennies — the face value plus the mandatory booking fee, computed at the type's minimum order size — and the payload gained bookingFeePolicy, the resolved fee policy (perTicketPennies, percentBps, capPennies, minPennies) so a client can compute the exact fee for any cart before reserving.

If you display ticket prices, switch your headline figure to feeInclusivePricePennies. UK drip-pricing rules (the DMCC Act, in force since April 2025) require an advertised ticket price to include mandatory fees; pricePennies remains the face value and the difference between the two is the per-unit booking fee, if you want to show a breakdown. Reserve and quote responses already itemised bookingFeePennies; nothing changed there.

This is additive and apiVersion remains 1. Both fields are absent on responses served by older deployments, so fall back to pricePennies when feeInclusivePricePennies is missing.

3 October 2026 – Ticket sale webhooks say where the sale came from

The ticket.sale webhook payload's attribution object gained six fields: source, the last touch in a few words ("a Meta ad", "an email", "a short link", or the artist's name for an artist link); the campaign tags the buyer's link carried, utmSource, utmMedium, utmCampaign and utmContent; and ad, the name of the ad for a sale from an ad campaign pushed through Grofomo (those ads carry it on their link as utm_term), in which case source reads "a Meta ad (The Offer video)". Each is null when the order does not carry it.

Two placeholders come with it, {{source}} and {{source_credit}}, and the default message now ends with {{source_credit}} in place of {{artist_credit}}. A sale from an artist's link reads exactly as before ("… totalling £45.00 – via DJ Nova"); a sale from anywhere else that can be placed now says so ("… totalling £45.00 – from a Meta ad"); a sale nobody can place reads as it always did. A Slack digest of several sales gains a "Where from" line counting them.

This is additive and apiVersion remains 1. {{artist_credit}} still works, and organisers who have customised their ticket.sale message keep it exactly as written; adding {{source_credit}} to it is opt-in.

15 August 2026 — Ticket sale webhooks name the artist who drove the sale

The ticket.sale webhook payload gained an attribution object: the last-touch channel, plus an artist ({ id, name }) when the buyer arrived on an artist's share link — null for every other sale. Two new placeholders come with it, {{artist_name}} and {{artist_credit}}, and the default message now ends with the credit phrase, so an artist-driven sale reads "… totalling £45.00 – via DJ Nova" without the organiser editing anything.

This is additive and apiVersion remains 1. Organisers who have customised their ticket.sale message keep it exactly as written; adding {{artist_credit}} to it is opt-in.

11 August 2026 — Account linking reports reconciliation

The attendee app's authenticated device-link response now includes a reconciliation object describing which existing favourites, uploads, ticket-customer records, organisation contacts and Ambassador payees were connected to the verified account.

This is additive and apiVersion remains 1. Existing clients may ignore the new field; clients that need reconciliation diagnostics can adopt it when they next update their mobile schema package.

5 August 2026 — These docs launched

The API as documented here is the baseline. The entry below predates this site, and is included because it affects integrations built before it existed; anything older is folded into the baseline rather than listed.

4 August 2026 — API keys introduced

The API gained authentication: two key tiers — pk_live_… publishable keys for browser code, sk_live_… secret keys for servers — sent in the X-Grofomo-Key header.

What it means for an existing integration:

  • If your site predates keys, nothing has changed for you yet. Organisations that existed before this date stay in open mode, where requests without a key are still answered.
  • New organisations start in key_required mode, with a publishable key minted at creation. A site built for one must send a key from day one.
  • Add a key now either way. In open mode it is accepted and ignored, and your integration is already correct on the day the organiser flips the switch. How organisers mint one.

Alongside keys, browser calls from a domain the organiser has not allowlisted became an explicit 403 origin_not_allowed — see CORS and allowed domains.

Coming

Key requirements will extend to the remaining open endpoints. A handful of reads — event detail, content version, artist detail, announcements and organiser profile — currently answer without a key, because the Grofomo mobile app calls them without one. That will change once the app carries a credential.

We are measuring key adoption on those endpoints before making the switch, so it lands as a measured decision rather than a hopeful one. Send a key to them now: it is accepted and ignored today, so an integration built that way needs no change on the day.