API and webhooks
Free, on every plan, and able to write — not a read-only feed. Your website can take registrations, your till can seat a walk-in, and your Discord can announce round two the moment it starts.
The API: read your events, standings and results from software of your own. Discord, webhooks and the API
Getting a key
A store owner creates one in their store settings. It looks like opk_… and is shown once — we store only a hash of it, so we cannot show it to you again and neither can anybody who steals our database.
curl https://www.organizedplay.gg/api/v1/events \ -H "Authorization: Bearer opk_your_key_here"
Bearer header only. We do not accept a key in the query string, because that puts it in every access log, proxy cache and browser history it passes through.
Use www.organizedplay.gg. The bare domain redirects there, and clients drop the Authorization header when they follow a redirect — so a key that is perfectly good will come back 401.
read- Look at your calendar and your rosters.
write- Everything read can do, plus seating players. You do not need both keys.
A key belongs to one store and can never reach another’s calendar.
Endpoints
| Endpoint | Scope | What it does |
|---|---|---|
GET /api/v1/events | read | Your events, with real seat counts and the public URL of each. |
POST /api/v1/events | write | Put a tournament on the calendar. Public by default; a paid plan may ask for anything else. |
GET /api/v1/events/{id}/registrations | read | Who is on the roster and what state they are in. |
POST /api/v1/events/{id}/registrations | write | Seat a player. Waitlists them if the room is full. |
POST /api/v1/events/{id}/competition | write | Start the tournament against whoever is checked in. A second call is safe. |
POST /api/v1/events/{id}/registrations/{rid}/check-in | write | Mark a registered player as physically present. |
DELETE /api/v1/events/{id}/registrations/{rid} | write | Withdraw. Frees the seat and promotes the earliest waitlisted player. |
GET /api/v1/events/{id}/rounds | read | Every round and who sat where. Add ?round=latest for the one being played. |
GET /api/v1/events/{id}/standings | read | The table, derived on the call. Split by division where the event has them. |
GET /api/v1/players/{id} | read | One player’s public history across every event and organization — a public record, no key required. |
curl -X POST https://www.organizedplay.gg/api/v1/events/EVENT_ID/registrations \
-H "Authorization: Bearer opk_your_key_here" \
-H "content-type: application/json" \
-d '{"displayName": "Ada Lovelace", "email": "ada@example.com"}'Posting the same player twice returns 409 rather than seating them again, so a retried request is safe. The path is versioned: /api/v1 will not change under code you have already shipped.
Webhook events
Register an HTTPS endpoint in your store settings and we POST to it when these happen. Subscribe to a list, or to none and get all of them — including ones we add later.
event.created- A night was put on the calendar, published or not.
registration.created- Somebody took a seat — online, at the counter, or over the API.
participant.checked_in- A player who registered has arrived.
competition.started- The first round of the first stage began.
round.started- A round began. Pairings are readable from this moment.
match.completed- A result was reported for the first time.
result.updated- A recorded result was corrected. Anything you published from it has changed.
round.completed- A round closed. Every match in it is final.
competition.completed- The event is over and the standings are final.
standings.updated- The table moved. Sent when a round closes, not on every result.
judge_call.opened- A table asked for a judge. Carries the table and the reason given.
result.disputed- Two reports for one table disagree. A judge decides; nothing is final yet.
These fire from the tournament record itself, so a round paired on a tablet with no signal still announces itself the moment that tablet reconnects.
Verifying a delivery
Every POST carries an op-signature header. Check it before you trust the body — anybody can POST to your URL, and the signature is what tells our delivery from theirs.
op-signature: t=1756500000,v1=<hex>
op-event: round.started
op-delivery: <unique id, safe to deduplicate on>
// v1 = HMAC-SHA256("<t>.<raw body>", "whsec_your_signing_secret")Reject anything whose t is more than 5 minutes from now: the timestamp is inside the signature, so this is what stops a captured delivery being replayed at you forever. Compare the hex in constant time.
When your endpoint is down
- Any
2xxis success. We retry everything else up to 8 times, waiting 1, 2, 4, 8, 16 then up to 60 minutes between attempts. - Answer
410 Goneand we stop immediately and switch the endpoint off. It is the polite way to retire a URL. - An endpoint that fails for a long time is disabled, and your store settings show the recent attempts and their status codes so you can see why.
- Answer fast and do your work afterwards. We give up on a request after ten seconds and will simply try again.
For agents
An MCP server at POST /api/mcp — JSON-RPC 2.0, with initialize, tools/list and tools/call. Send the same API key as the Authorization header. A key is offered only the tools it may actually call, so a read key never sees the one that seats a player.
list_events(read) — Every event this store has, upcoming and finished, with seat counts. Start here: every other tool needs an event id and this is where they come from.get_standings(read) — The current table for one event, ranked, with each player’s record and the tiebreakers this ruleset applies. Empty before the first round is paired — that means the event has not started, not that something failed. Where the event runs age or other divisions, each is ranked separately and rows carry a division name.get_rounds(read) — Every round and who sat at which table, with results where they have been reported. Pass round="latest" for the one being played. A table with no winner is either still playing or was drawn — check `reported` to tell those apart rather than assuming.get_roster(read) — Who is registered for an event and what state each of them is in — registered, checked in, waitlisted or dropped. Registration is intent; checking in is being in the room, and only checked-in players are paired.register_player(write) — Seat a player at an event by name. Waitlists them if the room is full. This writes to a real store’s roster and somebody will be expecting a seat — confirm the event and the spelling with the person you are acting for before calling it.create_event(write) — Put a real tournament on the store’s own calendar — players will see it and show up. Public by default; a free-plan store can only ask for anything else once it is on a paid plan. Confirm the date and the game with the person you are acting for before calling it. Beyond the store’s weekly allowance the event is still created, but as a draft that needs payment before it is announced — check the response for that rather than assuming the call failed.start_competition(write) — Start the real tournament against whoever is checked in — confirm the room is actually ready before calling it, since round one pairs immediately. Registering is intent; only a checked-in player gets seated. Calling this again once it has started is safe and simply returns the competition already running.check_in_player(write) — Mark a real, registered player as physically present — confirm they are actually in the room before calling it. Only a checked-in player is paired; somebody who registered online but never walked in should stay off the roster the engine seats.
initialize returns instructions an agent reads before it calls anything — chiefly that it should not compute placings or tiebreakers itself. The engine applies the ruleset the store actually chose, and arithmetic done from raw results will disagree with it. /llms.txt says the same thing for something that arrived by reading rather than by browsing.
Coming from TopDeck
A translation of the API above into the envelope TopDeck publishes, so a bot you already have changes a base URL rather than getting rewritten. Point it at /api/compat/topdeck/v2/…, send your key as Authorization: {key} the way theirs is sent, and {TID} becomes our event id.
GET /v2/tournaments/{TID},/info,/standings,/rounds,/rounds/latest, andGET /v2/me/tournaments.- Fields we hold nothing for are left out rather than sent as null, so your client breaks where the data is missing instead of somewhere later:
leader,decklist,deckObj,discord,discordId,headerImage,teamTags. Add?strict=1and every documented key is present, null where we have nothing. - A table’s
statusis ours. Their documentation names the field and not its vocabulary, and inventing an enum to match a spelling we have never seen would be a guess your client would depend on.
This exists to get you off TopDeck, not to keep you in their shape. Everything it returns is a translation of /api/v1, which is where the work goes.
House rules
- Your endpoint must be HTTPS, and must not be an address inside a private network.
- We do not follow redirects. Give us the final URL.
- Any project using this API should show a visible credit and a link back to OrganizedPlay. We ask rather than enforce: nothing checks for it, and no key is revoked over it. Every response carries it in
x-attribution-textandx-attribution-url, so you can render it from what you already have:<p>Tournament data from <a href="https://www.organizedplay.gg" target="_blank" rel="noopener">OrganizedPlay</a></p> - 100 API calls a minute per key on a paid plan — the same ceiling TopDeck publishes, on purpose, so an integration ported from theirs does not discover ours by hitting it. 20 a minute on the free plan: a free organization is capped at one event a week regardless, so this is headroom, not a squeeze. Over either you get a
429with aretry-after; the limit is per key, so one runaway script does not lock out your other integrations.
Every endpoint above is on the free plan too — a plan buys more volume, not more of the API.