Club Integration API
Build your club's own fan app on the Game Set Engage platform. Your brand and your build — our campaign engine, points ledger, venue network and fan accounts underneath. Your users live in a single-club universe: they belong to your club from the moment they register, and they only ever see your campaigns, your venue offers and their own data.
The Game Set Engage API is a closed platform: it serves our own apps and approved club integrations. Your club's API key is your app's identity — there is no anonymous or general-purpose access.
1. Get access
- Register your club — create your club account and complete onboarding.
- Be on the National or Global plan — API access is included in National and Global. Lower tiers can upgrade at any time; your fans, points and history carry over.
- Generate your keys — in your club dashboard open Club Management → API Access and press Generate API keys. If your club owner hasn't accepted the updated Club Agreement yet, the page asks them to first.
You get two keys:
| Key | Looks like | Where it lives | What it does |
|---|---|---|---|
| Publishable key | gse_pk_… |
Inside your app | Identifies your club and scopes every request to it. Not a secret. |
| Secret key | gse_sk_… |
Your servers only | Authenticates your server-to-server calls (§4): verification emails, fan imports, password setup. Shown once at generation — store it in a secret manager, never in the app. |
You can rotate the secret at any time (the publishable key survives), rotate the publishable key (coordinate with an app release — it breaks shipped builds), or revoke access entirely. Generating keys, and rotating the secret, need your club owner's acceptance of the updated Club Agreement, given on the same page. It covers what your club takes on when it emails its fans and imports them. If your club already has keys, accepting it there also issues a new secret, so your servers need the new one.
A white-label integration needs a server, not just an app. In your app, Game Set Engage doesn't send your fans their verification emails: your club does, from its own systems, along with the password-setup emails for fans you import. Your server gets the tokens for those emails with your secret key (§4).
2. The basics
- Production:
https://api.gamesetengage.com/api/v1 - Staging:
https://dev.gamesetengage.com/api/v1— a separate environment with its own accounts and data, so your production keys don't work there. Contact us if you want to test on it. - JSON in, JSON out. Non-GET requests need
Content-Type: application/json. - Send your publishable key on every request:
X-Club-Key: gse_pk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
An invalid or revoked key fails loudly with 401 INVALID_CLUB_KEY — it never
silently falls back. If your plan drops below National, requests return
403 CLUB_PLAN_REQUIRED until you upgrade again. Your server's calls to
/server/… send the secret key as well (§4).
Every response uses one envelope. Quote meta.request_id when you contact
support.
{ "success": true, "message": "…", "data": { }, "meta": { "timestamp": "…", "request_id": "…", "version": "v1" } }
{ "success": false, "error": { "code": "FORBIDDEN", "message": "…" }, "meta": { } }
3. Register and sign in your users
Accounts created through your app are automatically subscribed to your club — no club pickers, no discovery screens. Under the hood they are platform accounts, so password reset, account deletion and fraud protection come built in (password-reset emails come from Game Set Engage). Verifying the fan's email is your club's job: you send that email (below).
Create a fan (requires explicit terms acceptance):
curl -X POST https://api.gamesetengage.com/api/v1/auth/register \
-H "Content-Type: application/json" \
-H "X-Club-Key: gse_pk_XXXX" \
-d '{
"user": {
"email": "fan@example.com",
"password": "aStrongPassword!",
"first_name": "Alex",
"last_name": "Carter"
},
"terms_accepted": true
}'
We don't send the fan a verification email. In your app, your club sends it, and the response says so:
{
"success": true,
"message": "Registration successful. Please verify your email.",
"data": {
"user": { "unique_id": "usr_9f2ac1…", "email": "fan@example.com", "email_verified": false },
"verification_required": true,
"verification": { "sent_by": "club" }
}
}
To verify the fan:
- Your app tells your server that the fan registered.
- Your server calls
POST /server/verification_tokenswith the fan's email (§4) and gets back{ "token": "…", "expires_at": "…" }. The token lasts 24 hours. - Your server emails the fan a link that carries the token, into your app or to your website.
- When the fan opens it, your app or site calls
POST /auth/verify-emailwithX-Club-Keyand{ "token": "<token from the link>", "device_info": { … } }. That verifies the email and returns the JWT tokens in one step. On a website, make this call from your server: the API sends no CORS headers, so browsers block calls to it from your site's pages. The same goes for setting an imported fan's password withPOST /auth/reset_password(§4).
For a "send it again" button, have your server ask for a new token (it
replaces the old one) and send a new email. POST /auth/resend-verification
sends nothing from your app: it answers 409 CLUB_SENDS_VERIFICATION,
whatever the email. We don't send your fans a verification email from the
Game Set Engage app either: only your club does.
Sign in:
curl -X POST https://api.gamesetengage.com/api/v1/auth/login \
-H "Content-Type: application/json" \
-H "X-Club-Key: gse_pk_XXXX" \
-d '{
"user": { "email": "fan@example.com", "password": "aStrongPassword!" },
"device_info": { "device_id": "3F2504E0-4F89-11D3-9A0C-0305E82C3301", "platform": "ios", "app_version": "1.0.0" }
}'
Send device_info on login and on POST /auth/verify-email. Generate
device_id once, on first launch, and keep it in the Keychain or Keystore so it
survives app updates. The tokens are tied to it: you send it again to refresh,
and it's how we know which phone is the fan's registered device (see below).
If you leave it out we generate a random one that isn't returned to you, so
your app has nothing to send when it refreshes.
{
"success": true,
"message": "Login successful",
"data": {
"user": {
"unique_id": "usr_9f2ac1…",
"email": "fan@example.com",
"first_name": "Alex",
"role": "fan",
"email_verified": true,
"engagement_points": 127,
"points_by_club": [
{ "club_unique_id": "club_hollowmere", "club_name": "Hollowmere Town FC", "points": 127 }
],
"subscribed_club": { "unique_id": "club_hollowmere", "name": "Hollowmere Town FC" },
"legal_updates": []
},
"tokens": {
"access_token": "eyJhbGciOiJIUzI1NiJ9…",
"refresh_token": "9f8c4e2b…",
"expires_in": 900
}
}
}
Access tokens live 15 minutes. Refresh with POST /auth/refresh and
{ "refresh_token": "…", "device_id": "<the same device_id>" }. Send
Authorization: Bearer <access_token> plus X-Club-Key on every call from
here on.
Login cases to handle in your UI:
- A fan who already has a Game Set Engage account is subscribed to your club automatically on first sign-in through your app.
- If that account already follows the platform maximum of 3 clubs, left
your club less than 90 days ago (the message says when it can come back),
or is suspended, login returns
403with a clear message. Show it as-is.
After every successful login, register the device's push token
(POST /devices) so notifications follow the signed-in account.
One registered device per fan
A fan's account is registered to the first phone it signs in on. Taking part
in a campaign, checking in at a venue and claiming a venue offer only work
from that phone. From any other device they return
403 DEVICE_NOT_REGISTERED, with data.changes_remaining,
data.change_quota and data.next_change_available_at.
GET /profile/device tells you whether the current phone is the registered
one (bound_to_this_device) and how many changes are left. Offer a "Use this
phone" button that calls POST /profile/device/rebind. A fan gets 2
changes in any 12 months; once they're used up, rebind returns
409 CHANGE_QUOTA_EXHAUSTED. A phone that is already registered to another
account can't be taken over (422 DEVICE_TAKEN).
Legal updates
Fans who register in your app accept our Terms of Service, Privacy Policy and
Fan Terms when they register (fans you import accept them later, below). When
we change one of them in a way that needs their agreement again, or a
fan has never accepted a document that applies to them, legal_updates lists
what they owe. You get it on the user object from login and GET /auth/me,
on GET /profile, and on the refresh response. It's an empty array when
there's nothing to accept.
{
"key": "terms",
"title": "Terms of Service",
"version": "2026-10-15",
"reason": "updated",
"effective_date": "2026-10-15",
"required_from": "2026-10-15",
"url": "https://api.gamesetengage.com/terms",
"api_url": "https://api.gamesetengage.com/api/v1/legal/terms",
"changes_intro": "…",
"changes": [ { "what": "…", "why": "…" } ]
}
reason is updated (a new version) or not_yet_accepted (the fan never
accepted any version: show the full text; changes_intro is null and
changes is empty). changes_intro and any why can also be null on an
update, and changes can be empty. Show the list of
changes, each with its what and why, next to the full text (the item's
api_url, GET /legal/:key, returns it as HTML in data.html), with an
"I accept" button that calls POST /legal/accept. Send { "keys": ["terms"] }
to accept particular documents, or an empty body to accept everything owed; a
key that doesn't apply to the account returns 422 UNKNOWN_LEGAL_DOCUMENT.
The response lists what was accepted and the updated legal_updates. We
don't refuse any request while a document is owed (except for fans you
imported, below), but show the prompt at every sign-in until the fan accepts.
Terms for imported fans
Fans you import (§4) haven't accepted our Terms of Service, Privacy Policy and
Fan Terms: your club created the account. Until they accept all three, every
signed-in request returns 403 TERMS_ACCEPTANCE_REQUIRED, with
data.legal_updates listing what to accept, except these:
GET /auth/meandDELETE /auth/logoutGET /legal,GET /legal/:keyandPOST /legal/acceptGET /profile, andDELETE /profile, so the fan can always delete their accountPOST /devicesandDELETE /devices/unregisterGETandPUT /profile/notification_preferences, so the fan can turn notifications off before accepting
Sign-in, refresh, password setup and email verification work as usual, and
the login response already carries legal_updates. So when a fan's
legal_updates has not_yet_accepted items, show the acceptance screen
straight after sign-in. Once POST /legal/accept has recorded all of them,
everything opens up; accepting only some keeps the rest closed. Until then,
the fan gets no push notifications you send from your dashboard (they aren't
counted as reachable either), and no emails from us about updates to these
documents. Fans who register in your app accept at registration, so this
never applies to them.
4. Server-to-server
Three calls are for your servers only, because what they return lets someone verify an email or set a password: verification tokens, fan imports and password-setup tokens. Never make them from your app, and never give their tokens to your app directly: a token should reach your app or site only through the link the fan opens from your email. Anyone else holding it could verify an email they don't own, or take over the account.
Authentication
Send both keys, and no Authorization header:
X-Club-Key: gse_pk_XXXXXXXXXXXXXXXXXXXXXXXXXXXX
X-Club-Secret: gse_sk_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
| Refusal | What it means |
|---|---|
401 INVALID_CLUB_KEY |
The publishable key is missing or unknown. |
401 INVALID_CLUB_SECRET |
The secret is missing or wrong (for example, one you've since rotated), or your keys were revoked. The publishable key on its own never gets in. |
403 CLUB_PLAN_REQUIRED |
Your plan is below National. |
403 CLUB_AGREEMENT_REQUIRED |
Your club owner hasn't accepted the updated Club Agreement. They accept it in Dashboard → API Access; if your club already has keys, that also issues a new secret. If the club gets a new owner, they need to accept it too. |
Server calls are limited to 60 a minute per club key.
Verification tokens
curl -X POST https://api.gamesetengage.com/api/v1/server/verification_tokens \
-H "Content-Type: application/json" \
-H "X-Club-Key: gse_pk_XXXX" \
-H "X-Club-Secret: gse_sk_XXXX" \
-d '{ "email": "fan@example.com" }'
{ "success": true, "message": "Verification token issued", "data": { "token": "Zk3…", "expires_at": "2026-09-29T10:00:00Z" } }
This works for fans who signed up in your app, or whom you imported, and
haven't verified yet. Each new token replaces the last one, so older links stop
working, and it lasts 24 hours. Put it in a link to your app or site, which
redeems it with POST /auth/verify-email (§3). Asking for a token also cancels
any email-address change the fan had started.
409 ALREADY_VERIFIED: the fan's email is already verified.404 NOT_FOUNDfor everyone else, whether the email belongs to another club's fan, to someone who signed up in the Game Set Engage app, or to no one. The answer is the same in every case. A fan who signed up in the Game Set Engage app verifies through our email; they can ask for a new one in the Game Set Engage app.
Import fans
Create accounts for fans you already have, up to 100 per request:
curl -X POST https://api.gamesetengage.com/api/v1/server/fans/import \
-H "Content-Type: application/json" \
-H "X-Club-Key: gse_pk_XXXX" \
-H "X-Club-Secret: gse_sk_XXXX" \
-d '{
"fans": [
{ "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace", "verification_needed": false },
{ "email": "alan@example.com", "first_name": "Alan", "last_name": "Turing" }
]
}'
first_name and last_name are required. verification_needed is true if
you leave it out. Set it to false only for an email you have already checked
belongs to that fan: you are confirming that to us, and we record it.
You get one result per fan, in the order you sent them:
{
"success": true,
"message": "Import processed",
"data": {
"results": [
{
"email": "ada@example.com",
"status": "created",
"unique_id": "usr_…",
"email_verified": true,
"password_setup_token": "x7Q…",
"password_setup_token_expires_at": "2026-09-28T16:00:00Z"
},
{
"email": "alan@example.com",
"status": "created",
"unique_id": "usr_…",
"email_verified": false,
"verification_token": "Zk3…",
"verification_token_expires_at": "2026-09-29T10:00:00Z",
"password_setup_token": "Qm2…",
"password_setup_token_expires_at": "2026-09-28T16:00:00Z"
},
{ "email": "grace@example.com", "status": "exists" },
{ "email": "not-an-email", "status": "invalid", "errors": ["Email is invalid"] }
],
"summary": { "created": 2, "exists": 1, "invalid": 1 },
"daily_limit": { "limit": 1000, "remaining_today": 998 }
}
}
created: a new account, already following your club. We send it no email. Nobody knows its password yet: email the fan a link with thepassword_setup_token, and when they choose a password your app or site callsPOST /auth/reset_passwordwith{ "user": { "reset_password_token": "<the token>", "password": "…", "password_confirmation": "…" } }. That token lasts 6 hours. Ifverification_neededwastrueyou also get averification_tokenfor their verification email (24 hours, as above).exists: that email already has a Game Set Engage account, and we leave it exactly as it is: no password, name, verification or club change, and no tokens. The fan signs in to your app with their own password, and that sign-in adds your club; if they already follow three clubs, sign-in is refused until they unfollow one in the Game Set Engage app. You learn only that an account exists for the email; the Club Agreement lets you use that only to invite the fan to sign in.invalid:errorssays why, for example a malformed email, a missing name, averification_neededthat isn'ttrueorfalse, or an email that appears twice in the same request.
An imported fan has to accept our terms the first time they sign in (see Terms for imported fans).
Limits. More than 100 fans in one request returns 400 TOO_MANY_FANS. Your
club can create 1,000 accounts in any 24 hours; a request that would go past
that creates nothing and returns 429 IMPORT_LIMIT_REACHED, with
error.details.remaining_today telling you how many new fans you can still
send. We check every fan first, and only the ones we would create count:
fans that come back exists or invalid never cause the refusal.
Password-setup tokens
If a setup link expires before the fan uses it, ask for a new one:
POST /server/password_setup_tokens
{ "email": "ada@example.com" }
{ "success": true, "message": "Password setup token issued", "data": { "token": "x7Q…", "expires_at": "2026-09-28T16:00:00Z" } }
It works only for fans you imported who haven't set a password yet, and
replaces the previous token. Anyone else gets the same 404 NOT_FOUND.
5. List your campaigns
curl https://api.gamesetengage.com/api/v1/campaigns \
-H "X-Club-Key: gse_pk_XXXX" \
-H "Authorization: Bearer <access_token>"
Returns your club's active campaigns, paginated. Fetch one with
GET /campaigns/:unique_id — the detail includes everything your UI needs:
{
"success": true,
"data": {
"unique_id": "cmp_derby_checkin",
"name": "Derby Day Check-in",
"campaign_type": "event_check_in",
"engagement_points": 50,
"supportive_engagement_points": 10,
"venue_engagement_points": 30,
"start_date": "2026-09-12T16:00:00Z",
"end_date": "2026-09-12T23:00:00Z",
"can_participate": true,
"user_participated": false,
"my_club_points": 127,
"deal_codes_remaining": null,
"prediction_locked": false,
"checkin_location": { "latitude": 51.5549, "longitude": -0.1084, "radius_km": 0.5 },
"my_participation": null
}
}
Fields worth wiring up: can_participate (drive your CTA), my_club_points
(the fan's balance with your club — needed for auctions and point-spend
campaigns), deal_codes_remaining (stock indicator for special_deal_code),
my_participation (result + code after the fan has taken part).
6. Participation cookbook — every campaign type
All nine types hit the same endpoint —
POST /campaigns/:unique_id/participate — only the payload differs. Points
rules, per-fan limits, quotas and time windows are enforced server-side and
transactionally: a failed participation never burns points or codes.
Two things are true for every type:
- Who is participating comes from the
Authorizationheader, never the payload. An "empty"{}payload is only empty of campaign data — the JWT identifies the fan on every call. locationis required only forevent_check_inand for campaigns pinned to a place. A campaign is pinned when its detail carriescheckin_location, and the/campaigns/scanverdict then lists"location"inrequires. Every other campaign, of any type, works without it but still accepts"location": { "latitude", "longitude", "accuracy" }— when you send it, it is stored on the participation record and enriches the club's engagement analytics. Send it whenever the fan has granted location permission.
event_check_in — GPS check-in
location is required. Points are tiered by where the fan is (values are set
per campaign by you, not auto-multiplied): full points inside the venue
radius, reduced "watching from home" points outside it (if you enabled them),
and partner-venue points through the venue QR flow (§7).
POST /campaigns/cmp_derby_checkin/participate
{ "location": { "latitude": 51.5549, "longitude": -0.1084, "accuracy": 8 } }
{
"success": true,
"data": {
"participation": { "points_earned": 50, "total_points": 50 },
"checkin": { "tier": "main", "at_main_location": true, "points": 50, "warning": null }
}
}
A home check-in returns tier: "home", lower points, and a warning string
to surface. If home points are disabled, an outside check-in is rejected
(422 You must be near the event location to check in).
basic — one-tap participation
Empty payload; awards the campaign's points.
POST /campaigns/cmp_season_kickoff/participate
{}
…or, if the fan has granted location permission, send it along (optional — stored on the participation record, enriches your analytics):
POST /campaigns/cmp_season_kickoff/participate
{ "location": { "latitude": 51.5549, "longitude": -0.1084, "accuracy": 8 } }
{ "success": true, "data": { "participation": { "points_earned": 2, "total_points": 2 } } }
deal_code — shared discount code
Empty payload (optional location accepted). Every fan receives the same
code; show it prominently.
POST /campaigns/cmp_friday_pint/participate
{}
{
"success": true,
"data": {
"participation": { "points_earned": 1, "total_points": 1 },
"deal_code": "HOLLOWMERE-FRIDAY-20OFF"
}
}
special_deal_code — unique code from a limited pool
Empty payload (optional location accepted). Each fan draws a different
code; when the pool runs out the call returns 422 All deal codes have been claimed. Use
deal_codes_remaining from campaign detail as a stock badge.
POST /campaigns/cmp_limited_jersey/participate
{}
{
"success": true,
"data": {
"participation": { "points_earned": 0, "total_points": 0 },
"deal_code": "JERSEY-7F3K9Q"
}
}
qr_based — scan anywhere, or at a pinned place
The fan scans your campaign QR; your app calls POST /campaigns/scan with the
QR payload to resolve the campaign, then participates. If the club pinned the
campaign to a place, send location (the scan verdict's requires includes
"location"): a fan outside the radius gets
422 You must be at the campaign location to participate. Without a pinned
place the QR works anywhere and location is optional.
POST /campaigns/cmp_east_stand_qr/participate
{ "location": { "latitude": 51.5549, "longitude": -0.1084, "accuracy": 9 } }
{ "success": true, "data": { "participation": { "points_earned": 3, "total_points": 3 } } }
survey — questions, optional quiz bonus
survey_responses is required (optional location accepted alongside),
keyed by the question index as a string.
Questions you marked with a correct answer pay a bonus per correct reply; pure
opinion surveys just pay the base points.
POST /campaigns/cmp_matchday_quiz/participate
{ "survey_responses": { "0": "Hollowmere", "1": "Reyes" } }
{
"success": true,
"message": "Survey complete! 2 correct, 10 bonus points earned.",
"data": {
"participation": { "points_earned": 5, "quiz_bonus_points": 10, "quiz_correct_count": 2, "total_points": 15 }
}
}
prediction — points only for being right
Same payload shape as survey (optional location accepted alongside),
different economics: taking part earns nothing. The result is pending
until your club resolves the outcome after the event — points arrive with the
resolution. (Know the answers upfront? That is a quiz campaign, below.) Always render from the result object — never
frame a pending or wrong pick as a win.
POST /campaigns/cmp_final_score/participate
{ "survey_responses": { "0": "2-1" } }
{
"success": true,
"data": {
"participation": { "points_earned": 0, "total_points": 0 },
"result": {
"outcome": "pending",
"resolved": false,
"points_awarded": 0,
"title": "Prediction locked in",
"text": "We'll add your points once the result is confirmed."
}
}
}
After resolution, GET /campaigns/:unique_id shows the outcome under
my_participation, and prediction_locked: true closes new entries.
quiz — instant-scored answers
Same payload shape as survey and prediction. Every question ships with its
correct answer (validated at creation), so the result comes back in the
participate response: points_earned is always 0 and the reward is
engagement_points per correct answer, paid instantly — on the fan's
first participation only. Render from the result object; it is never
pending for a quiz.
POST /campaigns/cmp_derby_quiz/participate
{ "survey_responses": { "0": "1965" } }
auction — bid engagement points
bid_amount is required (optional location accepted alongside) and is paid
in club engagement points, never money. A bid must clear the current bid plus the step, and fit the fan's
balance (my_club_points). Outbid fans can always re-bid; the winner pays at
close via automatic settlement.
POST /campaigns/cmp_signed_shirt/participate
{ "bid_amount": 75 }
{ "success": true, "data": { "participation": { "points_earned": 0, "total_points": 0 } } }
Rejections are explicit:
422 Bid must be at least 80 points · 422 Insufficient points. You have 60 of 60 points available for this club.
Poll GET /campaigns/:unique_id/auction for the live state (highest bid,
your position, time left) or subscribe to the WebSocket for realtime updates.
7. Venue network
Your partner venues come with the platform — pubs, bars and restaurants where your fans check in, earn points and claim your venue offers. Every venue flow follows the same mechanic: your app shows a QR, venue staff scan and confirm it in person, your app polls until the decision lands. QRs expire after 10 minutes.
Find venues
GET /fan/nearby_venues?latitude=51.55&longitude=-0.10&radius_km=5 — your
club's partner venues around the fan. Your app lists only venues your club
has approved as partners, and only while they're active. A venue that works
with other clubs doesn't appear until your club approves it too:
{
"success": true,
"data": {
"venues": [
{
"unique_id": "ven_redlion",
"name": "The Red Lion",
"category": "Pub",
"address": "12 High St",
"city": "London",
"rating": 4.6,
"distance_km": 0.42,
"operating_status": "Open",
"coordinates": { "latitude": 51.5521, "longitude": -0.1044 }
}
],
"search_params": { "latitude": 51.55, "longitude": -0.1, "radius_km": 5.0, "total_found": 1 }
}
}
For a check-in campaign, GET /campaigns/:unique_id/venues lists its
affiliated venues with each venue's side deal (e.g. "Buy 1 beer, 2nd 50%
off") and checked_in_today so your UI can mark venues as done.
Venue check-in (QR confirmed by staff)
POST /campaigns/cmp_derby_checkin/venue_checkins
{ "venue_id": "ven_redlion", "latitude": 51.5521, "longitude": -0.1044 }
The fan has to be at the venue. Send latitude and longitude as top-level
fields (not a location object). Further than 100 m from the venue returns
422 You appear to be too far from this venue to check in.
{
"success": true,
"message": "Show this QR to the venue to confirm",
"data": {
"unique_id": "vchk_8f2a…",
"status": "pending",
"qr_payload": "VCHK:Xb7…",
"venue_points": 30,
"side_deal": "Buy 1 beer, 2nd 50% off",
"expires_at": "2026-09-12T21:10:00Z"
}
}
Render qr_payload as a QR code, then poll GET /venue_checkins/:unique_id
until status is approved (points + side deal to show staff) or rejected
/ expired. Re-calling the create endpoint while a QR is still live resumes
the same QR (and re-notifies the venue) instead of duplicating it. A fan
can check in at each venue once a day; a second try the same day returns
422 You've already checked in at this venue today. Venue check-in points are
paid once a day, whichever venue: a check-in confirmed at a second venue the
same day still gets that venue's side deal, but points_awarded is 0. A
venue's day runs from 06:00 to 06:00 in its local time, so a late match night
counts as one day.
A venue can drop out for a while: when it has an overdue invoice, or when
Game Set Engage suspends it. While it's out it disappears from venue lists and
offers, and new check-ins and offer claims there return
422 This venue is not currently active. It reappears on its own once the
invoice is settled or the suspension is lifted, and your app doesn't need to
do anything.
Venue offers (standalone promos)
GET /venue_offers?latitude=…&longitude=… — your club's active offers nearby,
each with discount, fan_points, runs_today and the venue block. Claiming
mirrors the check-in mechanic:
POST /venue_offers/vof_9ad21c/claim
{ "latitude": 51.5521, "longitude": -0.1044 }
{
"success": true,
"message": "Show this QR to the venue to confirm",
"data": {
"unique_id": "vofc_8f2a…",
"status": "pending",
"qr_payload": "VOFR:Xb7…",
"discount": "50% off mains",
"fan_points": 25,
"expires_at": "2026-09-10T19:40:00Z"
}
}
Poll GET /venue_offer_claims/:unique_id until approved — the response then
carries points_awarded and the discount to show at the till. As with
check-ins, the fan must be within 100 m of the venue and send latitude and
longitude. Most offers can be claimed once a day
(422 You've already claimed this offer today); a venue can let fans claim
on every visit, but points are paid once a day either way. Claiming outside
the offer's weekdays returns 422 This offer isn't available today.
8. Profile & points
The profile object
GET /profile:
{
"success": true,
"data": {
"unique_id": "usr_9f2ac1…",
"email": "fan@example.com",
"first_name": "Alex",
"last_name": "Carter",
"avatar_url": "https://…",
"engagement_points": 127,
"points_by_club": [
{ "club_unique_id": "club_hollowmere", "club_name": "Hollowmere Town FC", "points": 127 }
],
"subscribed_clubs": [
{ "unique_id": "club_hollowmere", "name": "Hollowmere Town FC", "subscribed_at": "2026-08-01T10:00:00Z" }
]
}
}
PUT /profile updates first_name / last_name. Avatar upload is the one
multipart endpoint: POST /profile/avatar with an avatar file field.
DELETE /profile (password-confirmed) anonymizes the account permanently —
wire it to your "delete account" screen; app-store rules require it.
Points & history
GET /profile/engagement_points — balance, last action and the fan's five
most recent participations. GET /profile/campaign_history — the full
paginated log; each row is self-contained:
{
"id": 4211,
"campaign": { "unique_id": "cmp_friday_pint", "name": "Friday Pint Deal", "campaign_type": "deal_code", "club_name": "Hollowmere Town FC" },
"points_earned": 1,
"bonus_points_earned": 0,
"total_points": 1,
"status": "success",
"participated_at": "2026-08-07T18:12:00Z",
"deal_code": "HOLLOWMERE-FRIDAY-20OFF",
"location": { "latitude": 51.5549, "longitude": -0.1084, "accuracy": 8.0 }
}
deal_code re-surfaces the fan's earned codes (shared or unique) so your
"my rewards" screen never loses them; location echoes what you sent at
participation (null if you didn't).
Notifications & devices
GET /profile/notification_preferences returns exactly four booleans —
all_campaigns, matchday_reminders, weekly_digest, partner_deals;
PUT accepts a partial object of the same keys (anything else is rejected).
Register the push token after every successful login:
POST /devices
{
"device_token": "a1b2c3…",
"platform": "ios",
"device_id": "3F2504E0-…",
"app_version": "1.0.0",
"apns_environment": "production"
}
Registration is idempotent and follows the signed-in account — on an account
switch the handset's pushes switch with it. Call
DELETE /devices/unregister on logout.
9. Errors your app should handle
Every error uses the same envelope; error.message is written to be shown to
the fan as-is, so most error UI is one generic sheet:
{
"success": false,
"error": { "code": "UNPROCESSABLE_CONTENT", "message": "You have reached the participation limit for this campaign" },
"meta": { "timestamp": "2026-09-12T18:00:00Z", "request_id": "7223a11b-…", "version": "v1" }
}
| Code | Status | What to do |
|---|---|---|
INVALID_CLUB_KEY |
401 | Your X-Club-Key is wrong or revoked. Config error — fail the build loudly, check the dashboard. |
CLUB_PLAN_REQUIRED |
403 | Plan dropped below National — API access paused until upgrade. |
TERMS_ACCEPTANCE_REQUIRED |
403 | A fan you imported hasn't accepted our terms yet. Show the acceptance screen from data.legal_updates, call POST /legal/accept, then retry (§3). |
AUTH_REQUIRED · TOKEN_EXPIRED · INVALID_TOKEN |
401 | Silently POST /auth/refresh; only on refresh failure send the fan to login. |
EMAIL_NOT_VERIFIED |
403 | Points-earning actions need a verified email — reopen your verification screen and have your server send a new link (§4). |
ACCOUNT_SUSPENDED |
403 | Fraud-blocked account — show the message with your support link. |
DEVICE_NOT_REGISTERED |
403 | Not the fan's registered phone. Offer "Use this phone" (POST /profile/device/rebind) and show data.changes_remaining. |
CHANGE_QUOTA_EXHAUSTED |
409 | On rebind: both device changes for the last 12 months are used. data.next_change_available_at says when the next one frees up. |
DEVICE_TAKEN |
422 | On rebind: this phone is already registered to another account. Show the message as-is. |
FORBIDDEN (on login) |
403 | The 3-club cap, a fan who left your club less than 90 days ago, or a suspended account. Show the server's message as-is. |
NOT_FOUND |
404 | Doesn't exist — or belongs outside your club's universe. Treat both the same. |
UNPROCESSABLE_CONTENT |
422 | Business rule: already participated, pool empty, bid too low, off-schedule offer, outside the geofence… Show message. |
VALIDATION_ERROR |
422 | Invalid input — error.details is an array of field errors. |
BAD_REQUEST · INVALID_CONTENT_TYPE |
400 | Malformed request (missing param, unreadable JSON), or a POST, PUT or PATCH without Content-Type: application/json. |
CLUB_SENDS_VERIFICATION |
409 | POST /auth/resend-verification from your app. Your server sends verification emails (§4). Not a message for the fan. |
Your server's calls (§4) have their own codes. These are for your logs and alerts, not for fans:
| Code | Status | What to do |
|---|---|---|
INVALID_CLUB_SECRET |
401 | The secret is missing or wrong, or your keys were revoked. Use the current secret from your secret manager. |
CLUB_AGREEMENT_REQUIRED |
403 | Your club owner needs to accept the updated Club Agreement in Dashboard → API Access. With existing keys, that issues a new secret: update your servers. |
ALREADY_VERIFIED |
409 | The fan's email is already verified; no email needed. |
NOT_FOUND |
404 | Verification tokens: not a fan who signed up in your app or whom you imported. Setup tokens: not a fan you imported who still has to set a password. |
TOO_MANY_FANS |
400 | More than 100 fans in one import. Split the batch. |
IMPORT_LIMIT_REACHED |
429 | The import would pass 1,000 new accounts in 24 hours. Nothing was created. Send at most error.details.remaining_today new fans, or wait. |
Concrete 422 messages you will meet in the wild — all display-ready:
You have reached the participation limit for this campaign
All deal codes have been claimed
You must be near the event location to check in
You must be at the campaign location to participate
Bid must be at least 80 points
Insufficient points. You have 60 of 60 points available for this club.
Survey responses are required
Location is required to check in at a venue
You appear to be too far from this venue to check in
You've already checked in at this venue today
This venue is not currently active
This offer isn't available today
Location is required to claim an offer at a venue
You appear to be too far from this venue to claim this offer
You've already claimed this offer today
Rate limits: 100 requests/min per IP, 300 per token; login, register and
password-reset endpoints are throttled harder, and server calls (§4) are
limited to 60 a minute per club key. A 429 from these limits comes from the
edge with a plain body (error, message, retry_after seconds) — back off
and retry after the given delay. 429 IMPORT_LIMIT_REACHED is different: it
uses the usual error envelope, and retrying the same batch straight away won't
help.
10. Launch checklist
Setup
- Generate keys in Dashboard → API Access (your club owner accepts the
updated Club Agreement there); embed
gse_pk_…in the app, vaultgse_sk_…on your servers. - Send
X-Club-Keyon every request — including register and login. Your server sendsX-Club-Secrettoo, and nothing else ever does. - If you test on staging (
dev.gamesetengage.com), use the staging key we give you there; your production key only works on production.
Auth flows to test end-to-end
- Register → your server gets a verification token and emails the link →
the link opens your app or site, which calls
POST /auth/verify-email→ login → refresh loop (tokens live 15 minutes — refresh proactively, not on failure only). Send the same storeddevice_idon verify, login and every refresh. Test the "send it again" path and an expired link. - Login with an existing Game Set Engage account (auto-subscribe path) and
with an account at the 3-club cap (expect the
403, show it kindly). - Register the push token after every successful login; unregister on logout. Test an account switch on one device — pushes must follow.
- Sign the same fan in on a second phone: expect
DEVICE_NOT_REGISTEREDon participation, then move the account with "Use this phone". - Build the
legal_updatesscreen and wire it toPOST /legal/accept. A new account owes nothing, so test the screen with a stubbed response, or with an imported fan, who owes all three documents.
Imports (if you bring existing fans)
- Import a small batch on staging: one fan with
verification_needed: false, one without, and one email that already has an account (expectexists). Email the setup link, set a password throughPOST /auth/reset_password, sign in, and expectTERMS_ACCEPTANCE_REQUIREDuntil the fan accepts. - Handle
IMPORT_LIMIT_REACHEDby sending smaller batches later, and alert onINVALID_CLUB_SECRETandCLUB_AGREEMENT_REQUIRED.
Campaign flows to test per type you'll run
- One participation per type from §6, plus the repeat attempt (expect the
friendly
422), the empty deal-code pool, and — for geofenced types — a check-in from outside the radius. - Send optional
locationwherever the fan has granted permission — it costs nothing and feeds your analytics.
Venue flows (if you use the venue network)
- Open a venue check-in QR, let it expire (10 min), reopen; then a real
staff-confirmed approve and the poll loop to
approved.
Before the store release
- Wire
DELETE /profile(account deletion) into settings — app-store rules require it. - Make sure error sheets show
error.messageverbatim and quotemeta.request_idin your support links. - Switch the base URL and keys to production, do one full smoke pass, ship.
Questions, or a capability you're missing? Contact us — we answer fast.