Grofomo API
Browse the docs

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

GrofomoYou
Holds, pricing, discounts and the booking feeThe 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 passesYour 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.

#RequirementDrop-inYour own UI
1Fee-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 youYours
2The three consent labels, verbatim, with the default tick state the feed gives you, sent back as consentTextSnapshot with formVersion. See Consent, exactly.Done for youYours
3The 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 youYours
4The attribution from checkout.legal, somewhere persistently visible. Same section.Done for youYours
5A countdown on the 15-minute hold, from reservedUntil, and an honest expiry when it lapses.Done for youYours
6Cookie consent for any pixel or analytics you run, and for remembering where a visitor came from. The checkout itself sets no cookies and loads no pixel. It keeps the in-progress order in sessionStorage, which the purchase needs; the funnel token and remembered tags are measurement and wait for consent. See What needs consent.Banner is yours, following it is done for youYours

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,
      // order.totalPennies (what was paid today), order.currency
    },
  });
</script>
OptionWhat it does
eventThe event's slug. Required.
keyThe organiser's publishable key. Required unless their API is open.
promo, affiliatePre-fill a promo code or credit an affiliate. Both fall back to ?promo= and ?aff= on your page URL.
basketInitial quantities by release id, e.g. { '<releaseId>': 2 }.
themeaccent, 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, onErrorCallbacks for analytics and your own confirmation page. onComplete gets the order id, status, email, and from 1.9.0 totalPennies and currency.
pixelfalse stops the drop-in reporting ViewContent and Purchase to a Meta Pixel your page has loaded. See Your own pixel.

Your own pixel

The drop-in loads no pixel and sets no cookies, so any Meta Pixel on your page is yours to load, behind your own banner. Once it is loaded, the drop-in reports to it on its own from 1.9.0: ViewContent when the tickets are listed, and Purchase when the order completes, with the order id as Meta's event id and the amount paid as the value. Nothing is reported while your banner has not yet let the pixel load, and a page with no pixel reports nothing. From 1.9.2 both carry grofomo_event set to the event's slug, which is what the retargeting and buyer audiences Grofomo builds for a campaign match on, so a visitor or buyer on your page counts the same as one on the Grofomo ticket page. Pass pixel: false to mount if your page fires these itself, and then send the order id as the event id and the slug as grofomo_event:

onComplete(order) {
  if (window.fbq && order.status === 'completed') {
    fbq('track', 'Purchase',
      { value: order.totalPennies / 100, currency: order.currency, grofomo_event: 'summer-fest' },
      { eventID: order.orderId });
  }
}

Which pixel. Load the one in event detail's metaPixelId, never an id copied into your code: it is the pixel the organiser's ads optimise against and the one the server-side report below goes to, and it changes when the organiser changes it. Put it in your ticket page's HTML when you build or render the page, not only from a script after load. Before an ad goes to Meta, Grofomo reads the ticket page's HTML for that id and stops the push when it is missing, so a page that fetches the id in the browser fails that check. Fetch it at build time, keep the last good id as a fallback, and rebuild when the organiser changes it. Loading it only after consent is fine, as long as the id is in the HTML.

Grofomo sends the same purchase to the organiser's pixel server-side, through Meta's Conversions API, under that same event id and value, with the buyer's email hashed so Meta can match the sale to the ad. Meta keeps one of the pair, so the sale is counted once whether the buyer's browser fired the pixel or not. Leave the event id out and Meta counts it twice.

That server-side send needs the buyer's consent. It goes only for an order whose adConsent is true; a buyer who refused your banner, or was never asked, is not sent to Meta at all, click id included. From 1.9.1 the drop-in sends it for you: it reads your banner's advertising category (Google Consent Mode's ad_user_data or ad_storage, Cookiebot's marketing, OneTrust's C0004, CookieYes's advertisement) and, failing that, takes a Meta Pixel your page has already loaded as the banner's yes. Pass adConsent: true or false to mount to decide yourself. On your own client, send adConsent on reserve or details from your banner's answer. Your privacy notice should name Meta as a recipient of purchase details for advertising measurement where the buyer agrees.

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. Registration questions are not in the drop-in yet. From 1.8.0 it sells payment plans: when every paid tier in the basket offers a plan, the buyer chooses between paying in full and each plan, sees the exact schedule before paying, and pays the deposit.

Your own design, on our client

The same file exposes the client the drop-in is built on, for a site that renders its own cart and forms. Use it rather than writing your own HTTP calls even when every pixel of the page is yours: the client carries the attribution, the funnel token and the cookie-banner handling below, so each rule we add later reaches your page without a change on your side. A hand-written client has to follow every one of them itself.

Load checkout.js on every page of your site, not only the tickets page, so a tagged landing page is seen:

<script src="https://developers.grofomo.com/checkout.js"></script>
const api = GrofomoCheckout.client({ event: 'summer-fest', key: 'pk_live_YOUR_PUBLISHABLE_KEY' });
const event = await api.event();          // name, dates, venue, brand
const catalog = await api.catalog(promo, { affiliate: GrofomoCheckout.affiliate(), artist: GrofomoCheckout.artist() }); // types, releases, prices, the checkout block
const funnel = GrofomoCheckout.funnelToken('summer-fest');
api.beacon('event_viewed', funnel);

const order = await api.reserve({
  items: [{ releaseId, quantity: 2 }],
  affiliateCode: GrofomoCheckout.affiliate(),
  artistId: GrofomoCheckout.artist(),
  attribution: GrofomoCheckout.attribution(),
  funnelSessionToken: funnel,
  resumeUrl: location.href,
});
await api.details(order.checkoutToken, { customer: { email }, orgConsent: { email: true }, platformConsent: { email: false } });
const pay = await api.pay(order.checkoutToken);
CallWhat it does for you
GrofomoCheckout.attribution()The attribution for reserve: this page's tags, plus earlier pages' once the visitor has agreed. Undefined when there is nothing to credit; send it as it is.
GrofomoCheckout.affiliate()The ambassador code to send as affiliateCode on quote and reserve: this page's ?aff=, else one an earlier page recorded once the visitor has agreed. It sticks past later visits, so an affiliate-gated tier stays visible. ?ref= is a Grofomo short link, not an ambassador; leave it to attribution().
GrofomoCheckout.artist()The artist id to send as artistId on quote and reserve and as artist to catalog: this page's ?artist=, which an artist's Grofomo link adds when it sends the buyer to your site, else an earlier artist visit once the visitor has agreed. Without it a ticket the organiser keeps for that artist's fans is never listed.
GrofomoCheckout.funnelToken(event)The funnel token, kept for the tab only with consent and otherwise for this page load. Send the same one on every beacon and as funnelSessionToken.
GrofomoCheckout.withTags(href)The link with this page's tags added, for links to your other pages (a.href = GrofomoCheckout.withTags('/tickets')). Credits a sale later in the visit without storing anything, so it needs no consent. Links to other sites are left alone.
GrofomoCheckout.rememberVisits(on)Only if your cookie banner is not Google Consent Mode, Cookiebot, OneTrust or CookieYes, which are followed automatically.

Every call throws GrofomoCheckout.Error with the API's code and status on failure, or code network and status 0 when the request never got an answer. 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_allowed before anything else.
  • The organiser has finished connecting Stripe. Until then reserve answers 409 unavailable, and only they can fix it.
  • Every request carries ?surface=web and the X-Grofomo-Key header. All of it can run in the browser with the publishable key.

The flow

  1. Ticket availability: the types, releases and prices to render, plus the checkout block with the consent wording.
  2. Quote as the cart changes: the running total and booking fee, and whether a promo code worked.
  3. Reserve: holds the cart for 15 minutes and returns the order's checkoutToken and reservedUntil. The token is the credential for the next three steps and for nothing else; it expires a day later. Show a countdown.
  4. Details: the buyer, and what they agreed to.
  5. Pay, then confirm the card with Stripe on your page. A zero-total order skips the card: complete free.
  6. 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:

LabelBoxSend as
consent.organiserEmailTicked by default (soft opt-in: the buyer is becoming the organiser's customer)orgConsent.email, with orgEmailBasis: "soft_opt_in"
consent.organiserMessagingUnticked; needs a phone numberorgConsent.sms and orgConsent.whatsapp, both from the one box
consent.platformEmailAs platformEmailDefaultTicked saysplatformConsent.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.

Remember the tags across your pages. A buyer rarely lands on the page your checkout is on: they arrive on your home page from a tagged link, then click through to tickets, and the tags do not come with them. Record every tagged visit on every page (utm_*, gclid, fbclid, and Grofomo's own ref, aff and artist), keep the first and the latest for 30 days, and send the latest as the flat fields with the first as firstTouch. Grofomo's links pass all of these on when they send a visitor to your site. Storing that on the visitor's device is yours to disclose in your cookie notice, and to ask about first where your notice treats it as optional. With checkout.js loaded on every page it is done for you once the visitor agrees: it reads your cookie banner's analytics answer from Google Consent Mode (analytics_storage), Cookiebot (statistics), OneTrust (the Performance group, C0002) or CookieYes (analytics), follows it if the visitor changes their mind, and forgets what it stored when consent is withdrawn. With another banner, call GrofomoCheckout.rememberVisits(true) once the visitor has agreed (on each page load), or false to keep it off; once your page calls it, the banner is no longer followed. GrofomoCheckout.attribution() returns the object to send when you render your own checkout. Until the visitor agrees, only the tags on the page the checkout is on count.

What needs consent

UK and EU cookie rules (PECR, and the ePrivacy rules behind it) allow storing something on a visitor's device without asking only when it is strictly necessary for what they asked for, which is buying a ticket. Measuring which link sold the ticket is useful to the organiser, but the buyer did not ask for it, so storing anything for that purpose needs their consent first. This applies whether it is a cookie, localStorage or sessionStorage.

WhatWhere it livesNeeds consent?Without consent
The basket and the in-progress order (the checkoutToken from reserve)sessionStorage, this tabNo, the purchase needs itWorks as normal
A promo code or affiliate code the buyer typed or arrived with, used in this purchaseMemory, or sessionStorage for this purchaseNoWorks as normal
Remembered tags from earlier pages or visits (utm_*, click ids, ref, aff, artist, first and latest touch)Your choice, usually localStorage or a cookie, up to 30 daysYesOnly the tags on the current page count
The funnel token (sessionToken on the funnel beacon, funnelSessionToken on reserve)sessionStorage, this tabYes to keep it, it is measurementMint a fresh token on each page load and keep it only in memory, so a reload counts as a new visit
The server-side Purchase to the organiser's Meta Pixel (adConsent on reserve or details)Nothing on the device: Grofomo posts it from the orderYes, it is advertising measurement about the buyer (IP address, click id, hashed email)Not sent. Send adConsent: false, or nothing

The funnel token is not needed for the purchase: reserve, details and pay all work without it.

Carrying tags without storing them. Without consent you can still credit a sale made in the same visit by carrying the tags in the address rather than on the device: when a tagged visitor clicks through to your tickets page, add the same query string to that link (GrofomoCheckout.withTags('/tickets') does it). Nothing is stored, so nothing needs asking about. A visitor who leaves and comes back later is not credited, and that is the trade the rules require.

With checkout.js loaded on every page, from 1.6.1 both the remembered tags and the funnel token follow your cookie banner as described under Remember the tags across your pages: nothing is kept until the visitor agrees, and what was kept is forgotten when they withdraw. You still need a banner that asks about analytics, and the lines below in your cookie notice.

Lines for your cookie notice. Adapt the names to what your site actually stores:

  • Ticket basket (gfm_checkout_<event>, session storage, essential): holds the tickets you are buying while you check out, and is cleared when you close the tab.
  • Where you came from (gfm_attr, local storage, analytics, 30 days, only with your consent): remembers which link, post or advert brought you to this site, so the event organiser can see what led to ticket sales. It holds the campaign tags and any referral code, not who you are. If you buy, the tags are saved with your order.
  • Ticket page visit (gfm_funnel_<event>, session storage, analytics, only with your consent): a random number that lets the organiser count how many visitors chose tickets and went on to pay, without counting a reload twice.

Links from Grofomo's own emails

Grofomo's emails can link to your page in two ways.

The abandoned-basket nudge returns the buyer to the page they left. Send resumeUrl on reserve: the address of the page running the checkout. If the buyer abandons, the nudge sends them back there. It is kept only when it is https, on one of the organiser's allowed domains, and (from a browser) on the same origin as the page making the request; anything else is ignored, never refused. A buyer who abandoned on the hosted page goes back to the hosted page. checkout.js sends it for you.

Everything else follows the event's ticket link. Once it points at your page, the automatic follow-ups an organiser switches on per tier link there, and so does the abandoned-basket nudge for an order that sent no resumeUrl.

Either way the 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, send no resumeUrl and 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.

To price the choice before reserving, use paymentPlans on the quote for the basket: every plan it can take right now, with depositPennies (due today, booking fee included), balancePennies and the exact schedule, priced by the same code reserve runs. Do not work the figures out from the offer's terms yourself: a per-ticket deposit counts only the units the buyer pays for, so a ticket a deal gives away carries none, and the feed cannot tell you which those are. GrofomoCheckout.planOptions(catalog, items, quote) turns that quote into display-ready options, and from 1.8.1 it uses these figures. If reserve answers plan_unavailable, the offer has closed or no longer fits the basket: fetch the catalog again and let the buyer choose again.

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.