# Webhooks

Source: https://blocklive.io/docs/developers/webhooks

Blocklive tells your server when something happens at the events your API key
reads: a ticket sold, an order refunded, a guest checked in. Use it to create
the buyer's account in your app and send your own follow-up, without polling.

> Webhooks are in a pilot, with the developer API. Blocklive switches them on.

## Set one up

On the key's **Manage** sheet in Blocklive's admin (whoever manages the key:
the organizer's admins, or a developer they added):

1. Tick the events to send. Each needs the matching attendee detail on the key:
   ticket sales and refunds need **Purchase status**, check-ins need **Venue
   check-in**.
2. Enter your endpoint: an `https://` address on port 443, on the public
   internet.
3. Save. Blocklive shows the key's **signing secret** once (`blwh_…`). Keep it
   with the key, in your server's secret settings. A new address gets a new
   secret.
4. Blocklive checks the endpoint at once (below). Nothing is sent until it
   passes.

An organizer can limit which events developers' keys may send, and switch
webhooks off for one event.

## The check

Saving an address sends it a signed `webhook.challenge`:

```json
{
    "id": "whc_…",
    "type": "webhook.challenge",
    "challenge": "5c0f…",
    "created_at": "2026-11-17T18:00:00.000Z"
}
```

Answer `2xx` with the `challenge` value in a `Blocklive-Verification` response
header. **Check again** on the sheet repeats it.

## What you receive

```http
POST /blocklive HTTP/1.1
Content-Type: application/json
Blocklive-Id: whe_9a1c…
Blocklive-Timestamp: 1763402400
Blocklive-Signature: v1=5257a869e7…

{
  "id": "whe_9a1c…",
  "type": "purchase.completed",
  "created_at": "2026-11-17T18:00:00.000Z",
  "event_id": "3b1f…",
  "attendee_id": "att_…",
  "order_id": "pur_…"
}
```

| `type`               | When                                                                                 |
| -------------------- | ------------------------------------------------------------------------------------ |
| `purchase.completed` | A ticket is sold or claimed and its order is confirmed.                              |
| `order.refunded`     | An order is refunded or charged back: one per refund, however many tickets it voids. |
| `ticket.checked_in`  | A ticket is checked in at the door (`order_id` is `null`).                           |
| `webhook.test`       | **Send a test event** on the sheet. About nobody.                                    |

Webhooks follow what happened within a few seconds. `created_at` is when
Blocklive recorded the event.

A webhook carries no email, name or other personal detail. Read the attendee
with your key for those:

```sh
curl -H "Authorization: Bearer $BLOCKLIVE_API_KEY" \
  https://blocklive.io/api/v1/events/$EVENT_ID/attendees/$ATTENDEE_ID
```

It returns what the key may read at that moment. IDs are your key's own, the
same as its reads return.

## Check the signature

Every webhook is signed with HMAC-SHA256 over `<Blocklive-Id>.<Blocklive-Timestamp>.<raw body>`.
The HMAC key is the secret's 32 bytes: the hex after `blwh_`, decoded. Check it
before you parse the body, refuse anything more than 5 minutes old, and
remember IDs you have handled so a repeat is ignored.

```js
import { createHmac, timingSafeEqual } from 'node:crypto'

// secrets: your current signing secret, plus the old one while you switch.
function fromBlocklive(rawBody, headers, secrets) {
    const id = headers['blocklive-id']
    const timestamp = headers['blocklive-timestamp']
    const signatures = (headers['blocklive-signature'] ?? '').split(' ')
    if (
        !id ||
        !timestamp ||
        Math.abs(Date.now() / 1000 - Number(timestamp)) > 300
    )
        return false
    return secrets.some((secret) => {
        const expected = createHmac(
            'sha256',
            Buffer.from(secret.slice(5), 'hex')
        )
            .update(`${id}.${timestamp}.`)
            .update(rawBody)
            .digest()
        return signatures.some((s) => {
            const given = Buffer.from(s.replace(/^v1=/, ''), 'hex')
            return (
                given.length === expected.length &&
                timingSafeEqual(given, expected)
            )
        })
    })
}
```

In Python:

```python
import hashlib, hmac, time

def from_blocklive(raw_body: bytes, headers, secrets) -> bool:
    event_id = headers.get('Blocklive-Id', '')
    timestamp = headers.get('Blocklive-Timestamp', '')
    signatures = headers.get('Blocklive-Signature', '').split(' ')
    if not event_id or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    for secret in secrets:
        key = bytes.fromhex(secret.removeprefix('blwh_'))
        expected = hmac.new(key, f'{event_id}.{timestamp}.'.encode() + raw_body, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(s.removeprefix('v1='), expected) for s in signatures):
            return True
    return False
```

**New signing secret** on the sheet makes a new one. For 24 hours each webhook
carries two signatures, one per secret, so your server keeps verifying while
you switch over.

## Answer, and retries

-   Answer `2xx` within 5 seconds; do the work afterwards. Blocklive doesn't
    read the body.
-   Anything else, or no answer, is retried after 1 s, 5 s, 30 s, 2 min, 10
    min, 30 min, 1 h, 2 h, 4 h and 6 h, for at most a day. A `Retry-After`
    header is honoured, up to an hour. Then the event is given up and the next
    one goes.
-   Events go to each key **in order**, one at a time: a webhook you keep
    failing holds up the ones after it until it is given up.
-   `410 Gone` switches the webhook off until it is checked again.
-   Redirects are not followed.

The sheet shows how many were delivered and given up, the last problem, and
the last 25 attempts: when, which type, which try and what your server
answered.
**Send again** replays everything from the last hour, day or 7 days, with the
same IDs: if you drop repeated IDs, forget the ones you want processed again.
**Check again** and **Send a test event** (once the address is checked) work
up to 5 times a minute.

## Catch up

The same events are kept for 7 days. Read them with the key, in order, to
catch up after downtime, or instead of webhooks if you'd rather poll:

```http
GET https://blocklive.io/api/v1/webhook-events?cursor=<next_cursor>
```

```json
{
    "data": [{ "id": "whe_…", "type": "purchase.completed", "…": "…" }],
    "next_cursor": "…",
    "complete": true,
    "as_of": "2026-11-17T18:00:05.000Z"
}
```

Keep the last `next_cursor` and send it next time. Without a cursor you get the
last 7 days. A cursor older than that gets `410 resync_required`: read the
attendees in full. It counts against the key's 120 requests a minute.
