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

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, moved or materially changed, you get everything back, booking fee included. 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.

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.