# 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
User-Agent: Blocklive-Webhooks/1.0 (+https://blocklive.io/docs/developers/webhooks)
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_…",
  "order_status": "processed",
  "ticket_count": 2,
  "ticket_entitlements": [
    { "id": "ent_…", "ticket_type_id": "typ_1…", "status": "active" },
    { "id": "ent_…", "ticket_type_id": "typ_2…", "status": "active" }
  ],
  "ticket_type_names": [
    { "id": "typ_1…", "name": "General admission" },
    { "id": "typ_2…", "name": "VIP" }
  ]
}
```

| `type`               | When                                                                                           |
| -------------------- | ---------------------------------------------------------------------------------------------- |
| `purchase.completed` | A ticket is sold or claimed: once its order is processed, or 10 minutes after it 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.                                                            |
| `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, by Blocklive's clock: a few seconds after the sale,
refund or scan itself, and always before the webhook is sent. Blocklive checks for
new events every 5 seconds. The `User-Agent` names the sender; the signature is
what proves it.

Nothing about an order comes before its sale: when an order is refunded, or a
ticket of it checked in, before its `purchase.completed` has gone, the
`purchase.completed` goes first.

### What each event says

Every event has `id`, `type`, `created_at`, `event_id`, `attendee_id` and
`order_id`, except `webhook.test`, which has only `id`, `type` and
`created_at`. The rest is there only when your key may read the same detail
about the attendee at that event ([what a key reads](https://blocklive.io/docs/developers#what-a-key-reads)).

| Field                 | On                                     | Needs on the key                           |
| --------------------- | -------------------------------------- | ------------------------------------------ |
| `ticket_entitlements` | All three                              | Ticket types and access                    |
| `ticket_count`        | All three                              | Ticket types and access                    |
| `ticket_type_names`   | All three                              | Ticket types and access, ticket type names |
| `order_status`        | `purchase.completed`, `order.refunded` | Purchase status                            |
| `checkin_time`        | `ticket.checked_in`                    | Venue check-in, check-in time              |
| `order_id`            | `ticket.checked_in` (else `null`)      | Purchase status                            |

-   `ticket_entitlements`: one item per ticket, as the attendee read gives
    them: `id`, `ticket_type_id` and `status`, in the attendee read's order.
    On `purchase.completed`, every ticket of the order, `active`. On
    `order.refunded`, the tickets that refund voided, `refunded` (the
    attendee read only ever says `active`). On `ticket.checked_in`, the
    ticket scanned, `active`. The IDs are the ones the attendee read gives
    the same tickets and types. A ticket not issued yet has `id: null`: one
    refunded before it was issued, or in an order that failed to deliver or
    wasn't finished 10 minutes after it was confirmed.
-   `ticket_type_names`: each listed ticket's type once, with its `id` and
    `name`, as the attendee read gives them.
-   `ticket_count`: how many tickets the event is about. `ticket_entitlements`
    lists the first 100, and fewer when more wouldn't fit in a webhook (64
    KiB). When `ticket_count` is more than the tickets listed, the rest
    aren't listed anywhere: no read says which order a ticket is from.
-   `order_status`: the order's status in the attendee export's words, such
    as `processed` (see
    [statuses](https://blocklive.io/docs/developers/reads#statuses-as-the-attendee-export-names-them)).
-   `checkin_time`: when the ticket was scanned.
-   `order_id` on a check-in: the ticket's order as the attendee read links
    them, or `null` when it links none.

Each event says what was true when Blocklive recorded it, so its retries,
**Send again** and the catch-up read say the same. What your key may read is
checked each time: a detail the organizer stops sharing is left out from then
on. An event Blocklive recorded before these fields existed has them `null`:
not known, rather than none.

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: at most 11 tries, for at most a day.
    Then the event is given up.
-   Events go to each key **in order**, one at a time. If one keeps failing
    while your server takes others, after 15 minutes it is set aside: retried
    on its own, on the same schedule, while the events after it go on. Events
    about the same order wait for it and keep their order: a sale, its refund
    and the check-ins of its tickets. Check-ins of two different tickets can
    arrive in either order, even in one order, as can events about different
    orders. A retry keeps its `Blocklive-Id`: drop repeats.
-   Events wait in order instead when your server seems down (since an event
    was set aside, nothing has been delivered and you haven't used **Check
    again** or **Send again**), when it answers `429` or `503`, or once 100
    events are set aside. A `Retry-After` asking for longer than the next
    try's own wait holds every event of the key, up to an hour.
-   `410 Gone` switches the webhook off until it is checked again. Set-aside
    events wait, and once it passes the check they go again at once, their
    tries and day starting over.
-   Redirects are not followed.

The sheet shows how many events were delivered, given up and set aside (with
the next try), 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.
