Webhooks: the event matrix, the envelope, signature verification and delivery health # Delivery health & logs > The delivery contract, the webhook logs in the dashboard, and the health states of an endpoint. ## Delivery contract [Section titled “Delivery contract”](#delivery-contract) | Behaviour | Today | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Timeout | 10 seconds. Answer `2xx` first, then process | | Redirects | Public HTTP/HTTPS destinations are supported, up to 21 redirects. Every destination is validated | | Automatic suspension | Disabled during the compatibility rollout. Failed deliveries remain visible in logs | | Retries | `match.*`, `fact.*` and `summary.*`: up to 6 attempts with 30 seconds of retry delays. Other events: up to 3 attempts with 1 minute of retry delays | | Ordering | `match.*`, `fact.*` and `summary.*` for one match are sent in order, one at a time. A retried delivery can arrive after a newer event. Across matches there is no guarantee | | Delivery | At least once. A duplicate is normal, a lost event needs every attempt to fail | | Idempotency | Use `(entity, data.id, date)` as the deduplication key | Redirects must remain on public HTTP/HTTPS destinations. Private-network destinations are rejected. Use `307` or `308` to preserve the POST method and body. Cross-host redirects do not retain Basic Auth credentials. ## Retries [Section titled “Retries”](#retries) LIGR retries a delivery when your endpoint sends no answer, or answers `408`, `429` or any `5xx`. LIGR does not retry a `2xx`, or any other `4xx`. A `4xx` means the request itself is wrong for your endpoint, and a repeat carries the same bytes. The schedule depends on the event. `match.*`, `fact.*` and `summary.*` events are delivered in order per match, one at a time, so a failing endpoint must not hold a match for long. Other events, `team.*` and `competition.*`, have no order to keep and wait longer. | Attempt | Match events: wait before it | Other events: wait before it | | ------- | ---------------------------- | ---------------------------- | | 1 | None | None | | 2 | None | 15 seconds | | 3 | 2 seconds | 45 seconds | | 4 | 4 seconds | — | | 5 | 8 seconds | — | | 6 | 16 seconds | — | The waits add up to 30 seconds for a match event and 1 minute for any other event. Each attempt also waits up to 10 seconds for your answer. An event that fails every attempt is lost. Retries cover a restart or a deploy on your side, not an outage. After a longer outage, read the entities you track once from the [REST API](/rest/) and continue from the webhooks. Five rules follow from retries. * **Delivery is at least once.** A retry after a lost answer, or a worker that restarts mid-delivery, sends an event you already processed. Answer `2xx` and skip it. * **A retry is the same event.** `date` is the same on every attempt of one event, so `(entity, data.id, date)` still deduplicates it. * **A retry carries the entity as it is at that moment.** The payload is read again for every attempt. Treat every delivery as the latest state of the entity, not as a diff. * **A retry can arrive out of order.** A retry goes to the back of the match’s queue, so events for the same match keep flowing while it waits. Compare `date`, or `updatedAt` on the entity, and ignore an older delivery. * **One event counts once towards health.** Each attempt is one row in the log. Only the final failed attempt of an event adds one consecutive failure. One lost event cannot move an endpoint to `failing` on its own. ## Logs [Section titled “Logs”](#logs) The dashboard keeps the newest 50 attempts per webhook. Open **Developers → Webhooks**, open the webhook, and select **Event Logs**. Each row is one attempt. It shows the status, the response body, the payload and the recorded health. | Status in the log | Meaning | | ----------------- | ---------------------------------------------------------------------------------- | | `200`–`299` | Your endpoint accepted the delivery | | `300`–`599` | The request ended without a successful `2xx` response | | `0` | Your endpoint sent no response. It timed out, refused the connection or failed DNS | | `-500` | LIGR failed before it sent the request | ## Health states [Section titled “Health states”](#health-states) LIGR records the result of every delivery and shows one health state per webhook. A broken endpoint remains visible as `degraded` or `failing`; failures do not automatically suspend delivery during this rollout. LIGR will email affected customers with an effective date before enabling stricter delivery enforcement. | State | Meaning | What to do | | ----------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------ | | `unknown` | No delivery attempted yet | Change something in a subscribed competition | | `healthy` | The last delivery succeeded | Nothing | | `degraded` | 1 to 4 consecutive failures, or the last result was a failure | Read your endpoint logs. Deliveries continue | | `failing` | 5 or more consecutive failures | Investigate the failures. Deliveries continue | | `suspended` | Delivery was already suspended | Fix the endpoint, then select **Re-enable** in the dashboard. LIGR keeps the history | ## Keep your endpoint healthy [Section titled “Keep your endpoint healthy”](#keep-your-endpoint-healthy) 1. Answer `2xx` in under 10 seconds. Queue the work. 2. Return `2xx` for an event you already processed. A duplicate is not an error. 3. Alert on your own 5xx rate. LIGR does not alert you. # Events > The webhook events, when each one fires, and how they are ordered. LIGR sends one POST per change to every webhook that subscribes to the competition. Each event names one entity and one verb. ## Event matrix [Section titled “Event matrix”](#event-matrix) Set subscriptions per webhook and per competition in the dashboard. Select the events you want. LIGR filters everything else before it leaves the queue. | Entity | create | update | delete | Fires when | | ------------- | ------ | ------ | ------ | ---------------------------------------------------------------------------------- | | `match` | ✓ | ✓ | — | A match is created, or its details, status, score, clock or lineups change | | `fact` | ✓ | ✓ | ✓ | A fact is created, edited, undone or deleted | | `summary` | — | ✓ | ✓ | The match summary is recomputed. A delete carries ids only | | `team` | ✓ | ✓ | — | A team in the competition changes | | `competition` | — | ✓ | — | The competition itself changes | | `overlay` | ✓ | ✓ | ✓ | An overlay of a match is created, its settings or its key change, or it is deleted | A fact is a goal, a card, a point, a substitution or any other time-stamped match event. A match is never deleted. A match that leaves the schedule is cancelled, abandoned or finished early. That arrives as a `fact.create` named `MATCH_CANCELLED`, `MATCH_ABANDONED` or `MATCH_FINISHED_EARLY`, then a `summary.update`, then a `match.update` with `finishedStatus` set. Read the status from `match.update`. An `overlay` event fires for the overlay record: name, key, graphics delay, Auto Graphics, ad type, theme setting and control room. A graphic shown or hidden on the overlay is not an overlay event. An overlay that belongs to no match fires nothing, because there is no competition to route it to. Caution `summary.create` and `competition.create` do not exist. A summary starts to exist with its first `update`. A competition is created in the dashboard, not through an event. `match.delete`, `team.delete` and `competition.delete` do not exist. ## Order and volume [Section titled “Order and volume”](#order-and-volume) * `match.*`, `fact.*` and `summary.*` for one match arrive in order, one delivery at a time, in the order the change happened. A retried delivery is the exception: it can arrive after a newer event. Compare `date`. * Events across matches have no order guarantee. * A live football match produces tens of events. A `summary.update` follows most fact changes. Subscribe only to the events you handle. Every extra event costs your endpoint time, and a slow endpoint fails the 10-second timeout. See [Delivery health & logs](/webhooks/delivery-health/). # Overview > What LIGR webhooks do, how to create one, and what you must build to receive them. A webhook is an HTTP POST that LIGR sends to your URL when something changes. Use webhooks instead of polling the REST API. ## What you get [Section titled “What you get”](#what-you-get) * One POST per change, for the entities you subscribe to. * A signed request, so you can prove that LIGR sent it. * Per-competition subscriptions, so one endpoint can serve many competitions. * At-least-once delivery. An event can arrive more than once. It never arrives at most once. See [Events](/webhooks/events/) for the full matrix. ## Create a webhook [Section titled “Create a webhook”](#create-a-webhook) Webhooks are a dashboard feature. There is no REST endpoint for them. 1. Ask LIGR to enable the `f-webhooks` feature flag for your organization. 2. Open **Developers → Webhooks** in the dashboard. 3. Add the URL of your endpoint. 4. Select the competitions and the events you want. 5. Copy the signing secret from **Developers → Webhooks**. Your organization has one signing secret. Every webhook of the organization uses it. Caution A webhook with no signing secret is skipped without an error. Set the secret before you rely on deliveries. ## What your endpoint does [Section titled “What your endpoint does”](#what-your-endpoint-does) 1. Read the raw request body. Do not parse it before you verify the signature. 2. Verify `x-ligr-webhook-sig`. See [Verify signatures](/webhooks/verify-signatures/). 3. Answer `2xx` at once. Process the event after you answer. 4. Deduplicate on `(entity, data.id, date)`. Delivery is at least once, so a duplicate is normal. Your endpoint is your own code, so LIGR cannot check any of these steps. Step 2 is a recommendation. LIGR signs every delivery; verifying that signature is your choice. Steps 3 and 4 change what you receive: a slow answer causes a retry, and a retry arrives whether or not you deduplicate. LIGR waits 10 seconds for your answer. A delivery with no answer, or with a `5xx`, `408` or `429` answer, is retried: `match.*`, `fact.*` and `summary.*` up to 5 more times with 30 seconds of retry delays, other events up to 2 more times with 1 minute of retry delays. Each attempt can take 10 seconds. See [Delivery health & logs](/webhooks/delivery-health/). ## Fill a gap from REST [Section titled “Fill a gap from REST”](#fill-a-gap-from-rest) Webhooks carry changes. The [REST API](/rest/) holds current state. Read from REST in two cases: * **You start late.** A receiver that comes up mid-match, or a webhook enabled after kick-off, has not seen the earlier events. Read the match and its summary once, then apply events as they arrive. * **You lost events.** An event that fails every attempt is lost. After an outage on your side longer than a minute, read the entities you track once and continue from the webhooks. Do not poll REST on a timer as a substitute for webhooks. # Payloads > The webhook envelope, and the data shape for match, fact, summary, team and competition. ## Envelope [Section titled “Envelope”](#envelope) Every event has the same five top-level keys. ```json { "type": "update", "entity": "fact", "competitionId": 2311, "date": "2026-09-12T10:04:31.118Z", "data": {} } ``` | Key | Type | Meaning | | --------------- | --------------------------------------------------------- | ------------------------------------- | | `type` | `create` \| `update` \| `delete` | The verb | | `entity` | `match` \| `team` \| `fact` \| `competition` \| `summary` | The entity | | `competitionId` | number | The competition the change belongs to | | `date` | ISO 8601 string | When LIGR queued the event | | `data` | object | The entity, in the webhook shape | Use `(entity, data.id, date)` as your deduplication key. Delivery is at least once: the same event can arrive twice with the same key. ## match [Section titled “match”](#match) `data.homeTeam` and `data.awayTeam` follow `competitorsType`. A `teams` match carries a team object. A `singles` match carries a player. A `doubles` match carries a pair. `lineups` is present after a lineup import. ```json { "type": "update", "entity": "match", "competitionId": 2311, "date": "2026-09-12T10:04:31.118Z", "data": { "id": 1188213, "name": "Sydney FC v Melbourne City", "competitorsType": "teams", "homeTeam": { "id": 5021, "name": "Sydney FC", "gfxName": "SYD" }, "awayTeam": { "id": 5030, "name": "Melbourne City", "gfxName": "MCY" }, "score": { "home": 1, "away": 0 }, "clockData": { "clock": 1712, "running": true }, "liveStatus": "live", "finishedStatus": "default", "currentPeriod": { "number": 1, "name": "1st half" }, "coverage": "full", "lineups": { "home": { "starters": [], "bench": [] }, "away": { "starters": [], "bench": [] } } } } ``` ## fact [Section titled “fact”](#fact) A fact carries its match, its type, its clock position and the competitors it names. An undone fact arrives as a `delete`. ## summary [Section titled “summary”](#summary) A `summary` event carries the computed score, the periods and the statistics of one match. LIGR sends it after it folds a fact change into the summary. A `summary` delete carries ids only. ## overlay [Section titled “overlay”](#overlay) An `overlay` event carries the overlay record: the key a browser source uses, the match it belongs to, and the switches an operator or the REST API changes. A new key arrives as an `update`. ```json { "type": "update", "entity": "overlay", "competitionId": 2311, "date": "2026-09-12T10:04:31.118Z", "data": { "id": 2300003, "key": "3f2a9c1e-4b6d-4e8f-9a0b-1c2d3e4f5a6b", "name": "Broadcast", "matchId": 1188213, "competitionThemeSettingId": 8802, "controlRoomId": 91, "controllerId": null, "isPrimary": true, "isActive": true, "autoMode": false, "encodingDelay": 6, "adType": "free", "lng": null, "createdAt": "2026-09-01T02:14:09.000Z", "updatedAt": "2026-09-12T10:04:31.118Z", "deletedAt": null } } ``` ## team and competition [Section titled “team and competition”](#team-and-competition) A `team` event carries the team as the competition holds it, including `gfxName`. A `competition` event carries the competition record. ## Read the exact fields [Section titled “Read the exact fields”](#read-the-exact-fields) The webhook payload of an entity tracks the entity itself. To see every field your competition produces, read one live delivery in the log. Open **Developers → Webhooks**, open the webhook, and select **Event Logs**. The log shows the payload LIGR sent. # Verify signatures > Compute the HMAC-SHA256 signature over v0:timestamp:body and compare it in constant time. Every delivery is signed with the signing secret of your organization. LIGR recommends that you verify the signature before you trust the body. LIGR cannot enforce this: the check runs in your code, on your endpoint. The rules below describe the check LIGR recommends you write. ## Headers [Section titled “Headers”](#headers) | Header | Value | | -------------------------- | ------------------------------------------------------ | | `content-type` | `application/json` | | `x-ligr-webhook-timestamp` | Unix time in milliseconds when LIGR signed the request | | `x-ligr-webhook-sig` | The hex HMAC-SHA256 of `v0:{timestamp}:{body}` | ## The signed string [Section titled “The signed string”](#the-signed-string) LIGR joins three parts with colons. ```text v0:1757671471118:{"type":"update","entity":"fact",…} ``` * `v0` is the signature scheme. * The timestamp is the value of `x-ligr-webhook-timestamp`. * The body is the raw request body, byte for byte. Caution Sign the raw body. A body that your framework parsed and serialized again produces a different signature. ## Verify in Node [Section titled “Verify in Node”](#verify-in-node) verify.ts ```ts import { createHmac, timingSafeEqual } from 'node:crypto' const FIVE_MINUTES = 5 * 60_000 export function verify(rawBody: string, headers: Record, secret: string): boolean { const ts = headers['x-ligr-webhook-timestamp'] const sig = headers['x-ligr-webhook-sig'] if (!ts || !sig) return false if (Math.abs(Date.now() - Number(ts)) > FIVE_MINUTES) return false const expected = createHmac('sha256', secret).update(`v0:${ts}:${rawBody}`).digest('hex') if (sig.length !== expected.length) return false return timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) } ``` ## Verify in Python [Section titled “Verify in Python”](#verify-in-python) verify.py ```python import hmac import time from hashlib import sha256 FIVE_MINUTES_MS = 5 * 60 * 1000 def verify(raw_body: bytes, headers: dict, secret: str) -> bool: ts = headers.get("x-ligr-webhook-timestamp") sig = headers.get("x-ligr-webhook-sig") if not ts or not sig: return False if abs(time.time() * 1000 - int(ts)) > FIVE_MINUTES_MS: return False signed = f"v0:{ts}:".encode() + raw_body expected = hmac.new(secret.encode(), signed, sha256).hexdigest() return hmac.compare_digest(sig, expected) ``` ## Rules [Section titled “Rules”](#rules) 1. Compare in constant time. `timingSafeEqual` and `compare_digest` do this. 2. Reject a request older than 5 minutes. This stops a replay. 3. Reject a request with no signature header. 4. Never log the secret.