The Pikel Partner API
Turn a booking-platform event into a running Pikel game, and get the results back. One key per club, two calls to go live, no OAuth to build.
What this does
Court-booking and club-management platforms already run registration and payment for a club's night. This API hands that night's roster to Pikel so it can run the actual play: pairings, scoring, standings, and DUPR submission. Every player you send is matched to a real Pikel account where possible; anyone unmatched is seated as a claimable guest, so a sync never fails because a player is new to Pikel. Once the game is created, Pikel becomes the second half of the same event: same night, two systems, one roster.
Quickstart
-
Mint a key
Email partners@pikel.app with the club and your platform, and we issue the club’s key to its admin the same day. It is shown once. One active key per club and provider, so a multi-location org gets one per Pikel club. Self-service minting from Pikel → Club → Integrations is next on the roadmap; a key issued now keeps working when it lands.
-
List the formats
GET /formatsreturns the live format catalog: token, name, aliases, and whether it takes a fixed or rotating partner. Use it to populate your event-type dropdown; cache it for up to an hour. -
Create the game
POST /gameswith the event and its roster. Pikel seats every player it can and returns a game id, a join code, and a deep link, all in one response. -
Open the deep link
The response's
deep_linkopens the game in Pikel. Show it to whoever runs your "Sync to Pikel" button as confirmation the night now exists on both sides. -
Read the results
GET /games/{id}/resultsanswers at any point in the game's life — standings and per-match scores fill in once the game isactive, andfinalistrueonce it iscompleted. Poll it; it never refuses because a game has not started. A season's board isGET /leagues/{id}/results. Signed webhooks are not enabled in v1 (they answer 501), so polling is the way results come back.
Create a game
A complete, copy-pasteable request. Swap the key for your own and the base URL for the sandbox one below to try it against the preprod demo club.
curl https://api.pikel.app/v1/games \
-H "Authorization: Bearer pk_live_4kd9XXXXXXXXXXXXXXXXXXXXXXXXXXXX" \
-H "Content-Type: application/json" \
-d '{
"external_ref": "108085/1815940",
"external_url": "https://app.playbypoint.com/admin/facilities/93/manage_bookings#/programs/108085",
"title": "Friday Night Mixer",
"description": "Friday Night",
"format": "popcorn",
"start_time": "2026-04-25T20:00:00-05:00",
"end_time": "2026-04-25T22:00:00-05:00",
"timezone": "America/Chicago",
"courts": ["Pickleball 1", "Pickleball 2"],
"max_players": 12,
"dupr": { "enabled": true },
"players": [
{ "name": "Sofia Gonzalez", "external_id": "u_1" },
{ "name": "Carlos Merlos Perez", "dupr_id": "55002709", "external_id": "u_2" },
{ "name": "Isaac Stevens", "dupr_id": "3163225109", "email": "isaac@example.com", "external_id": "u_3" }
]
}'
Sandbox base URL, for a pk_test_… key against the preprod demo club:
https://dtdjxwkukkroosjargbg.supabase.co/functions/v1/partner-api/v1/games
The response is the game plus how the roster landed:
{
"id": "0d8f6d2a-9c3d-4e56-8b31-6d9a7e6f4b10",
"external_ref": "108085/1815940",
"title": "Friday Night Mixer",
"format": "popcorn",
"status": "upcoming",
"start_time": "2026-04-25T20:00:00-05:00",
"courts": ["Pickleball 1", "Pickleball 2"],
"join_code": "MIXR-7F2Q",
"deep_link": "https://pikel.app/g/0d8f6d2a",
"created": true,
"players": [
{ "player_id": "b3f28a9e-...", "external_id": "u_1", "name": "Sofia Gonzalez",
"match": "guest", "status": "accepted", "checkin_status": "not_checked_in",
"claim_url": "https://pikel.app/invite/MIXR-7F2Q" },
{ "player_id": "7c410f2d-...", "external_id": "u_2", "name": "Carlos Merlos Perez",
"match": "dupr", "dupr_id": "55002709", "status": "accepted", "checkin_status": "not_checked_in" }
],
"seating": {
"seated": [ "… one entry per player above …" ],
"skipped": [], "failed": [],
"summary": { "requested": 3, "seated": 3, "skipped": 0, "failed": 0 }
}
}
Call it again with the same external_ref for this club and you get the same game back, 200 instead of 201, created: false, and no new roster rows for players already seated. See idempotency below.
How players get matched
Every player you send is matched in order, first hit wins:
A DUPR id is the strongest key: it is globally unique and both sides already store it for this reason. Email, then phone, are the fallbacks. Anyone who matches none of the three is seated as a name-only guest, never refused, and given a claim_url so the real player can take the seat over later. A sync degrades; it never blocks on a stranger showing up at 8pm.
claim_url is the game's own link, the same one as join_code — it is per game, not per seat. The player opens it, signs in, and picks their own name from the unclaimed seats; whoever holds the link can pick any of them, so treat it as you would treat the game link itself. A DUPR id you send that no Pikel profile holds comes back as supplied_dupr_id rather than dupr_id — on a guest seat, and on a player matched by email or phone whose profile has a different id or none. Pikel records what you sent but only ever stores a DUPR id that arrived with the player’s own DUPR authorization, so claiming the seat does not turn yours into theirs. Seeing match: "email" next to a supplied_dupr_id is worth a look: one of the two systems has the wrong id for that person.
Each seated player comes back with a match field — dupr, email, phone or guest — and the batch carries seating.summary.matched and seating.summary.match_rate. The rate is reported, never enforced: a low one is a signal that the club's two member lists have drifted apart, not a failure. Sending email or phone alongside a name is the single cheapest way to raise it.
Email and phone are used for matching and claim invites only. Neither is ever returned by this API, and neither is logged.
Two rules, not one
The booking platform and Pikel both have a legitimate claim on "who's here tonight," and they mean different things by it. Rather than pick a single winner, the API keeps the two facts apart.
Pikel owns the roster once play starts
Re-syncing is additive: POST /games onto a game that already exists adds players who are not yet seated and never removes anyone. Taking a player off the roster is always the explicit DELETE /games/{id}/players/{player_id} call, never a side effect of a resync.
Once a game is no longer upcoming, a seat that someone added or changed inside Pikel belongs to them. A sync does not overwrite it — those seats come back in seating.refused[] with seat_locally_managed, and the single-seat verbs answer 409 with the same code. You are always told; nothing is silently dropped. This is the rule that lets an organiser edit a live night without fearing your next sync.
You stay authoritative for payment
PUT /games/{id}/players/{id}/checkin writes Pikel's own checkin_status, a convenience for a desk that wants one screen. It is never pushed back as a billing fact, and nothing in this API changes what your platform invoices. What a player owes, and whether they showed for billing purposes, stays yours in every game state.
Idempotency, without a header
There is no Idempotency-Key to generate or store. A game is unique per (provider, external_ref), and external_ref is your own event id, not one Pikel hands you. Resend the same POST /games after a timeout, a retry, or a redeploy, and you get the same game back with created: false, never a duplicate. Retry freely.
Rate limits
60 requests per minute per key. Every response, not only a throttled one, carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset (unix seconds). A 429 also carries Retry-After.
Errors
Every error is { error, message, details?, request_id? }. Code against error; it is stable. message is for your logs, not your logic.
| Code | HTTP | Where | Meaning |
|---|---|---|---|
invalid_key | 401 | any request | The bearer token is not recognized. |
key_revoked | 401 | any request | The key was revoked in Pikel. |
not_authorized | 403 | any request with an id in the path | The key is live and the game is in its club, but the admin who minted it cannot manage that particular game. An id in a different club answers not_found, not this — a key cannot be used to discover which ids exist elsewhere. |
invalid_request | 400 | any write | Body failed validation; details lists the fields. |
not_found | 404 | any request with an id | The id does not exist in this club. |
format_unavailable | 422 | POST /games, POST /leagues | The format token is unknown or currently gated off. |
start_time_past | 422 | POST /games, POST /leagues | There is no night without a start time. |
dupr_club_not_linked | 422 | POST /games | The third and last outright refusal: you asked for dupr.enabled, and this club is not linked to a DUPR club in Pikel, so nothing it plays could reach a rating. Link it in Club settings, or send the event without dupr. |
game_already_started | 409 | PATCH (format only), DELETE /games/{id} | The game is under way: reformat or cancel it from inside Pikel instead. |
game_completed | 409 | every write verb on a game | The game is completed or cancelled, so it can no longer be changed and its roster is frozen. One code for that on every verb, so you branch on it once. |
field_not_editable | 422 | PATCH /games/{id} | details.fields lists what you tried to change that Pikel's own edit door does not accept. |
game_managed_by_series | 409 | PATCH /games/{id} | Pikel refused this row as part of a repeating schedule. You can receive it, but should not expect it: a league night created by POST /leagues normally reschedules here without you doing anything. |
game_managed_by_competition | 409 | PATCH, DELETE /games/{id} | The game is a fixture inside a competition's own schedule (a tournament round, or a game day Pikel generated), so the bracket owns its time and place. Not about the format: a teams or bracket game you created here is editable like any other. |
webhooks_not_enabled | 501 | PUT/DELETE /webhooks, POST /webhooks/test | Not enabled in v1. Poll GET /games/{id}/results instead; it is final once the game is completed. |
week_count_out_of_range | 422 | POST /leagues | week_count must be 2 to 26. |
no_webhook_registered | 400 | POST /webhooks/test | Register a callback with PUT /webhooks first. |
v1 scope
v1 syncs two kinds of Pikel event: single-night games and recurring leagues (POST /leagues creates the season and seats week one, and GET /leagues/{id}/results reads the season board once the club keeps a league table for it). Tournaments are not a sync target yet. GET /formats tells you which of the two, game or league, each format token is for.
Price: free, included in every Pikel plan. No metered usage, no extra tier to unlock.
Beyond the calls on this page, the same key can update or cancel a game in place, add or remove players after the initial sync, and register a signed webhook so results reach you the moment a game finishes instead of only when you poll for them. All of it, with every field and every schema, is in the full reference.