Skip to content

Webhooks

On this page

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.

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" }
  ]
}
typeWhen
purchase.completedA ticket is sold or claimed: once its order is processed, or 10 minutes after it is confirmed.
order.refundedAn order is refunded or charged back: one per refund, however many tickets it voids.
ticket.checked_inA ticket is checked in at the door.
webhook.testSend 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).

FieldOnNeeds on the key
ticket_entitlementsAll threeTicket types and access
ticket_countAll threeTicket types and access
ticket_type_namesAll threeTicket types and access, ticket type names
order_statuspurchase.completed, order.refundedPurchase status
checkin_timeticket.checked_inVenue check-in, check-in time
order_idticket.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).
  • 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:

Shell
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.

JavaScript
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.