Posting API
v1 · rendered now — the example fixture below is live
What this is
One endpoint, POST https://betclub.gg/api/v1/posts, that
writes a bet to your own public record exactly as posting in the app does. The post is stamped
on the server, is graded against the result, and can never be edited or deleted — by you or by
Betclub. A post is refused once the fixture has kicked
off. Betclub hosts this record; it does not give tips.
1. Get a key
Keys are issued to a signed-in Betclub account, from inside
the app.
Each key posts as that account and no other — a key cannot name a different author. The secret
(bcl_ followed by 38 characters) is shown once; Betclub stores only
its hash and the first ten characters as a label. An account may hold up to five live keys, and
a key can be revoked at any time; posts already made stay on the record, because the record is the
product. Hosted-record customers: your pilot contact walks through this with you.
2. Post a bet
Send JSON with a bearer token. Every field is required except book.
curl -X POST https://betclub.gg/api/v1/posts \
-H "Authorization: Bearer bcl_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: my-tool-2026-slip-0417" \
-d '{
"fixture_id": "fc-energie-cottbus-1-fc-n-rnberg-2026-10-16",
"selections": [
{ "event": "fc-energie-cottbus-1-fc-n-rnberg-2026-10-16", "market": "MATCH_RESULT", "pick": { "side": "home" } }
],
"quoted_price": 1.85,
"stake": 10,
"book": "bet365",
"sealed": false
}' - fixture_id
- The fixture the post is headlined by, copied from the fixtures feed (below). It
must be the
eventof at least one leg, and it must be a fixture Betclub already holds a kick-off time for — an unknown fixture is refused, because a post that cannot be locked cannot be recorded. - selections
- One to twenty legs. Each leg is
{ event, market, pick }:eventis the fixture id the leg is graded against (an accumulator names a different fixture per leg);marketis one of the codes in the table below, andpickis the object that table gives for it — exactly those keys, nothing free-text. Betclub grades every one of these markets from the final or half-time score; a leg in any other shape is refused with a 400 naming the leg, because a leg the grader cannot read would sit pending on a record that cannot be edited. The lock runs against the earliest leg: once any leg's match has started, the whole post is too late. - quoted_price
- The decimal price you were offered, greater than 1, rounded to 3 decimal places before it is checked, recorded as quoted and shown on the record as quoted.
- stake
- Greater than 0 after rounding to 2 decimal places, at most 100,000. Stakes are part of the public record — a P&L means nothing without them — so post at a size you are comfortable being seen.
- book
- Optional bookmaker slug (
bet365,pinnacle). It selects the void rule the post is graded under; omitted, the default rulebook applies. - sealed
- Optional, a JSON boolean, default
false. Withtruethe post is public from kick-off, sealed until then, committed from the moment of posting: the public ledger, your record page, its badge, embed and JSON show the row from the moment it lands — its id, when it was posted, its kick-off, the bookmaker, and that it is locked — but publish the match, the selections, the price and the stake only once the last leg has kicked off (reveal_aton the row; for a single, its kick-off. The lock is against the earliest leg,kickoff, so an accumulator's later legs are not published while their matches are still to come). In their place, from the first second, the row carries acommitment:sha256( commit_salt | fixture_id | match | selections | quoted_price | stake )as hex, over a 16-byte salt the server draws, computed by the server after it has stamped the row. The salt is published with the pick at kick-off, so anyone can recompute the hash and hold the revealed pick to the one that was public all along; the salt is what keeps the hash from being brute-forced from the fixture list before then. Sealing changes when the pick can be read and nothing else: the post is refused after kick-off like any other, graded like any other, and can never be edited or deleted. You cannot supply the salt or the commitment — there is no field for them, and the database grants none. A string such as"yes"is refused with a 400 namingsealed. - Idempotency-Key (header) · client_ref
- Optional, and the reason to send it is the 502 row below. A tag of your own, 1 to 64
characters of letters, digits,
.,_,:or-, as anIdempotency-Keyheader or aclient_refbody field (the same value; the header wins, and if both are sent they must agree). The ledger keeps one row per account per tag: a repeat with the same tag writes nothing and answers200with the row that already exists andreplayed: true, whatever the body of the repeat says. So a client that times out can retry until it gets an answer, and the record still holds one row. The tag is published on the ledger row, likebook— it is a label, not a secret, so do not put one in it. Without a tag a retry after a 502 can be a second permanent post.
Markets and picks
The closed list. line is a whole or half number (2.5, -1, 0.5) — quarter
lines are refused, because a quarter line whose two halves land differently has no single result to
grade. Keys not in the shape are dropped; a value outside it is a 400.
| market | pick | example |
|---|---|---|
| MATCH_RESULTMatch result | { "side": "home" | "draw" | "away" } | {"side":"home"} |
| DOUBLE_CHANCEDouble chance | { "side": "1X" | "12" | "X2" } | {"side":"1X"} |
| DRAW_NO_BETDraw no bet | { "side": "home" | "away" } | {"side":"away"} |
| BTTSBoth teams to score | { "side": "yes" | "no" } | {"side":"yes"} |
| TOTAL_GOALSTotal goals | { "side": "over" | "under", "line": <whole or half number, e.g. 2.5> } | {"side":"over","line":2.5} |
| TEAM_TOTAL_GOALSTeam total goals | { "subject": "home" | "away", "side": "over" | "under", "line": <whole or half number, e.g. 2.5> } | {"subject":"home","side":"over","line":1.5} |
| CORRECT_SCORECorrect score | { "extra": { "hs": <goals>, "as": <goals> } } | {"extra":{"hs":2,"as":1}} |
| HANDICAPHandicap | { "subject": "home" | "away", "line": <whole or half number, e.g. 2.5> } | {"subject":"home","line":-1} |
| ODD_EVEN_GOALSOdd or even goals | { "side": "odd" | "even" } | {"side":"odd"} |
| HT_RESULTHalf-time result | { "side": "home" | "draw" | "away" } | {"side":"draw"} |
| HT_TOTAL_GOALSHalf-time total goals | { "side": "over" | "under", "line": <whole or half number, e.g. 2.5> } | {"side":"under","line":1.5} |
| HT_FTHalf-time/full-time | { "extra": { "ht": "home" | "draw" | "away", "ft": "home" | "draw" | "away" } } | {"extra":{"ht":"home","ft":"home"}} |
Fixture ids
A fixture id is the slug of the feed's own spelling of both team names and the date
(fc-energie-cottbus-1-fc-n-rnberg-2026-10-16),
so it is copied from the feed, never composed. The example above is the first fixture in the feed more
than a day away as this page was rendered; it is a real id and a real kick-off, not a placeholder.
The feed is a plain read of the fixtures Betclub holds, upcoming first — no account, no key of yours;
the key in the URL is the public one every browser that opens the app already carries:
GET https://auth.betclub.gg/rest/v1/fixtures?select=fixture_id,home,away,kickoff&kickoff=gt.<now>&order=kickoff.asc&apikey=sb_publishable_slKOMU-AsbCgGicsufFw4g__MBOvC-w
Replace <now> with the current UTC time
(right now: 2026-10-13T23:32:28Z). It answers JSON rows like
[{"fixture_id":"fc-energie-cottbus-1-fc-n-rnberg-2026-10-16","home":"FC Energie Cottbus","away":"1. FC Nürnberg","kickoff":"2026-10-16T16:30:00+00:00"}, …]
Use fixture_id verbatim as the post's fixture_id
and each leg's event. Betclub holds fixtures for the leagues its odds feed
carries, at least four days ahead; a match that is not in the feed cannot be posted through the API.
3. The response
HTTP/1.1 201 Created
{
"ok": true,
"id": "6f1c…",
"posted_at": "2026-10-13T23:32:28.801+00:00",
"kickoff": "2026-10-16T16:30:00+00:00",
"match": "FC Energie Cottbus v 1. FC Nürnberg",
"reveal_at": "2026-10-16T16:30:00+00:00",
"sealed": false,
"commitment": null,
"client_ref": "my-tool-2026-slip-0417",
"ledger_url": "https://auth.betclub.gg/rest/v1/ledger?select=…&id=eq.6f1c…"
}
A 201 is a row written. Send the same tagged request again and the answer is a
200 — the row from the first time, unchanged, with
replayed: true; nothing was written the second time:
HTTP/1.1 200 OK
{
"ok": true,
"id": "6f1c…",
"replayed": true,
"client_ref": "my-tool-2026-slip-0417",
… the same fields as the 201 above, for the row that already exists
} posted_at is the server's clock at the moment the row was written,
kickoff is the earliest leg's, and ledger_url
opens the public ledger row itself — readable by anyone, no account needed. Your record page
lists it at https://betclub.gg/r/<your handle>.
For a sealed post, sealed is true and
commitment is the 64-hex hash the row will be held to — keep it.
reveal_at, when the reply carries it, is the last leg's kick-off — the
instant the public row publishes the pick (a single's equals kickoff).
The ledger writes the commitment; this route reads it back from the public row in a second request. If
that read fails, the post has still landed and the reply is still a 201, with
commitment: null and a note saying so —
read the commitment from ledger_url, where it is published from the
moment of posting. Until reveal_at the public row reads:
{
"id": "6f1c…", "user_id": "d66b…",
"match": null, "selections": null, "quoted_price": null, "stake": null,
"book": "bet365", "client_ref": null, "posted_at": "2026-10-13T23:32:28.801+00:00", "kickoff": "2026-10-16T16:30:00+00:00",
"reveal_at": "2026-10-16T16:30:00+00:00",
"sealed": true,
"commitment": "9b3e…(64 hex)",
"commit_salt": null
}
and from reveal_at the same row carries the match, selections, price, stake and
commit_salt. To check it: quoted_price and
stake are hashed at the column's scale (1.850,
10.00), selections as the database's own JSON
text (keys sorted shortest first, then bytewise; ", " and
": " as separators), the six parts joined with |.
Your own record page shows the sealed row as the public sees it; the app shows you your own pick.
4. Errors
Every reply is JSON with ok and, when false, error.
| status | error | why |
|---|---|---|
| 400 | …, field | The body failed a check. `field` names the exact key (`selections[2].event`, `selections[0].pick`, `stake`, `sealed`, `content-type`); `error` says the rule — for a market or pick, the exact shape expected. Nothing reached the ledger. |
| 400 | refused: unknown fixture | A leg's `event` (or the `fixture_id`) is not a fixture Betclub holds a kick-off time for. `detail` carries the fixtures feed URL — copy the id from there. |
| 401 | missing or malformed api key | No `Authorization: Bearer bcl_…` header, or one that is not `bcl_` followed by 38 characters. Nothing reached the ledger. |
| 401 | invalid api key | The key is well-formed but is not a live key: never issued, or revoked. |
| 409 | refused: kicked off | The earliest leg's fixture has already started; `detail` carries the kick-off time and the time the post arrived. This is the lock, and it has no override. |
| 413 | body too large | Over 16 KB, by the declared `Content-Length` or by the bytes read. A twenty-leg post is well under 4 KB. |
| 429 | rate limit: 60 posts per hour | This key's account has posted 60 times in the last rolling hour. The window rolls, so the next post is accepted once the oldest of the sixty is an hour old — anything up to an hour away. `Retry-After: 3600`; retrying sooner is refused again. |
| 429 | rate limited: 120 requests per minute per address | One IP address sent more than 120 requests in a minute that were about to reach the ledger. A request refused before that — for its key shape, content type, size or body — is not counted. `Retry-After: 60`. |
| 405 | method not allowed: POST only | Anything but POST — GET, PUT, PATCH and DELETE alike, each answered here in JSON. |
| 400 | …, field: client_ref | The `Idempotency-Key` header or the `client_ref` body field is not 1-64 characters of letters, digits, `.`, `_`, `:` or `-` — or both were sent and differ. Nothing reached the ledger. |
| 502 | upstream | Betclub could not reach or read the ledger, or the ledger did not answer inside 8 seconds. The post may or may not have been recorded: the ledger can commit a row after this route stops waiting, and a row can never be taken back. Read your ledger (`/api/record/<handle>.json`) before retrying — or send an `Idempotency-Key`, and retry freely: a repeat answers 200 with the row that already exists. `detail` says what answered. |
5. Limits
- 60 posts per account per rolling hour, counted across all of that account's keys, enforced in the database. A refused post waits up to an hour — until the oldest of the sixty ages out.
- 120 requests per minute per IP address, enforced at the edge and counting only requests whose key shape and body passed and were about to reach the ledger. A request refused for its key shape, content type, size or body costs nothing and is not counted.
- Twenty legs per post; 16 KB per request body, judged on the declared
Content-Lengthfirst and the bytes received second. - One row per account per
Idempotency-Key, forever: a tag is never freed, because the row it names is never deleted. Use a new tag for a new post. - No update, no delete, no back-dating: there is no endpoint for any of them, for anyone.
What the record is, and isn't
A post made through this API is on the record from the moment it lands — the ledger row, your record page, its badge and embed all show it, losers included. An unsealed post is public at every age; a sealed post is public from kick-off, sealed until then, committed from the moment of posting. Betclub locks it before kick-off and grades it against an independent results feed, and that is the whole of the service: the record is yours, what you say about it is your statement, and a record of past picks is history, not a forecast. See the terms and the responsible-gambling page.
Questions
Email hello@betclub.gg. Betclub is operated by The Quantum Club B.V., Amsterdam, Netherlands.