Reserve tickets
/v1/events/{slug}/tickets/reserveHold the tickets in the cart and create the order. The hold is oversell-proof and lasts 15 minutes (reservedUntil); show the buyer a countdown, because details and pay refuse an expired order with 409 expired and the only way on is a fresh reserve. Nothing needs cancelling: an unpaid hold is released on its own.
Prices are rebuilt server-side from the release ids and quantities. Whatever your page showed, the order carries what the server computed, so read the totals off the response rather than off your own arithmetic.
checkoutToken is the credential for the order. Every later call (details, pay, complete-free, status) is addressed by it and nothing else. It authorises the checkout only: not the tickets, not a transfer, not the receipt, all of which reach the buyer by email. It expires 24 hours after the reserve. Keep it in memory or session storage and out of URLs, logs and analytics.
A zero-total order (a free tier, or a code that discounts everything) is finished with complete free instead of pay.
Reserve fails with 409 unavailable until the organiser has finished connecting Stripe. That is the organiser's setup to complete, not something a key or a domain can fix.
404 not_found for no visible reason.Request
curl -sS \
-X POST \
-H 'X-Grofomo-Key: pk_live_YOUR_PUBLISHABLE_KEY' \
-H 'Content-Type: application/json' \
-d '{"items":[{"releaseId":"9c7e5a3b-1f0d-4b2e-8a6c-4d2f0b8e6a14","quantity":2}],"promoCode":"EARLYBIRD","funnelSessionToken":"9d1f7c2a-3b6e-4a8d-9e0f-1c2b3a4d5e6f","attribution":{"channel":"social","utmSource":"instagram","referrer":"instagram.com"}}' \
'https://api.grofomo.com/v1/events/summer-fest/tickets/reserve?surface=web'Request body
itemsobject[]required- The cart. One entry per release; up to 20 releases.
items[].releaseIduuidrequired- A release id from the ticket availability feed.
items[].quantitynumberrequired- Units of that release. Must sit within the type's `minPerOrder` and `maxPerOrder`.
promoCodestring- A promo code to apply. An invalid code does not fail the reserve: the order is priced without it and `discountPennies` says so.
affiliateCodestring- An affiliate code, when the buyer arrived on an affiliate link. Credits the sale and reveals affiliate-gated tiers.
artistIduuid- The lineup artist the buyer arrived through, when your page came from an artist share link. Re-checked against the lineup server-side.
paymentPlanIduuid- A payment plan offer id from `paymentPlans` on every PAID tier in the cart (free tiers carry no offers and are ignored). Omit to pay in full. Refused with `plan_unavailable` (409) unless every paid tier lists it and it is still open.
attributionobject- Where the sale came from, for the organiser's sales-channel report. Optional and PII-free. The flat fields are the LAST touch; `firstTouch` is the first.
attribution.channel"short_link" | "poster" | "artist" | "affiliate" | …requiredattribution.refIdstringattribution.utmSourcestringattribution.utmMediumstringattribution.utmCampaignstringattribution.utmTermstringattribution.utmContentstringattribution.referrerstringattribution.gclidstringattribution.fbclidstringattribution.atnumberattribution.firstTouchobject- The first tagged visit in your attribution window, when you track one. The flat fields are the last touch.
attribution.firstTouch.channel"short_link" | "poster" | "artist" | "affiliate" | …requiredattribution.firstTouch.refIdstringattribution.firstTouch.utmSourcestringattribution.firstTouch.utmMediumstringattribution.firstTouch.utmCampaignstringattribution.firstTouch.utmTermstringattribution.firstTouch.utmContentstringattribution.firstTouch.referrerstringattribution.firstTouch.gclidstringattribution.firstTouch.fbclidstringattribution.firstTouch.atnumberfunnelSessionTokenstring- The same opaque session token you send on funnel beacons, so the organiser's funnel report can join this checkout to the page views before it. Omit both, or send both.
deviceHandoffstring- Internal: a signed hand-off from the Grofomo mobile app. Leave it out.
Path parameters
slugstringrequired- The event's URL slug. Globally unique. It can change if the organiser renames the event, but an old slug keeps reaching the same event, so a stored one never breaks. Example: `summer-fest`.
Query parameters
surface'web'required- Must be `web` for a website. Omit it and you get the mobile-app surface, which is gated on a different visibility setting — so a perfectly live event can return 404. Example: `web`.
Response
{
"apiVersion": 1,
"data": {
"orderId": "c3a1e5d7-9b2f-4e6a-8c0d-2f4b6a8c0e12",
"checkoutToken": "YzNhMWU1ZDctOWIyZi00ZTZhLThjMGQtMmY0YjZhOGMwZTEyOjE3ODk2NDM2MDAwMDA6dGt0LWNoZWNrb3V0.ZXhhbXBsZS1zaWduYXR1cmU",
"reservedUntil": "2026-07-04T18:15:00.000Z",
"currency": "GBP",
"subtotalPennies": 22000,
"discountPennies": 2200,
"bookingFeePennies": 1535,
"totalPennies": 21335,
"quantityDiscount": null,
"promoDiscountPennies": 2200,
"paymentPlan": null,
"crossEventOffers": []
}
}orderIduuid- The order's internal id. For your records; every later call uses `publicId`.
checkoutTokenstring- The credential for this order on the details, pay, complete-free and status endpoints: put it in the path where those show `{checkoutToken}`. It authorises the checkout and nothing else (not the tickets, not transfers, not the receipt: those reach the buyer by email), and it expires 24 hours after the reserve. Keep it in memory or session storage and out of URLs, logs and analytics.
reservedUntilstring- ISO instant the 15-minute inventory hold ends. Show a countdown; after it, details and pay answer 409 `expired` and the buyer starts again.
currencystring- ISO 4217 code every amount on this order is in.
subtotalPenniesnumber- Face value of the cart before discounts, in minor units.
discountPenniesnumber- Everything taken off: the automatic quantity discount plus the promo code, in minor units.
bookingFeePenniesnumber- The booking fee for the order, in minor units.
totalPenniesnumber- What the buyer pays: subtotal minus discount plus booking fee, in minor units. Zero means a free order: call complete-free instead of pay.
quantityDiscountobject | nullmay be absent- The automatic quantity discount that applied, or null when none did.
quantityDiscount.iduuid- The rule that gave most. When a giveaway of a different ticket combined with the deal that earned it, this is the larger of the two and `name` names both.
quantityDiscount.namestring- The discount's name, to show on the receipt line. Both names, joined with " + ", when a giveaway combined with a deal on the tickets that earned it.
quantityDiscount.discountPenniesnumber- Its share of `discountPennies`.
promoDiscountPenniesnumbermay be absent- The promo code's share of `discountPennies`, in minor units.
paymentPlanobject | null- The instalment plan the order is on, or null when paying in full.
paymentPlan.iduuid- The payment plan offer the order is on.
paymentPlan.namestring- The organiser's name for the plan, to show the buyer.
paymentPlan.depositPenniesnumber- What the payment step charges now: the deposit share of the face value plus the whole booking fee, in minor units.
paymentPlan.balancePenniesnumber- The remaining face value, collected over the instalments, in minor units.
paymentPlan.payByAtstring- ISO instant the plan must be fully paid by. The last instalment lands on or before it.
paymentPlan.installmentsobject[]- The exact schedule the saved card is charged on once the deposit is paid. Show it before the buyer pays.
paymentPlan.installments[].seqnumber- 1-based position in the schedule.
paymentPlan.installments[].amountPenniesnumber- Charged on `dueAt`, in minor units.
paymentPlan.installments[].dueAtstring- ISO instant the instalment is taken.
crossEventOffersobject[]may be absent- Offers this cart earns on the organiser's other events. The personal codes are minted once the order completes and arrive on the status endpoint and by email.
crossEventOffers[].iduuid- The offer id.
crossEventOffers[].namestring- The organiser's label for the offer.
crossEventOffers[].targetEventNamestring- The event the code is spent on.
crossEventOffers[].targetEventSlugstring- Slug of the target event, for a link to its storefront.
crossEventOffers[].discountType"percentage" | "fixed"- How the code discounts: a percentage of the tier price, or a fixed amount off.
crossEventOffers[].discountValuenumber- Basis points for percentage (1000 = 10%), pennies for fixed.
crossEventOffers[].codeValidDaysnumber | null- The code expires this many days after purchase; null = never.
Errors
unauthorizedHTTP 401- No key was sent and the organiser requires one — or the key is invalid, revoked, belongs to another organisation, or is scoped to a different event.
origin_not_allowedHTTP 403- Your site's domain is not on the organiser's allowed list. Browser calls only: a server sends no Origin header and is unaffected.
not_foundHTTP 404- No event with that slug is published on this surface. Check the slug, and check you sent `?surface=web`.
bad_requestHTTP 400- The body did not match the schema.
unknown_releaseHTTP 400- A `releaseId` is not on this event or not visible to this caller.
not_on_saleHTTP 409- A release in the cart is closed, not yet open, or sold out.
per_order_minHTTP 400- Fewer units of a type than its `minPerOrder`.
per_order_maxHTTP 400- More units of a type than its `maxPerOrder`.
invalid_promoHTTP 400- The promo code does not exist or does not cover this cart.
expired_promoHTTP 400- The promo code is outside its dates.
promo_used_upHTTP 409- The promo code has reached its use limit.
promo_requiredHTTP 400- A release in the cart is gated behind a promo code and none was sent.
affiliate_requiredHTTP 400- A release in the cart is affiliate-gated and no valid `affiliateCode` was sent.
cancelledHTTP 410- The event has been cancelled.
sold_outHTTP 409- Somebody else took the last of a release between your quote and this call.
unavailableHTTP 409- The organiser has not finished connecting Stripe, so nothing can be sold yet.
plan_unavailableHTTP 409- The `paymentPlanId` is not open on every paid tier in the cart (free tiers are ignored).
too_many_holdsHTTP 429- Too many live reservations on this event from the same connection (or the same app device). Show the message and let the buyer try again in a few minutes; a hold lapses in 15 minutes.
Notes
- Rate limited to roughly 20 requests per minute per caller. Guidance, not a contract — see caching and rate limits.
Cache-Control: no-store