Ticket availability
/v1/events/{slug}/ticketsThe ticket types and release tiers a visitor is allowed to see, with live availability.
Visibility is resolved server-side, so this only ever returns what the public should see: time-gated tiers appear when they open, and hidden sold-out tiers stay hidden.
Read soldOut rather than inferring from available. available is null both when a tier is unlimited and when the organiser has chosen not to publish counts — soldOut is always authoritative.
Every release carries two prices. pricePennies is the face value; feeInclusivePricePennies is the total a buyer pays for one unit, and the difference between them is the per-unit booking fee. On a consumer-facing page, headline the fee-inclusive figure, because UK drip-pricing rules (the DMCC Act) bind whoever invites the purchase, which includes a site that advertises a price and links out to checkout. The face value is yours to use where that does not apply: as the "+ booking fee" half of a breakdown, for a non-UK audience, or in B2B and reporting contexts.
n of a tier cost exactly n times feeInclusivePricePennies. The fee is linear in quantity, so a running total you compute as quantity x the advertised price agrees with us to the penny. Two carts pay LESS than that and never more: one whose items were re-priced by a promo code or a buy-more-save-more tier, and one small enough that the policy's per-order minimum fee fires. bookingFeePolicy is on the payload if you want to derive the fee yourself, and the server stays authoritative at reserve either way.
Featuring a deal
quantityDiscounts lists the automatic discounts on this event: buy-more-save-more tiers, buy-some-get-some-free, and fixed bundle prices. They apply themselves at checkout, so there is no code for a visitor to enter and nothing for you to do beyond advertising them.
Show label, do not build your own string from the numbers. The trap is buy_n_get_m: buyQuantity: 4, freeQuantity: 1 reads as "buy 4, get 1 free", but the rule needs buyQuantity + freeQuantity in the basket before the cheapest one goes free. A visitor who adds four gets nothing and never finds out why. label says "5 for the price of 4", which is the number they have to reach. Grofomo's own storefront shipped the wrong phrasing before this field existed.
A rule with freeReleaseId gives a different ticket away ("buy 1 early bird, get a free shuttle bus"). There buyQuantity is the true paid count, and the free units come off that release, but only ones the visitor has actually added: the server never puts a ticket in the basket for them. If you build a cart, add freeQuantity of the free release alongside the paid ticket, or the deal is advertised and never taken.
For bundle_price, label is already fee-inclusive, for the same drip-pricing reason as the prices above: a site advertising "5 for £80" is inviting the purchase, so £80 must be what the buyer actually pays.
applicableReleaseIds scopes a rule to particular tiers; an empty array means the whole order.
It is a view of what you can buy right now, not the organiser's configuration. The ids are filtered to the releases THIS request can currently purchase, so the array is routinely shorter than what the organiser ticked, and it changes on its own as tiers open, sell out, close, or are revealed by a ?promo= code. A rule whose whole scope is invisible is omitted from the response entirely: its absence means nobody can take that deal today, never that the organiser deleted it. So do not diff this against the console, cache it as configuration, or read a single id as "they only picked one tier" - a five-tier deal on an event where one tier is on sale correctly reports that one tier. Rules on the same tickets never stack: of those, the single best one wins. The one exception is a rule that gives a different ticket away (freeReleaseId set): it combines with the best deal on the tickets that earned it, because "5 adults for the price of 4" and "buy 4 adults, get a child ticket free" are two offers about two tickets and a buyer who qualifies for both gets both. Do not add savings together yourself; quote tells you what applies.
Working out what a given basket saves is deliberately not your job: totals are computed at checkout, where the server is authoritative.
Selling from your own page
The checkout block carries the consent wording a checkout must print and the form version to send back with the buyer's details. The purchase itself runs through quote, reserve, details and pay; the selling tickets guide walks the whole flow.
404 not_found for no visible reason.Request
curl -sS \
-H 'X-Grofomo-Key: pk_live_YOUR_PUBLISHABLE_KEY' \
'https://api.grofomo.com/v1/events/summer-fest/tickets?surface=web'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`.
promostring- A live unlock code, to reveal gated ticket types and the hidden tiers that code names.
Response
{
"apiVersion": 1,
"data": {
"categories": [
{
"id": "6f1c8e2a-4b3d-4c9e-9f21-8a7d5e0b1c35",
"title": "Weekend",
"description": "All three days, with camping."
}
],
"types": [
{
"id": "b2d4a6f8-1c3e-4a5b-8d7f-2e9c0a1b3d46",
"categoryId": "6f1c8e2a-4b3d-4c9e-9f21-8a7d5e0b1c35",
"title": "Weekend camping ticket",
"description": "Entry Friday to Sunday, plus a camping pitch from Friday 10:00.",
"descriptionDoc": {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "Entry Friday to Sunday, plus a camping pitch from Friday 10:00."
}
]
}
]
},
"ticketsPerGroup": 1,
"minPerOrder": 1,
"maxPerOrder": 6,
"isHighlighted": true,
"highlightMessage": "Most popular",
"paymentPlan": {
"depositBps": 2500,
"installments": 3
},
"releases": [
{
"id": "3e5a7c9b-0d2f-4e6a-9b8c-1d3f5a7e9c03",
"label": "Tier 1",
"pricePennies": 9500,
"feeInclusivePricePennies": 10237,
"currency": "GBP",
"available": null,
"capacity": null,
"closesAt": null,
"opensAt": null,
"upcoming": false,
"soldOut": true,
"closed": false
},
{
"id": "9c7e5a3b-1f0d-4b2e-8a6c-4d2f0b8e6a14",
"label": "Tier 2",
"pricePennies": 11000,
"feeInclusivePricePennies": 11850,
"currency": "GBP",
"available": 340,
"capacity": 1500,
"closesAt": "2026-07-10T22:59:00.000Z",
"opensAt": null,
"upcoming": false,
"soldOut": false,
"closed": false,
"paymentPlans": [
{
"id": "d4c2b0a8-6e1f-4d3c-9b7a-5e8f2c1a0b36",
"name": "Early bird plan",
"deposit": {
"kind": "percent",
"value": 2500
},
"installments": 3,
"dueDates": [
"2026-04-01T08:00:00.000Z",
"2026-05-01T08:00:00.000Z",
"2026-06-01T08:00:00.000Z"
],
"payByAt": "2026-07-10T10:00:00.000Z"
}
]
},
{
"id": "47a9e1c3-5b8d-4f20-9c6e-3a1b7d5f9e03",
"label": "Final release",
"pricePennies": 12500,
"feeInclusivePricePennies": 13462,
"currency": "GBP",
"available": null,
"capacity": null,
"closesAt": null,
"opensAt": "2026-07-11T09:00:00.000Z",
"upcoming": true,
"soldOut": false,
"closed": false
}
]
}
],
"quantityDiscounts": [
{
"id": "d1f3b5a7-9c2e-4d80-8b6f-5a3c1e7d9b05",
"name": "Group of four",
"kind": "tiered_percent",
"applicableReleaseIds": [
"9c7e5a3b-1f0d-4b2e-8a6c-4d2f0b8e6a14"
],
"tiers": [
{
"minQuantity": 4,
"value": 1000
}
],
"combinableWithPromo": false,
"label": "Buy 4+ and save 10%"
},
{
"id": "f4a2c8e0-6b1d-4739-9e5a-2c7b0d4f8a16",
"name": "Group Saver",
"kind": "buy_n_get_m",
"applicableReleaseIds": [
"9c7e5a3b-1f0d-4b2e-8a6c-4d2f0b8e6a14"
],
"buyQuantity": 4,
"freeQuantity": 1,
"combinableWithPromo": false,
"label": "5 for the price of 4"
}
],
"bookingFeePolicy": {
"perTicketPennies": 25,
"percentBps": 750,
"capPennies": 0,
"minPennies": 50
},
"checkout": {
"consent": {
"organiserEmail": "We'll email you about future Riot Promotions shows, presales and lineup drops. Untick if you'd rather not - unsubscribe anytime.",
"organiserMessaging": "Be first in line: on-sale alerts, presale codes and last-minute ticket drops from Riot Promotions by SMS or WhatsApp. Reply STOP anytime to opt out.",
"platformEmail": "Grofomo runs this checkout and delivers your tickets. Tick to get occasional email picks of similar events on Grofomo. Unsubscribe anytime."
},
"platformEmailBasis": "consent",
"platformEmailDefaultTicked": false,
"formVersion": "storefront-web-3",
"legal": {
"attestation": {
"text": "By continuing you confirm you are 18 or over and accept the Grofomo Terms.",
"linkText": "Grofomo Terms",
"termsUrl": "https://events.grofomo.com/terms",
"privacyUrl": "https://events.grofomo.com/privacy"
},
"attribution": {
"text": "Ticketing by Grofomo",
"url": "https://events.grofomo.com"
}
}
}
}
}categoriesobject[]- Optional grouping for the types below.
categories[].iduuid- Referenced by `categoryId` on a ticket type.
categories[].titlestring- Section heading, e.g. "Weekend".
categories[].descriptionstring | nulltypesobject[]- Only what this caller is allowed to see. Hidden and promo-gated types are filtered server-side.
types[].iduuidtypes[].categoryIduuid | null- Groups this type under a category. Null when ungrouped.
types[].titlestringtypes[].descriptionstring | null- Plain-text description, for clients that render no rich text.
types[].descriptionDocobject | null- The same description as a ProseMirror document. Render this when present, otherwise `description`.
types[].descriptionDoc.type"doc"types[].descriptionDoc.contentobject | object | object | object | object | object[]may be absenttypes[].ticketsPerGroupnumber- Admissions issued per unit bought. 1 is a single ticket; higher means a table or group ticket.
types[].minPerOrdernumber- Fewest units one order may contain.
types[].maxPerOrdernumber- Most units one order may contain.
types[].isHighlightedboolean- The organiser wants this type visually promoted.
types[].highlightMessagestring | null- Badge text for a highlighted type, e.g. "Most popular".
types[].paymentPlanobject | null- Deprecated: read `paymentPlans` on each release instead. Kept for older clients; set only when every purchasable tier of this type shares one open percent-deposit offer, and then carries that offer's terms. Null otherwise, including when the tiers differ.
types[].paymentPlan.depositBpsnumber- Deposit as basis points of the face value, so 2500 is 25%.
types[].paymentPlan.installmentsnumber- Number of monthly payments after the deposit.
types[].releasesobject[]- Price tiers, in sale order. Only tiers this caller is allowed to see are included.
types[].releases[].iduuidtypes[].releases[].labelstring- Release name, e.g. "Tier 2" or "Final release".
types[].releases[].pricePenniesnumber- Price in minor units, so 9500 is £95.00.
types[].releases[].feeInclusivePricePenniesnumbermay be absent- The total a buyer pays for one unit including the mandatory booking fee, computed at the type's minimum order size. Advertise THIS figure (UK drip-pricing rules require the fee-inclusive total); `pricePennies` is the face value. The difference is the per-unit booking fee. Additive; absent on older servers.
types[].releases[].currencystring- ISO 4217 code, e.g. "GBP".
types[].releases[].availablenumber | null- Tickets left. Null means EITHER unlimited OR the organiser has chosen not to publish counts — so this can never gate a buy button on its own. Use `soldOut`.
types[].releases[].capacitynumber | nullmay be absent- Total allocation, hidden under the same rule as `available`. With it you can show percent sold.
types[].releases[].closesAtstring | nullmay be absent- When this release stops selling. Null means no cut-off.
types[].releases[].opensAtstring | nullmay be absent- When this release starts selling. Set alongside `upcoming`, and the target for a countdown.
types[].releases[].upcomingbooleanmay be absent- A teaser: listed but not yet purchasable. Takes precedence over `soldOut` and `closed`, which are both false while it holds.
types[].releases[].soldOutboolean- Authoritative. Gate your buy button on this, never on `available`.
types[].releases[].closedboolean- The sale window has ended. Still shown, no longer purchasable.
types[].releases[].paymentPlansobject[]may be absent- Payment plan offers open on this tier for an order placed now, in organiser order. Empty means pay in full only. An offer can disappear with no organiser action as the event approaches and there is no longer time to collect its instalments, so never cache it across a session. A mixed cart may use only an offer that appears on every PAID tier in it; free tiers carry no offers and are ignored, so adding a £0 ticket never removes the plan. Additive; absent on older servers.
types[].releases[].paymentPlans[].iduuid- Pass as `paymentPlanId` on reserve to buy on this plan.
types[].releases[].paymentPlans[].namestring- Organiser-facing name shown to the buyer, e.g. "Early bird plan".
types[].releases[].paymentPlans[].depositobject- What is paid today, before the booking fee. The booking fee is always taken in full with the deposit.
types[].releases[].paymentPlans[].deposit.kind"percent" | "fixed"types[].releases[].paymentPlans[].deposit.valuenumbertypes[].releases[].paymentPlans[].installmentsnumber- Number of monthly payments after the deposit. Fixed per offer.
types[].releases[].paymentPlans[].dueDatesstring[]- ISO instants of each payment for an order placed NOW: monthly anniversaries of today at 09:00 in the event timezone, the last on or before the settle-by date. Re-read with the catalog; do not cache across a session.
types[].releases[].paymentPlans[].payByAtstring- ISO instant the plan must be fully paid by (the event start minus the offer's margin).
quantityDiscountsobject[]may be absent- Automatic discounts, in enough detail to advertise ("Buy 4+ and save 10%") and preview locally. The server stays authoritative at checkout.
quantityDiscounts[].iduuidquantityDiscounts[].namestring- Organiser-facing name, e.g. "Group of four".
quantityDiscounts[].kind"tiered_percent" | "tiered_fixed" | "buy_n_get_m" | "bundle_price"- Which of the fields below apply, and how `tiers[].value` should be read.
quantityDiscounts[].applicableReleaseIdsuuid[]- Releases this rule applies to. Empty means the whole order.
quantityDiscounts[].tiersobject[]may be absent- Present for the two tiered kinds.
quantityDiscounts[].tiers[].minQuantitynumber- Quantity at which this tier starts applying.
quantityDiscounts[].tiers[].valuenumber- Basis points off for `tiered_percent` (1000 = 10%), or pennies off per ticket for `tiered_fixed`.
quantityDiscounts[].buyQuantitynumbermay be absent- The "buy N" of a `buy_n_get_m` rule.
quantityDiscounts[].freeQuantitynumbermay be absent- The "get M free" of a `buy_n_get_m` rule.
quantityDiscounts[].freeReleaseIduuidmay be absent- For a `buy_n_get_m` rule that gives away a DIFFERENT ticket ("buy 1 early bird, get a free shuttle bus"): the release given free. Every full group of `buyQuantity` paid units in scope frees `freeQuantity` units of this release, but only ones the visitor has put in the basket, so add it to the cart alongside the paid ticket. Absent means the same-pool rule: the cheapest units in scope go free.
quantityDiscounts[].bundleSizenumbermay be absent- Tickets per bundle, for `bundle_price`.
quantityDiscounts[].bundlePricePenniesnumbermay be absent- Price of one whole bundle, for `bundle_price`.
quantityDiscounts[].combinableWithPromoboolean- Whether a promo code can stack on top of this discount.
quantityDiscounts[].labelstringmay be absent- Ready-to-display wording for this deal, e.g. "5 for the price of 4". Prefer it over building a string from the numbers: for `buy_n_get_m` the buyer must add buyQuantity + freeQuantity, so phrasing it as "buy N, get M free" tells them to add too few. Fee-inclusive for `bundle_price`. Additive; absent on older servers.
bookingFeePolicyobjectmay be absent- The resolved booking-fee policy for this event, so a client can show the exact fee for any cart: max(min(floor(subtotal × percentBps / 10000) + perTicketPennies × ticketCount, cap), minPennies), with cap 0 meaning uncapped and the fee computed on the post-discount subtotal. `ticketCount` counts units the buyer PAYS for: a £0 tier does not count, and neither does a unit given away by a `buy_n_get_m` rule, so "5 for the price of 4" costs exactly what 4 cost. The server stays authoritative at reserve. Additive; absent on older servers.
bookingFeePolicy.perTicketPenniesnumber- Fixed booking fee per ticket, in minor units.
bookingFeePolicy.percentBpsnumber- Percentage of the discounted subtotal, in basis points (300 = 3%).
bookingFeePolicy.capPenniesnumber- Hard cap on the fee per order, in minor units. 0 means uncapped.
bookingFeePolicy.minPenniesnumber- Minimum fee per order, in minor units.
checkoutobjectmay be absent- What a checkout on your own site must show the buyer, so the consent it collects is the consent the organiser and Grofomo rely on. Print each string verbatim and send it back on the details step. Absent on the app surface.
checkout.consentobjectcheckout.consent.organiserEmailstring- Label for the organiser marketing-email box. Show it PRE-TICKED (soft opt-in: the buyer is becoming the organiser's customer) and send the result as `orgConsent.email` with `orgEmailBasis: "soft_opt_in"`.
checkout.consent.organiserMessagingstring- Label for the organiser SMS and WhatsApp box. Show it UNTICKED; it needs a phone number. Send the one result as both `orgConsent.sms` and `orgConsent.whatsapp`.
checkout.consent.platformEmailstring- Label for Grofomo's own marketing-email box, asked separately from the organiser's. Send the result as `platformConsent.email`.
checkout.platformEmailBasis"consent" | "soft_opt_in"- How to show the Grofomo box: `soft_opt_in` means pre-ticked, `consent` means unticked. Send it back as `platformEmailBasis`.
checkout.platformEmailDefaultTickedboolean- The initial state of the Grofomo box. Follows `platformEmailBasis`.
checkout.formVersionstring- Names the CONSENT wording above. Send it back as `formVersion` on the details step. It does not cover `legal`, which is notice rather than evidence and changes independently.
checkout.legalobjectmay be absent- Legal notices your checkout MUST print. Absent on older servers only; treat its absence as a bug to fix, not as permission to skip the notices.
checkout.legal.attestationobject- The 18+ and Terms confirmation. Required on every checkout surface.
checkout.legal.attestation.textstringcheckout.legal.attestation.linkTextstringcheckout.legal.attestation.termsUrlstringcheckout.legal.attestation.privacyUrlstringcheckout.legal.attributionobject- Names Grofomo as the party supplying the booking service and charging the booking fee. A ticket order is two supplies: admission sold by the organiser as principal, and the booking fee charged by Grofomo in its own right. The buyer is entitled to know who they are paying, and it is what makes the fee line on their VAT receipt make sense.
checkout.legal.attribution.textstringcheckout.legal.attribution.urlstringcheckout.legal.organiserobjectmay be absent- What the ORGANISER must tell the buyer, as opposed to what Grofomo must. They are the seller and the data controller for this sale, so their privacy notice and their refund position are theirs to supply and cannot be discharged by Grofomo’s own policies. Print it 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.
checkout.legal.organiser.namestring- The organiser’s display name, as the seller. Use it wherever you name who the buyer is buying from.
checkout.legal.organiser.privacyPolicyUrlstringmay be absent- REQUIRED to print when present. The organiser’s own privacy notice. They are the controller for everything this checkout collects, so linking only Grofomo’s policy leaves their Article 13 duty undischarged. Absent only where the organiser has not supplied one.
checkout.legal.organiser.refundPolicyLinestringmay be absent- The organiser’s discretionary refund position, already written as a sentence. Absent when they have set none. Print it VERBATIM and on its OWN line, directly above refundCancellationLine.
checkout.legal.organiser.refundCancellationLinestring- REQUIRED. The statutory floor, which applies whatever the line above says. Print it VERBATIM on a SEPARATE line beneath refundPolicyLine, never joined into one paragraph: the booking fee comes back on a cancellation and does not on a change of mind, so the two read as a contradiction when run together.
checkout.legal.organiser.merchandiseobjectmay be absent- REQUIRED to print when the order can contain merchandise. Merchandise is goods rather than a dated leisure service, so the buyer usually keeps a statutory right to cancel that a ticket does not carry, and this information must reach them BEFORE the order is placed and be preserved in the confirmation. Absent when the event sells no merchandise.
checkout.legal.organiser.merchandise.sellerDetailsstring- Who the buyer is buying the goods from, and how to contact them.
checkout.legal.organiser.merchandise.deliveryInfostring- Where the goods ship and how long they take, or that they are collected at the event.
checkout.legal.organiser.merchandise.returnsInfostring- How to cancel and where to send goods back. Telling the seller and returning the goods are two separate steps with two separate deadlines.
checkout.legal.organiser.merchandise.returnCostsstring- Who pays return postage.
checkout.legal.organiser.merchandise.statutoryExceptionsstringmay be absent- Any genuine statutory exception, such as goods made to the buyer’s specification. Usually absent.
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`.
Notes
- Rate limited to roughly 60 requests per minute per caller. Guidance, not a contract — see caching and rate limits.