Skip to content

Webhooks and the event feed

Every change to an order or a personal link is recorded as an event, whichever channel made the change: the API, the workspace, Luxorr or a player on the gift site.

Events are available in two ways:

  • Webhook: pushed to the key’s webhook URL within seconds.
  • Event feed: GET /events, read at your own pace. It includes everything a webhook may have missed.

A webhook body and a feed item are the same object:

{
"id": "0b7e5f3c-2a1d-4c6e-9f8a-7b6c5d4e3f21",
"type": "order.status_changed",
"occurredAt": "2026-10-06T14:02:10Z",
"environment": "production",
"data": {
"order": { "id": "4c1f9a52-8d0e-4f4b-9a3e-1f2d3c4b5a69", "status": "SENT", "…": "…" },
"previousStatus": "PROCESSING"
}
}
Type data Sent when
order.created Order An order is placed, including by a personal-link claim.
order.status_changed {order, previousStatus} An order is approved, dispatched, delivered or cancelled, or its status is corrected.
order.updated Order Delivery details, courier comment, urgency or shipment change.
order.message_posted {orderId, externalOrderId, message} A message is posted on the order.
personal_link.created PersonalLink, REQUESTED or ACTIVE A link is created, from a campaign or a catalog selection. One event per link.
personal_link.approved PersonalLink A link waiting for approval is approved.
personal_link.claimed PersonalLink, with orderId A player claims a link.
personal_link.cancelled PersonalLink A link is withdrawn.
ping {} POST /webhooks:test is called. Sent only to that key, never in the feed.
  • A request that changes nothing, such as cancelling a CANCELLED order, records no event.
  • With approval required, a link sends personal_link.created with status REQUESTED, then personal_link.approved.
  • Order and PersonalLink match GET /orders/{orderId} and GET /personal-links/{linkId}. They contain no name, email, phone or street.

Which events a key receives. Order events need ORDERS. Personal-link events need PERSONAL_LINKS. A key limited to some brands receives only those brands’ events.

Own changes. A change made by a key’s own request is not sent to that key as a webhook. Other keys of the tenant receive it. The event feed includes the key’s own changes, except messages the key posted.

Luxorr sends POST <webhook URL> with the event as the body and these headers:

Header Value
Content-Type application/json
Luxorr-Event-Id the event’s id
Luxorr-Signature t=<unix seconds>,v1=<hex HMAC-SHA256>

The webhook URL must use HTTPS and resolve to a public address. It is set on the key in the workspace.

Any 2xx counts as delivered. Store the event, respond, then process it.

Other responses and timeouts are retried after 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours and 12 hours. After 8 attempts (about a day) delivery stops. The event stays in the feed.

v1 is the hex HMAC-SHA256 of <t>.<raw body>, keyed with the webhook secret. The secret is shown once, when the key is created or the secret is rotated. It is a 64-character hex string; use the string’s bytes as the HMAC key, not the decoded value.

  1. Read the raw body bytes before parsing JSON. Re-serialised JSON does not match.
  2. Split the header on , and = to get t and v1.
  3. Reject a t more than five minutes from your clock, to block replays.
  4. Compute the HMAC of t, a dot and the raw body. Compare it with v1 in constant time.
import crypto from 'node:crypto'
import express from 'express'
const TOLERANCE_SECONDS = 300
const webhookSecret = process.env.LUXORR_WEBHOOK_SECRET
function verifyLuxorrSignature(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((part) => part.split('=', 2)))
const timestamp = Number(parts.t)
if (!Number.isInteger(timestamp) || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(`${parts.t}.`), rawBody]))
.digest()
const received = Buffer.from(parts.v1, 'hex')
return received.length === expected.length && crypto.timingSafeEqual(received, expected)
}
const app = express()
app.post('/luxorr/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyLuxorrSignature(req.body, req.get('Luxorr-Signature') ?? '', webhookSecret)) {
return res.sendStatus(400)
}
const event = JSON.parse(req.body.toString('utf8'))
saveForProcessing(event)
res.sendStatus(200)
})

POST /webhooks:test sends a signed ping immediately and answers 200 with the result:

Case Response
Receiver responds 2xx {"responseStatus": 200, "error": null}
Receiver responds with another status {"responseStatus": 500, "error": "The receiver answered HTTP 500"}, with the actual status
Receiver unreachable {"responseStatus": null, "error": "The webhook could not be reached"}, or another reason
Key has no webhook URL {"responseStatus": null, "error": "This key has no webhook URL"}; nothing is sent

It needs an Idempotency-Key header. Tests are not retried and do not appear in the feed.

The Administrator rotates the secret on the key’s page. The old secret stops working immediately, so deploy the new one at once. Failed deliveries are retried, and the feed has every event.

  • At least once. Deliveries can repeat. Store processed event ids and skip duplicates.
  • No ordering. A failed delivery is retried later and does not block newer events. occurredAt is the time of the change.
  • The resource is the source of truth. If order matters, read the order or link again, or compare updatedAt and keep the newer.

GET /events?after=<cursor>&limit=100 returns events in commit order:

{
"items": [{ "id": "…", "type": "order.created", "occurredAt": "…", "environment": "production", "data": { "…": "…" } }],
"nextCursor": "eyJ0cmFuc2FjdGlvbklkIjo3NDIsInNlcXVlbmNlIjoxOX0"
}
  • Without after, the feed starts at the oldest retained event. Events are kept 30 days.
  • Store nextCursor and send it as after next time. Every page returns it, including the last one. It is null only when the feed is empty and no after was sent.
  • No event is skipped when you resume from a cursor. An event appears only after all earlier changes are saved.
  • The feed holds all events of the tenant, including events from before the key existed, filtered by the key’s brands and scopes.

Run a reconciler every few minutes:

  1. Read the feed from the stored cursor and process every event.
  2. Skip events whose id you already processed.
  3. Store the new cursor.

After an outage, the reconciler picks up all events without waiting for webhook retries. Retried webhooks that arrive later are skipped as duplicates.

Webhooks pause and calls answer 401. Events are still recorded. When access is switched on again, paused deliveries are sent and the feed is complete.