Selling tickets
Drop checkout.js into your page, or run the purchase flow yourself.
Examples use a fictional festival, summer-fest. Sign in at events.grofomo.com and they switch to your own events.
Sell tickets on your own page. Grofomo holds the inventory, prices the cart, takes the card through Stripe and issues the tickets. Your page renders the choice, collects the buyer's details, and prints the consent wording we give you. The same write flow runs the hosted ticket page at grfm.to/e/summer-fest/tickets, so nothing here is a second-class path.
Who does what
| Grofomo | You |
|---|---|
| Holds, pricing, discounts and the booking fee | The page, in your design |
| The card, through a Stripe Payment Element on your page (card data never touches your site) | Everything under What your page must show, below |
| Tickets, the confirmation email, refunds, wallet passes | Your own cookie consent for any pixel you run |
What your page must show
Six things are not yours to design away. Each is a rule about the sale rather than a house style, and each is spelled out in full further down. The drop-in does the first five for you; on your own UI they are all yours.
| # | Requirement | Drop-in | Your own UI |
|---|---|---|---|
| 1 | Fee-inclusive price. Headline feeInclusivePricePennies, not the bare ticket price. UK drip-pricing rules bind whoever invites the purchase, which on your page is you. See ticket availability. | Done for you | Yours |
| 2 | The three consent labels, verbatim, with the default tick state the feed gives you, sent back as consentTextSnapshot with formVersion. See Consent, exactly. | Done for you | Yours |
| 3 | The attestation from checkout.legal, beside the control that commits the buyer, with linkText linked to termsUrl. See The two notices you must print. | Done for you | Yours |
| 4 | The attribution from checkout.legal, somewhere persistently visible. Same section. | Done for you | Yours |
| 5 | A countdown on the 15-minute hold, from reservedUntil, and an honest expiry when it lapses. | Done for you | Yours |
| 6 | Cookie consent for any pixel or analytics you run. The checkout itself sets no cookies and loads no pixel: it keeps a funnel token and the in-progress order in sessionStorage, both of which the purchase needs to work. | Yours | Yours |
Requirements 3 and 4 are the newest. They ship in the ticket feed rather than
in this page precisely so that "print this" is a specific instruction with the
words attached: read them from checkout.legal at runtime, do not paste them
into your source. checkout.js prints them from 1.3.0.
The drop-in
Two script tags and an element. checkout.js renders the whole purchase inside the element, in a plain style your CSS can override, against the same endpoints described below.
<div id="tickets"></div>
<script src="https://developers.grofomo.com/checkout.js"></script>
<script>
GrofomoCheckout.mount('#tickets', {
event: 'summer-fest',
key: 'pk_live_YOUR_PUBLISHABLE_KEY',
onComplete(order) {
// order.orderId, order.status ('completed'), order.email
},
});
</script>| Option | What it does |
|---|---|
event | The event's slug. Required. |
key | The organiser's publishable key. Required unless their API is open. |
promo, affiliate | Pre-fill a promo code or credit an affiliate. Both fall back to ?promo= and ?aff= on your page URL. |
basket | Initial quantities by release id, e.g. { '<releaseId>': 2 }. |
theme | accent, text, background, muted, border, radius, font. Each sets a CSS custom property (--gfm-accent and so on) on the root, so a stylesheet can set them instead. |
onStep, onComplete, onError | Callbacks for analytics and your own confirmation page. |
Everything in the drop-in is a .gfm-* class, and it adds nothing to your page beyond one <style> tag and Stripe.js, which it loads itself when a card is needed. Payment plans and registration questions are not in the drop-in yet: an event that sells plans shows them on the hosted page only.
The same file exposes the client the drop-in is built on, for a site that renders its own cart and forms:
const api = GrofomoCheckout.client({ event: 'summer-fest', key: 'pk_live_YOUR_PUBLISHABLE_KEY' });
const catalog = await api.catalog();
const order = await api.reserve({ items: [{ releaseId, quantity: 2 }] });
await api.details(order.checkoutToken, { customer: { email }, orgConsent: { email: true }, platformConsent: { email: false } });
const pay = await api.pay(order.checkoutToken);Every call throws GrofomoCheckout.Error with the API's code and status on failure. The rest of this page is the contract underneath, for the client or for your own HTTP calls.
Before you start
- The organiser adds your domain to their allowed list and gives you a publishable key, both under Settings, Developers in EventOS. See for organisers and CORS. A browser call from a domain that is not listed gets
403 origin_not_allowedbefore anything else. - The organiser has finished connecting Stripe. Until then reserve answers
409 unavailable, and only they can fix it. - Every request carries
?surface=weband theX-Grofomo-Keyheader. All of it can run in the browser with the publishable key.
The flow
- Ticket availability: the types, releases and prices to render, plus the
checkoutblock with the consent wording. - Quote as the cart changes: the running total and booking fee, and whether a promo code worked.
- Reserve: holds the cart for 15 minutes and returns the order's
checkoutTokenandreservedUntil. The token is the credential for the next three steps and for nothing else; it expires a day later. Show a countdown. - Details: the buyer, and what they agreed to.
- Pay, then confirm the card with Stripe on your page. A zero-total order skips the card: complete free.
- Order status: poll until it reads
completed, then say the tickets are on their way.
Headline the fee-inclusive price (feeInclusivePricePennies) on the page. UK drip-pricing rules bind whoever invites the purchase, and that is now you. The ticket availability reference explains the two prices.
Consent, exactly
The checkout block on the ticket feed has three labels. Print each one verbatim beside its box:
| Label | Box | Send as |
|---|---|---|
consent.organiserEmail | Ticked by default (soft opt-in: the buyer is becoming the organiser's customer) | orgConsent.email, with orgEmailBasis: "soft_opt_in" |
consent.organiserMessaging | Unticked; needs a phone number | orgConsent.sms and orgConsent.whatsapp, both from the one box |
consent.platformEmail | As platformEmailDefaultTicked says | platformConsent.email, with platformEmailBasis copied from the block |
Then send the three strings back as consentTextSnapshot under the keys organiser, messaging and platform, and formVersion from the same block. That snapshot is the record of what was agreed to, which is why the wording is ours and not yours. A channel you did not ask about is omitted, never sent as false.
The two notices you must print
The same checkout block carries a legal object. Both parts are required, and a checkout that leaves them out is not finished. They are not marketing and not consent: they are what makes the sale a sale.
"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" }
}attestation goes immediately beside the control that commits the buyer to pay - the same button that calls /details. Print text verbatim, and wrap the linkText substring in a link to termsUrl that opens in a new tab. linkText is given as a substring precisely so you never reassemble the sentence:
const { attestation } = checkout.legal;
const at = attestation.text.indexOf(attestation.linkText);
// attestation.text.slice(0, at) + <a href={attestation.termsUrl}>{attestation.linkText}</a> + attestation.text.slice(at + attestation.linkText.length)attribution goes somewhere persistently visible, typically a footer on the checkout.
If you use the drop-in, both are already printed: checkout.js 1.3.0 renders the attestation above its submit button and the attribution under every step, from this same block, so a wording change here reaches your page without you redeploying it. Everything below is for a checkout you render yourself.
Why this is on you and not on us: a ticket order is two separate supplies. Admission is sold by the organiser as principal, with Grofomo as their disclosed agent. The booking fee is charged by Grofomo in its own right, and it is a supply from us to the buyer, with our own VAT on it and our Terms behind it. On the hosted ticket page the buyer sees that plainly. On your page they see only what you render, so if you omit these two notices the buyer has bought a service from a company they were never shown, under terms they were never given - and the age confirmation our Terms require at §2 never happens. Print them.
Taking the card
Load Stripe.js from https://js.stripe.com/v3/ and mount a Payment Element with the secret from pay:
const { data: pay } = await api('POST', `/tickets/orders/${order.checkoutToken}/pay`);
const stripe = Stripe(pay.publishableKey);
const elements = stripe.elements({ clientSecret: pay.clientSecret });
elements.create('payment', { layout: 'tabs' }).mount('#card');
// On submit:
const { error, paymentIntent } = await stripe.confirmPayment({
elements,
redirect: 'if_required',
confirmParams: { return_url: window.location.href },
});
if (error) showMessage(error.message);
else if (paymentIntent.status === 'succeeded' || paymentIntent.status === 'processing') {
await waitForTickets(order.checkoutToken);
}The publishable key in the response is Grofomo's and is meant to be public. The client secret pays this one order and nothing else.
Some payment methods leave your page and come back. Keep the checkoutToken in sessionStorage before confirming (never in the URL); when the page loads with payment_intent_client_secret in the URL, skip straight to the waiting step with the stored token.
After the payment
Tickets are issued when the payment is confirmed server-side, usually within a minute of the card being accepted. Poll order status every few seconds until orderStatus is completed:
async function waitForTickets(checkoutToken) {
for (let attempt = 0; attempt < 24; attempt++) {
const { data } = await api('GET', `/tickets/orders/${checkoutToken}`);
if (data.orderStatus === 'completed') return showConfirmed(data.codes);
await new Promise((r) => setTimeout(r, 2500));
}
showConfirmedSoftly(); // the confirmation email still arrives
}Grofomo sends the confirmation email with the tickets. There is nothing for you to send. The same response carries order (the lines and totals, and the shortId printed on the tickets) for the receipt on your page, any codes the buyer earned on the organiser's other events, and ambassador (their own share code and reward ladder) when the organiser runs a programme.
Holds and expiry
A reserve holds the tickets for 15 minutes. After reservedUntil, details and pay answer 409 expired and the only way on is a new reserve; nothing needs cancelling, an unpaid hold releases itself. Show a countdown and a "start again" path, and do not reserve until the buyer has chosen: a hold placed on page load takes tickets away from other buyers for nothing.
Promo codes, affiliates and attribution
?promo= on the ticket feed reveals promo-gated types. Quote tells you whether a typed code applies (promoApplied) and whether it also unlocks hidden types (unlocksVisibility, then fetch the feed again with the code). Send the code on reserve; a bad code there does not fail the order, it is simply not applied.
An affiliate link arrives as ?aff=<code> on your page. Send it as affiliateCode on quote and reserve so the sale is credited and affiliate-gated tiers show.
attribution on reserve is optional and PII-free: the UTM tags, click ids and referrer host from the page URL, for the organiser's sales-channel report. The funnel beacon is optional too, and is what puts the top of the funnel on that report.
Links from Grofomo's own emails
Once the event's ticket link points at your page, Grofomo's emails point there too: the abandoned-basket nudge, and the automatic follow-ups an organiser switches on per tier. Those links carry three things your page must honour, or the email lands worse than it would on the hosted page.
The basket. The abandoned-basket link arrives as ?basket=<releaseId>:<qty>,<releaseId>:<qty>, so the buyer lands with the tickets they left already in the basket. Read it, then clamp every entry against the feed as it is now: nothing was held, so a tier that has since sold out seeds nothing, a tier with three left seeds at most three, the type's maxPerOrder still binds, and an id the feed does not list is dropped. Never send an unclamped seed to reserve. checkout.js does all of this itself.
The code. When the basket was priced with a promo code, the link carries it as ?promo=<code>, and it is load-bearing rather than a convenience: a tier that code unlocked is hidden again to a request without it. Fetch the ticket feed with ?promo= BEFORE you clamp the basket, or the clamp drops the very line the buyer came back for, and send the same code as promoCode on quote and reserve. Order matters here: clamping against a feed fetched without the code silently empties the basket it was sent to restore. checkout.js reads the parameter and loads the feed with it before clamping.
The attribution. The same links carry utm_source=grofomo, utm_medium=email and a utm_content naming the email. Pass them through as attribution on reserve, exactly as you would for any tagged visit; that is what lets the organiser's sales report say a sale was recovered by the nudge rather than counting it as direct traffic. checkout.js passes them.
Until your page honours all three, leave the event's ticket link unset, and the emails keep sending buyers to the hosted page, which does.
Payment plans
A release may carry paymentPlans: instalment offers with a deposit and a schedule. Send the chosen paymentPlanId on reserve (it must be open on every PAID tier in the cart; a free tier such as an under-5 ticket carries no offers and is ignored, so it never removes the plan), show the schedule from the reserve response, and pay charges the deposit plus the booking fee while saving the card for the instalments. The buyer manages the plan afterwards on a hosted page linked from their confirmation email.
Content Security Policy
If your site sets one, allow https://js.stripe.com in script-src and frame-src, https://hooks.stripe.com in frame-src, and https://api.grofomo.com plus https://api.stripe.com in connect-src.
What stays on the hosted page
Registration questions (the organiser's per-ticket questions) are not published on this API yet, so an event that asks them should link to the hosted ticket page for now. Wallet passes, ticket resends and refunds are Grofomo's, reached from the confirmation email.