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.
The event
Section titled “The event”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
CANCELLEDorder, records no event. - With approval required, a link sends
personal_link.createdwith statusREQUESTED, thenpersonal_link.approved. - Order and PersonalLink match
GET /orders/{orderId}andGET /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.
Webhooks
Section titled “Webhooks”The request
Section titled “The request”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.
Respond with 2xx
Section titled “Respond with 2xx”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.
Verify the signature
Section titled “Verify the signature”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.
- Read the raw body bytes before parsing JSON. Re-serialised JSON does not match.
- Split the header on
,and=to gettandv1. - Reject a
tmore than five minutes from your clock, to block replays. - Compute the HMAC of
t, a dot and the raw body. Compare it withv1in constant time.
import crypto from 'node:crypto'import express from 'express'
const TOLERANCE_SECONDS = 300const 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)})import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
TOLERANCE_SECONDS = 300WEBHOOK_SECRET = os.environ["LUXORR_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
def verify_luxorr_signature(raw_body: bytes, header: str, secret: bytes) -> bool: parts = dict(part.split("=", 1) for part in header.split(",") if "=" in part) timestamp, signature = parts.get("t", ""), parts.get("v1", "") if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False expected = hmac.new(secret, timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)
@app.post("/luxorr/webhooks")def luxorr_webhook(): raw_body = request.get_data() if not verify_luxorr_signature(raw_body, request.headers.get("Luxorr-Signature", ""), WEBHOOK_SECRET): abort(400) save_for_processing(request.get_json()) return "", 200<?php
const TOLERANCE_SECONDS = 300;
function verifyLuxorrSignature(string $rawBody, string $header, string $secret): bool{ $parts = []; foreach (explode(',', $header) as $part) { [$name, $value] = array_pad(explode('=', $part, 2), 2, ''); $parts[$name] = $value; } $timestamp = $parts['t'] ?? ''; $signature = $parts['v1'] ?? ''; if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); return hash_equals($expected, $signature);}
$rawBody = file_get_contents('php://input');$header = $_SERVER['HTTP_LUXORR_SIGNATURE'] ?? '';
if (!verifyLuxorrSignature($rawBody, $header, getenv('LUXORR_WEBHOOK_SECRET'))) { http_response_code(400); exit;}
saveForProcessing(json_decode($rawBody, true));http_response_code(200);import java.nio.charset.StandardCharsets;import java.security.GeneralSecurityException;import java.security.MessageDigest;import java.time.Instant;import java.util.HexFormat;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
public final class LuxorrSignature {
private static final long TOLERANCE_SECONDS = 300;
public static boolean verify(byte[] rawBody, String header, String secret) throws GeneralSecurityException { String timestamp = null; String signature = null; for (String part : header.split(",")) { String[] pair = part.split("=", 2); if (pair.length == 2 && pair[0].equals("t")) timestamp = pair[1]; if (pair.length == 2 && pair[0].equals("v1")) signature = pair[1]; } if (timestamp == null || signature == null || !timestamp.matches("\\d+")) return false; if (Math.abs(Instant.now().getEpochSecond() - Long.parseLong(timestamp)) > TOLERANCE_SECONDS) return false;
Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.US_ASCII); return MessageDigest.isEqual(expected, signature.getBytes(StandardCharsets.US_ASCII)); }}Test your receiver
Section titled “Test your receiver”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.
Rotating the secret
Section titled “Rotating the secret”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.
Delivery guarantees
Section titled “Delivery guarantees”- 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.
occurredAtis the time of the change. - The resource is the source of truth. If order matters, read the order or link again, or compare
updatedAtand keep the newer.
The event feed
Section titled “The event feed”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
nextCursorand send it asafternext time. Every page returns it, including the last one. It isnullonly when the feed is empty and noafterwas 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.
Recovering missed webhooks
Section titled “Recovering missed webhooks”Run a reconciler every few minutes:
- Read the feed from the stored cursor and process every event.
- Skip events whose
idyou already processed. - 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.
When API access is off
Section titled “When API access is off”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.