Webhooks
Results, fixtures and live scores posted to your server the moment they happen.
Instead of polling, give us an https address. When a result lands, a fixture is added, a match goes in play or its score moves, we post one JSON event to it, signed with your secret. If your side is down we retry for about 3 hours, and every attempt is in your dashboard for 30 days.
Set it up
Open your webhooksAdd your address
In the dashboard, under Webhooks: an https address that answers from the public internet, and the events you want.
Add an endpointWatch the test arrive
Saving sends a webhook.test event at once, so you see the request your code will get and the answer it gave.
Check it, answer 2xx
Check the signature with your secret, answer any 2xx within 10 seconds, then do the work. That is all a delivery needs.
The events
Choose any of them for each endpoint| Event | When it is sent | In data |
|---|---|---|
| match.finished | A result lands in the archive, with the score, both averages and the match id. Only for matches played in the last two days, so filling in old seasons never sends anything. | match_idplayed_oneventevent_idroundwinnerloser |
| fixture.added | A new match appears on the draw, with its date and time where known. | fixture_ideventrounddatestarts_atdate_confirmedplayers |
| match.started | A match goes in play on the live feed. | fixture_ideventrounddatestarts_atdate_confirmedplayers |
| score.changed | The score moves on the live feed, checked every five minutes. One event can cover several legs. | fixture_ideventrounddatestarts_atdate_confirmedplayersprevious_score |
| webhook.test | Sent when you add an endpoint or press Send test. Sent once, never retried. | message |
What arrives
Real events, as they were posted{
"id": "evt_UPDynB7GYCnG",
"type": "match.finished",
"created": "2026-11-04T06:08:02Z",
"data": {
"match_id": "mt_1PJWAT7H6VZ1WHG5C6KJ",
"played_on": "2026-11-02",
"event": "China Championship",
"event_id": "ev_R18E03",
"round": "round_robin",
"winner": {
"player_id": "pl_Z4W4X1",
"name": "Zhuolun Luo",
"score": 5,
"average": 69.38
},
"loser": {
"player_id": "pl_3R65AZ",
"name": "Chao Geng",
"score": 4,
"average": 66.75
}
}
}{
"id": "evt_b98SYXW6aQgB",
"type": "fixture.added",
"created": "2026-11-13T00:01:03Z",
"data": {
"fixture_id": "fx_dd5c3d63719513a2",
"event": "Tomorrow's matches",
"round": null,
"date": "2026-11-13",
"starts_at": "2026-11-13T17:30:00Z",
"date_confirmed": false,
"players": [
{
"player_id": "pl_NWEKMD",
"name": "Jonny Clayton"
},
{
"player_id": "pl_NE4HY4",
"name": "Anton Östlund"
}
]
}
}{
"id": "evt_Wq7Rm3xKd9Lp",
"type": "match.started",
"created": "2026-11-15T19:31:40Z",
"data": {
"fixture_id": "...",
"event": "Grand Slam of Darts",
"round": "...",
"date": "2026-11-15",
"starts_at": "2026-11-15T19:30:00Z",
"date_confirmed": true,
"players": [
{
"player_id": "pl_...",
"name": "Luke Littler",
"score": 0
},
{
"player_id": "pl_...",
"name": "Gerwyn Price",
"score": 0
}
]
}
}{
"id": "evt_b6Ty2NcV8rQs",
"type": "score.changed",
"created": "2026-11-15T19:51:12Z",
"data": {
"fixture_id": "...",
"event": "Grand Slam of Darts",
"round": "...",
"date": "2026-11-15",
"starts_at": "2026-11-15T19:30:00Z",
"date_confirmed": true,
"players": [
{
"player_id": "pl_...",
"name": "Luke Littler",
"score": 6
},
{
"player_id": "pl_...",
"name": "Gerwyn Price",
"score": 4
}
],
"previous_score": "5-4"
}
}{
"id": "evt_Zc4Hn7Qw2Ksm",
"type": "webhook.test",
"created": "2026-11-13T06:56:30Z",
"data": {
"message": "A test from dartsapi.com. Answer with any 2xx and this endpoint is working."
}
}The request
POST, Content-Type application/json| Header | Example | What it is |
|---|---|---|
X-DartsAPI-Event | match.finished | The event's type, the same as type in the body. |
X-DartsAPI-Delivery | dlv_Hq3mT8wKc2Rz | This delivery. The same on every retry of it, and shown against it in your dashboard. |
X-DartsAPI-Signature | t=1794552990,v1=5f1c...e9a2 | When it was signed, and the HMAC-SHA256 of the time, a dot and the raw body. |
User-Agent | DartsAPI-Webhooks/1.0 | For your logs and your firewall. |
Check a delivery is ours
Your signing secretYour secret starts whsec_ and is the same for every endpoint on your account. Sign the time, a dot and the raw body with it, compare in constant time, and refuse anything older than five minutes so a captured request cannot be replayed.
// your endpoint, e.g. /webhooks/darts $secret = getenv('DARTSAPI_WEBHOOK_SECRET'); $body = file_get_contents('php://input'); // the raw body, before any decoding parse_str(strtr($_SERVER['HTTP_X_DARTSAPI_SIGNATURE'] ?? '', ',', '&'), $sig); $want = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, $secret); if (!hash_equals($want, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) { http_response_code(400); // not from us, or too old exit; } http_response_code(200); // answer first, work after $event = json_decode($body, true);
import hmac, hashlib, os, time from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["DARTSAPI_WEBHOOK_SECRET"].encode() @app.post("/webhooks/darts") def darts(): body = request.get_data() # the raw body sig = dict(p.split("=", 1) for p in request.headers.get("X-DartsAPI-Signature", "").split(",") if "=" in p) want = hmac.new(SECRET, sig.get("t", "").encode() + b"." + body, hashlib.sha256).hexdigest() if not hmac.compare_digest(want, sig.get("v1", "")) or abs(time.time() - int(sig.get("t", 0))) > 300: abort(400) event = request.get_json() return "", 200
import crypto from "node:crypto"; import express from "express"; const app = express(); app.post("/webhooks/darts", express.raw({ type: "application/json" }), (req, res) => { const sig = Object.fromEntries((req.get("X-DartsAPI-Signature") || "").split(",").map((p) => p.split("="))); const want = crypto.createHmac("sha256", process.env.DARTSAPI_WEBHOOK_SECRET) .update(`${sig.t}.${req.body}`).digest("hex"); const ok = sig.v1 && want.length === sig.v1.length && crypto.timingSafeEqual(Buffer.from(want), Buffer.from(sig.v1)); if (!ok || Math.abs(Date.now() / 1000 - Number(sig.t)) > 300) return res.sendStatus(400); res.sendStatus(200); const event = JSON.parse(req.body); });
Delivery and retries
Every attempt logged for 30 daysWhat counts as delivered
Any 2xx within 10 seconds. A redirect is not followed and counts as a miss, so give the final address.
Which addresses
https only, and never one that resolves to a private or reserved network. The address we checked is the one we post to.
From your dashboard
Retry a delivery now, see its payload and the answer you gave, pause or resume an endpoint, or send a test.
Good practice
Answer, then work
Reply 2xx as soon as the signature checks, and do the slow work afterwards, so a busy moment never looks like an outage.
Expect a repeat
A retry sends the same event again. Keep the event id you have handled and skip one you have seen.
Order by time, not arrival
A retried event can land after a newer one. Use created, and the match's own score, to decide what is latest.
Webhooks come with Live, Complete and Analyst
The same key, up to 5 endpoints and every event, from $39 a month.