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
openmode, where requests without a key are still answered. - New organisations start in
key_requiredmode, 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
openmode 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.