Base URLs & variables
Throughout this documentation we use the following variables. Replace them with your own values when making requests.
| Variable | Production | Staging |
|---|---|---|
{API_BASE_URL} |
https://api-v2.thegoodgame.com |
https://staging.api-v2.thegoodgame.com |
{WEB_BASE_URL} |
https://my-v2.thegoodgame.com |
https://staging.my-v2.thegoodgame.com |
| Your team's console | https://app-v2.thegoodgame.com |
https://staging.app-v2.thegoodgame.com |
{organization_id} |
Your organization's unique identifier (UUID), provided by The Good Game. | |
{event_id} |
The unique identifier (UUID) of an event, returned by the events list endpoint. Legacy numeric ids also resolve. | |
{booking_id} |
A booking's UUID. Returned by the checkout booking-complete event
and by the portal's bookings endpoints.
|
|
{API_KEY} |
Your Partner API key. Issued by us, shown once at creation. Used by clearance and staffing only. | |
The API prefix is /api/v1 and appears once — e.g.
{API_BASE_URL}/api/v1/partner/jobs.
Auth models
Three, depending on what you're calling. You never hold a parent's credential in any of them.
| Surface | Auth | Notes |
|---|---|---|
| Public event endpoints | None | Read-only and unauthenticated, so they can be called from a server-rendered page with no secret. But the response carries more than the catalogue — participant names, commercial totals and private events are all reachable without a key today. Read the note on the event payload before you forward any part of a raw response to a browser. |
| Partner API (clearance, staffing) | Authorization: Bearer {API_KEY} or X-API-Key |
Keys are issued by us, expire if you ask them to, and are revocable. Your
partner organization must be active — suspended partners get
401 on every route regardless of key validity. Scopes and the 60
req/min allowance are recorded on the key and the org, but
neither is enforced yet on the staffing and
clearance routes.
|
| Parent-facing endpoints | The parent's own JWT | Established inside our checkout or portal and never exposed to you. This is why there's no server-to-server booking API today. |
Response envelope
All API responses are wrapped in a data envelope with a timestamp:
{ "data": { /* payload */ }, "timestamp": "2026-08-06T17:57:02.785Z" }
Money is always cents
Every monetary field is an integer in the smallest currency unit, suffixed
Cents or _cents (e.g. 64500 = $645.00 USD).
Descriptions are HTML
description fields contain sanitized HTML. Render as rich text, or
strip tags for plain-text use.
Times are UTC instants
Timestamps are ISO-8601 UTC. An event also carries its own timeZone,
which is the zone its schedule should be displayed in.
Every field is optional
A documented response is the most an endpoint returns. Organizations can withhold individual fields, so the same route can answer differently for two orgs. Code defensively — never require a field to be present.
Signing & verification Preview
Rather than polling, give us an HTTPS endpoint and we'll push signed events to it. You provide the URL; we generate the signing secret and can rotate it on request. Used by clearance and staffing.
Request shape
POST /your/webhook/path
Content-Type: application/json
X-TGG-Signature: t=1780000000,v1=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b...
X-TGG-Event-Type: offer.accepted
X-TGG-Delivery-Id: 3f2a9c10-1111-4222-8333-444455556666
{
"id": "3f2a9c10-1111-4222-8333-444455556666",
"type": "offer.accepted",
"data": { /* event-specific payload */ },
"createdAt": "2026-08-06T17:12:04.001Z"
}
Verifying the signature
X-TGG-Signature is t=<unix seconds>,v1=<hex>,
where the hex is an HMAC-SHA256 over the exact string
<t>.<raw request body> keyed with your webhook secret. Verify
against the raw body — re-serializing the JSON changes the bytes and breaks the
check.
const crypto = require('crypto');
function verifyTggWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = {};
for (const kv of signatureHeader.split(',')) {
const i = kv.indexOf('=');
parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
}
// Reject replays.
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(parts.v1 ?? '', 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Event types
| Product | Type | Fires when | Fires today? |
|---|---|---|---|
| Staffing | offer.accepted |
A coach accepts your offer. | Yes |
| Staffing | offer.declined |
A coach declines. | Yes |
| Staffing | offer.countered |
A coach counters on price, or you counter theirs. | Yes |
| Staffing | offer.withdrawn |
You withdraw an offer. | Yes |
| Staffing | offer.expired |
An offer's expiresAt passes unanswered. |
No — nothing expires offers |
| Staffing | payment.captured |
Funds captured for a job. | No — no emitter |
| Staffing | payment.payout_sent |
Payout sent to the worker. | No — no emitter |
| Staffing | payment.payout_failed |
A payout attempt failed. | No — no emitter |
| Clearance | candidate.onboarding_started |
An invited candidate begins onboarding. | No |
| Clearance | candidate.c2j_cleared |
A candidate passes clearance and is hireable. | No |
| Clearance | candidate.c2j_failed |
A candidate does not clear. | No |
Registration has no webhooks — in the embedded drawer you get the in-page
booking-complete callback
instead. Registration webhooks are on the roadmap alongside
booking writes.
Delivery & retries
| Success |
Any 2xx. Respond fast and do your work asynchronously — we time out
at 15 seconds.
|
| Retries | Non-2xx, timeout or connection error retries at 1 min, 5 min, 30 min, 2 hours, then 6 hours. After the last attempt the delivery is parked in a dead-letter queue we can inspect and replay. |
| Cadence | A drain runs every 2 minutes, so first delivery is near-real-time but not instant. |
| Idempotency |
Deduplicate on X-TGG-Delivery-Id (also the body's id).
A retried delivery reuses the same id; assume at-least-once.
|
Status — read this before you build against it
The transport is live in production. Signing, the retry ladder, the dead-letter queue, the two-minute drain, the admin replay and the test-delivery button all run today. If an event is emitted, it will reach you with a valid signature and be retried properly.
Emission is per event type; the table above is the truth. The rest are in the catalog but have no emitter, so a listener for them never runs. Build against the "Yes" rows and tell us which others you need.
Your organization record
These are set by us, not passed per request, and they apply consistently across the hosted pages, the checkout drawer, and the parent portal. Send us the values and we'll configure them.
| Setting | Effect |
|---|---|
| Organization name & logo | Used throughout the drawer, the hosted pages and the portal shell. |
| Parent-portal logo override | A different mark for the signed-in portal, if you want one. |
| Events site URL & events label | Where "Browse our events" links back to, and the noun you use for them (e.g. "camps" instead of "events"). |
| Meta Pixel ID |
Fires the Purchase conversion after a booking. fbclid and
_fbp are forwarded from your page into the drawer automatically.
|
| App Store / Play Store links | Cross-platform CTAs pointing at the TGG mobile app. |
| Memberships published | Derived. When your org publishes memberships, the portal's Memberships page and nav item appear; otherwise they're hidden entirely. |
| Hide athlete requirements | Switches clearance surfaces off if your own process already covers compliance. |
| Disable self-cancellation | Turns off self-service cancellation requests, so cancellations must come through your staff. |
| Feature flags | Additive per-org toggles for anything else we gate. |
That table is exactly what
GET {API_BASE_URL}/api/v1/pp/org/{organization_id}/config returns, so you
can inspect what's set for your org rather than asking. It's live in production and
authenticated as a parent — call it with a signed-in session, not a partner key.
Configured elsewhere, not on that endpoint
| Setting | Effect |
|---|---|
| Checkout mode (Multi-Camp Cart) | OFF (default): single-camp checkout — one camp per checkout. ON: multi-camp checkout — several camps from the same organization, one cart, one checkout and one charge. Set at organization level in TGG admin; ask your TGG contact to enable or disable it. Not a per-event setting or a code flag. Visual comparison · Full cart reference. |
| Requirement sets | Which Clear2Join credentials an event demands, and how each policy must be accepted — informational, checkbox, or drawn signature. Set per organization and per event. |
| Checkout hold duration | How long a seat is held during checkout. 15 minutes unless the event overrides it. |
| Fee model | How platform fees are applied and who absorbs them. |
Customization limits
The honest answer to "how much can I change?", surface by surface. Deeper white-labeling than what's listed is a conversation, not a config flag — but it's a conversation we have regularly.
| Surface | Product · Family | Branding | Layout & CSS | SEO credit | Hosted by |
|---|---|---|---|---|---|
| Hosted storefront & booking | Registration · embedded | Org logo, name, event content | No — it's our page | Our domain | TGG |
| Parent portal | Registration · embedded | Logo, portal logo override, name, events label, pixel | No — but surfaces toggle off (requirements, cancellation, memberships) | n/a (signed-in area) | TGG |
| Checkout drawer | Registration · embedded | Your logo, org name, sponsors, Meta pixel | No — cross-origin iframe by design (card data never enters your page) | n/a (overlay on your page) | TGG |
| Multi-Camp Cart widget | Registration · embedded | accent, buttonColor, iconColor |
cartWidget controls position, label and
checkoutLabel. It styles the widget; it cannot enable or disable
Multi-Camp Cart.
|
n/a (widget on your page) | TGG SDK |
| Chrome-free iframe pages | Registration · embedded | Event and org content | Partly — your surrounding layout; the frame is ours | Our domain | TGG |
| Event-card fragments | Registration · embedded | Your page, our cards | Yes — scope CSS to data-tgg-fragment |
Your domain | You |
| Public event API | Registration · API | Total — it's JSON | Total | Your domain | You |
| Clearance in our surfaces | Clearance · embedded | Inherits the host surface | No — or switch the surfaces off entirely | n/a | TGG |
| Clearance API | Clearance · API | Total | Total | Your domain | You |
| Staffing API | Staffing · API | Total | Total | Your domain | You (worker app is ours) |
Copy isn't a column because the answer is the same everywhere: all event-facing text — titles, descriptions, policies, custom questions, FAQs — comes from your own event data on every surface. Only the surrounding UI chrome is ours, and it's localized.
Where the real flexibility is
Not in CSS overrides on our surfaces — in which option you pick. If the drawer's look is the blocker, go API for discovery and keep the drawer only for the payment moment. If our pages are the blocker, take the JSON and render everything.
Status labels
Every status in this documentation reflects what is actually deployed, not what is planned. Where something isn't ready, the gap is named rather than glossed.
| Label | Means | Safe to build against? |
|---|---|---|
| Live | Shipped and running in production right now, with partners on it. | Yes. |
| Deployed | Reachable in production and working, but no partner has run it yet — so you'd be first, and the rough edges haven't been found by anyone but us. | Yes, with a conversation first. |
| In development | Not ready to be depended on, for one of two reasons: it isn't deployed yet (the URLs won't respond at all), or it is deployed and works but has named gaps and no partner has run it in production. Which one it is is always stated on the product page — Staffing is the current example: deployed and working, but no partner has run it yet. | Design for it and prototype against it, but don't commit a launch date until we confirm. |
| Preview | The plumbing exists and is reachable, but it isn't wired end-to-end — usually a manual step remains on our side. | Talk to us first so you know exactly which part is manual. |
| Planned | Designed and documented so you can react to the shape, but not built. | No — but telling us you want it is what schedules it. |
Questions worth answering before you start
- Whose domain should rank in search? If yours, you need fragments or the event API — an overlay drawer is invisible to crawlers by design, and our hosted pages earn the credit for us.
- Who handles "I need to change my registration"? If you don't want that queue, the parent portal isn't optional.
- Do you want to be in payments? If not, keep the transaction in the drawer. It's the single largest scope reduction available to you.
- Do you need clearance to be portable? Ours already is. Rebuilding it means re-collecting documents your users have already provided elsewhere.
- How much of this is your differentiator? Go API on that one; let us run the rest.