Delivery · Webhooks
We call you when the score moves
Webhooks turn the stream around: rather than holding a connection open, you give us an HTTPS address and we send each event to it as a signed POST. Good for serverless functions, notification pipelines and anything that would rather react than listen.
- Free: not included
- Starter: not included
- Pro: not included
- Business: included
How delivery works
Set it up once in the dashboard; after that every matching event becomes one request to each endpoint that wants it.
- 01
Register
In the dashboard's Webhooks tab, add the URL that should receive events. It must be https:// on a public host; plain HTTP and private addresses are refused.
- 02
Keep the secret
Each endpoint gets its own signing secret, shown once when you create it or rotate it. Store it next to your API key.
- 03
Choose what to hear
Pick event types and narrow by sport, league or fixture. Leave a filter empty and it lets everything through.
Pick webhooks when
- you run on functions or short-lived workers that cannot hold a socket;
- you care about scores, status, events and lineups rather than every field;
- you want our retries to cover your downtime.
Pick the WebSocket when
- you need every change to a match, odds or a race, not just four event types;
- latency matters more than simplicity;
- you want numbered frames with replay. See the WebSocket guide.
Event types
Two types follow every live match your filters allow. Two are detailed enough that you name the fixtures you want them for.
| Type | Sent when | For which fixtures |
|---|---|---|
fixture.score | The current score of either side changes. | Every live fixture that passes your filters. |
fixture.status | The match kicks off, enters a new period or a break, reaches full time, or is postponed or cancelled. | Every live fixture that passes your filters. |
fixture.events | Match events are added, corrected or removed: goals with their scorers, cards, substitutions. | Only fixtures you list in the endpoint's fixture ids. |
fixture.lineups | The official lineups appear and whenever they change in play. We start looking about 90 minutes before kick-off; most teams publish around an hour before. | Only fixtures you list in the endpoint's fixture ids. |
ping | You send a test delivery from the dashboard. | The endpoint you tested. |
For fixture.score and fixture.status, data.changes is a partial Fixture with the same shape as a WebSocket delta, so the code that merges deltas merges these too. For events and lineups the most robust pattern is to treat the webhook as a nudge and read the full list from the REST endpoint below, which is always complete.
Filters
You can narrow each endpoint with up to four optional filters — by event type, sport, league id and fixture id. Only events that match every filter you set are delivered.
Event types
- fixture.score
- fixture.status
andSports
- football
andLeague ids
- 17
- 8
andFixture ids
- empty: any
fixture.score · football · league 17Delivered: passes all four filtersfixture.status · football · league 52Not delivered: league 52 is not in the listfixture.score · tennis · league 2391Not delivered: tennis is not in the listfixture.events · football · league 8Not delivered: type not selected, and no fixture ids named
League and fixture filters take our ids, the same ones the REST API returns. A common setup is one broad endpoint for scores and status across a few leagues, plus a second endpoint whose fixture ids you update from your own code for the matches that need events and lineups.
The request we send
One POST per event per endpoint, with a small JSON body and four headers.
POST /webhooks/sports HTTP/1.1
Host: your-server.example.com
Content-Type: application/json
DSA-Signature: t=1793551606,v1=5f0c…
DSA-Event: fixture.score
DSA-Event-Id: evt_8a1f…
{
"id": "evt_8a1f…",
"type": "fixture.score",
"createdAt": "2026-11-01T16:46:46.208Z",
"data": {
"fixtureId": 211521,
"sport": "football",
"leagueId": 17,
"changes": { "homeScore": { "current": 1, "periods": { "period1": 1 } } }
}
}- DSA-Signature
t=1793551606,v1=5f0c…Timestamp and HMAC. Verify it before anything else.
- DSA-Event
fixture.scoreThe event type, so you can route before parsing the body.
- DSA-Event-Id
evt_8a1f…Same as id in the body. Stays identical across retries.
- Content-Type
application/jsonThe body is always JSON.
- id
- evt_…
Unique per event.
- createdAt
- ISO-8601, UTC
When we created the event, to the millisecond.
Verifying signatures
Anyone can POST to a public URL. The signature proves the request came from us and was not altered or replayed.
DSA-Signature: t=1793551606,v1=5f0c9e…41d2
t · when we signed
Unix seconds at the moment of signing. Refuse anything more than five minutes away from your clock, so a captured request cannot be played back later.
v1 · what we signed
Lower-case hex of an HMAC-SHA256, keyed with the endpoint's signing secret, over the timestamp, a full stop and the raw request body.
- 1Read the raw bodyBefore any JSON middleware touches it. The HMAC covers the exact bytes we sent.
- 2Split the headerSeparate t and v1 at the comma, then each name from its value at the first equals sign.
- 3Check the timeReject the request if t is more than five minutes from now.
- 4Recompute and compareHMAC-SHA256 over t, a full stop and the body, keyed with the signing secret; compare with v1 in constant time.
// webhook.ts: receive and verify events (Node 22+, no dependencies)
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const SECRET = process.env.WEBHOOK_SIGNING_SECRET!; // shown once, when the endpoint is created
const TOLERANCE_S = 300;
const seen = new Set<string>(); // use Redis or your database in production
export function verify(header: string | undefined, raw: Buffer, secret: string): boolean {
if (!header) return false;
const parts = new Map(header.split(",").map((kv) => kv.split("=", 2) as [string, string]));
const t = parts.get("t");
const v1 = parts.get("v1");
if (!t || !v1) return false;
const age = Date.now() / 1000 - Number(t);
if (!Number.isFinite(age) || Math.abs(age) > TOLERANCE_S) return false; // stale or replayed
const expected = createHmac("sha256", secret).update(`${t}.`).update(raw).digest();
const given = Buffer.from(v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks); // the exact bytes: verify before parsing
if (!verify(req.headers["dsa-signature"] as string | undefined, raw, SECRET)) {
res.writeHead(401).end();
return;
}
res.writeHead(204).end(); // answer inside 10 seconds, then do the work
const event = JSON.parse(raw.toString("utf8"));
if (seen.has(event.id)) return; // a retry of something already handled
seen.add(event.id);
enqueue(event);
});
}).listen(3000);
function enqueue(event: { id: string; type: string; createdAt: string; data: Record<string, unknown> }) {
// Hand off to your job queue. data.changes is a partial Fixture:
// deep-merge it exactly like a WebSocket delta.
}Responses and retries
Answer with any 2xx status within 10 seconds and the delivery is done. Anything else counts as a failure, and we try again.
- t = 0Delivery
- +10 sRetry 1
- +1 minRetry 2
- +5 minRetry 3
- +30 minRetry 4
- +2 hRetry 5
- +6 hRetry 6
- thenFailed
- Counts as success
- 2xx within 10 s
Any status from 200 to 299 will do; 204 No Content is the tidiest.
- Counts as failure
- anything else
4xx, 5xx, a timeout, a connection error and redirects too: we do not follow a 3xx, so point the endpoint at the final URL.
- Retry schedule
- 10 s · 1 min · 5 min · 30 min · 2 h · 6 h
Each wait follows the previous failed attempt. After the sixth retry the delivery is marked failed.
- Disabled endpoints
- 24 h of failures
If every attempt to an endpoint fails for a whole day, we stop sending to it. Fix it, then re-enable it in the dashboard.
Duplicates and order
Deliveries can arrive more than once and not always in the order things happened. Two fields make both harmless.
DSA-Event-Id · id
Seen it? Skip it.
A retry carries the same id as the original. Record each id you process (a unique index in your database works well) and ignore repeats. A response that timed out on our side may have succeeded on yours, so duplicates are normal, not an error.
createdAt
Older than what you have? Skip it.
A retried score can land after a newer one. Keep the newest createdAt you applied per fixture and event type, and do not let an older event overwrite newer state.
Before you go live
- Verify on raw bytesCompute the HMAC over the body exactly as it arrived. Parsing and re-serialising JSON changes the bytes.
- Compare in constant timetimingSafeEqual in Node, hmac.compare_digest in Python. A plain == leaks how much of a guess was right.
- Reject old timestampsFive minutes either way is a sensible window. Keep your server clock synced with NTP.
- Acknowledge firstReturn 2xx as soon as the signature checks out; do the slow work from a queue.
- Deduplicate by event idRemember ids for longer than the retry window, a day or more, so a late retry is recognised.
- Never trust arrival orderCompare createdAt before applying a change, or re-read the fixture over REST when order matters.
- Guard the secretStore the signing secret like an API key. If it may have leaked, rotate it in the dashboard; the new one is shown once.
- Watch for failuresAfter 24 hours in which every attempt failed, the endpoint is disabled. Turn it back on in the dashboard once it is fixed.
Testing locally? Endpoints must be public HTTPS, so expose your machine through a tunnel and use the dashboard's test delivery, which sends a ping event.