Skip to content

Receive webhooks

This guide builds a webhook receiver in Node and connects it to a competition. It needs a public HTTPS URL, the signing secret of your organization, and about half an hour.

The contract in one line: LIGR sends one signed POST per change, waits 10 seconds, and retries a failed delivery with up to a minute of retry delays. Your endpoint must verify, answer 2xx at once, and process afterwards.

  1. Get the secret.

    Open Developers → Webhooks in the dashboard and copy the signing secret. Your organization has one secret, and every webhook of the organization uses it. Put it in an environment variable on your server. Never put it in a browser.

  2. Write the receiver.

    Read the raw body before anything parses it. The signature covers the bytes LIGR sent.

    server.ts
    import { createHmac, timingSafeEqual } from 'node:crypto'
    import express from 'express'
    const SECRET = process.env.LIGR_WEBHOOK_SECRET!
    const FIVE_MINUTES = 5 * 60_000
    const seen = new Set<string>()
    function verify(rawBody: string, ts: string | undefined, sig: string | undefined): boolean {
    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))
    }
    const app = express()
    app.post('/ligr', express.raw({ type: 'application/json' }), (req, res) => {
    const rawBody = req.body.toString('utf8')
    const ts = req.header('x-ligr-webhook-timestamp')
    const sig = req.header('x-ligr-webhook-sig')
    if (!verify(rawBody, ts, sig)) return res.status(401).end()
    const event = JSON.parse(rawBody)
    const key = `${event.entity}:${event.data.id}:${event.date}`
    if (seen.has(key)) return res.status(200).end()
    seen.add(key)
    res.status(200).end()
    setImmediate(() => handle(event))
    })
    function handle(event: { type: string; entity: string; competitionId: number; date: string; data: any }) {
    console.log(event.entity, event.type, event.data.id)
    }
    app.listen(3000)

    The handler answers before it does any work. A slow database write inside the request is the usual cause of a timeout.

  3. Expose it over HTTPS and register it.

    Deploy the receiver, or tunnel it for a test. Then in Developers → Webhooks, add the URL, select the competitions and the events you want, and save. Subscribe only to the events you handle. See Events.

  4. Trigger a delivery.

    Change something in a subscribed competition. A fact is the quickest: post one to a test match with POST /v2/matches/{matchId}/facts, then delete it. See Score a match. Your receiver logs fact create, then fact delete, and a summary update between them.

  5. Read the log.

    Open Developers → Webhooks, open the webhook, and select Event Logs. The log keeps the newest 50 attempts per webhook, with the status, the response body and the payload. A 0 status means your endpoint sent no answer. A 401 from the receiver above means the signature failed. See Delivery health & logs.

LIGR retries a delivery that gets no answer, or a 5xx, 408 or 429 answer. A fact or summary event retries at once, then after 2, 4, 8 and 16 seconds. Any other event retries after 15 and 45 seconds. A restart or a deploy on your side is covered. An outage longer than a minute loses the events of that window.

Read from the REST API once, then continue from the webhooks, when you start late or lost events:

SituationRead once
Your receiver comes up mid-match, or the webhook was enabled after kick-offGET /v2/matches/{matchId} and GET /v2/matches/{matchId}/summary
Your endpoint was down for longer than a minuteThe matches and summaries of every live competition

The date on every event and the updatedAt on every entity tell you which side is newer.

Delivery is at least once. A retried delivery keeps the date of the event, so the seen set in the receiver above drops a duplicate. It carries the entity as it is at retry time, and it can arrive after a newer event for the same entity. Keep the delivery with the newest date.

SymptomCauseFix
Every delivery fails verificationThe body was parsed and re-serialized before the checkRead the raw body. Mount the raw parser before any JSON parser
Every delivery fails after a deployThe secret changed, or the environment variable is missingCopy the secret from the dashboard again
Some deliveries failClock skew over five minutesSync the server clock. The timestamp is Unix time in milliseconds

The full verification contract, with a Python version, is on Verify signatures.