Webhooks and the event feed
Luxorr records an event for every change to a personal link, whoever made the change: the API, your team in the workspace, Luxorr, a player on the gift site, or the link expiring. Luxorr also records four moments of a rewards store order: approved, canceled, sent, and completed.
You can get events in two ways:
- Webhook: Luxorr sends the event to your key’s webhook URL within seconds.
- Event feed: you read
GET /eventsat your own pace. It includes everything a webhook might 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": "personal_link.claimed", "occurredAt": "2026-10-06T14:02:10Z", "environment": "production", "data": { "id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d", "status": "CLAIMED", "customFields": [ { "fieldId": "5c4b3a29-1807-4f6e-8d5c-4b3a29180746", "textValue": "player-123", "checked": null } ], "…": "…" }}environment is production. On personal_link.* events, data is the PersonalLink, the same as GET /personal-links/{linkId} returns. On order.* events, data is the RewardStoreOrder, the same as GET /reward-store-orders/{orderId} returns.
The following table lists the event types:
| Type | data |
Sent when |
|---|---|---|
personal_link.created |
PersonalLink, REQUESTED or ACTIVE |
A link is created, through the API or by your team. One event per link. |
personal_link.approved |
PersonalLink | A link that was waiting for approval is approved. |
personal_link.claimed |
PersonalLink, CLAIMED |
A player claims a link. Match it to your player by its customFields. |
personal_link.cancelled |
PersonalLink | A link is withdrawn. |
personal_link.expired |
PersonalLink | A link expires because its campaign ended or passed its own expiry. |
order.approved |
RewardStoreOrder, PROCESSING |
A store order that was waiting for approval is approved. |
order.cancelled |
RewardStoreOrder, CANCELLED |
A store order is canceled, including a rejection while it was waiting for approval. Refund the player once per orderId. |
order.sent |
RewardStoreOrder, SENT |
A store order shipped. shipment carries the carrier and tracking. |
order.completed |
RewardStoreOrder, COMPLETED |
A store order was delivered. |
ping |
{} |
You called POST /webhooks:test. Only that key receives it, and it never appears in the feed. |
Keep the following in mind:
- A request that changes nothing, such as canceling a
CANCELLEDlink, records no event. - If approval is required, a link sends
personal_link.createdwith statusREQUESTED, and thenpersonal_link.approved. - A PersonalLink contains no name, email, phone number, or street address. It does carry the link’s custom field answers.
- Each
order.*event is sent at most once per order. Other order changes send no event.order.sentandorder.completedare for information only: never move coins on them. For more information, see Coins. - A RewardStoreOrder contains no player name, email, phone number, or address.
Which events a key receives. A key that’s limited to some brands receives only the events of those brands. personal_link.* events go only to keys with the PERSONAL_LINKS scope. order.* events go only to the key that placed the order, and only while that key has the REWARD_STORES scope. Your other keys never receive them.
Your own changes. If your key’s own request makes a change, that key doesn’t receive it as a webhook. Your other keys with the matching scope do. The event feed includes your key’s own changes. An expiry isn’t made by any key, so every key with PERSONAL_LINKS receives personal_link.expired, including the key that created the link. Your team or Luxorr makes order changes, so the key that placed the order receives their events.
Webhooks
Section titled “Webhooks”The request
Section titled “The request”Luxorr sends POST <webhook URL> with the event as the body and the following 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. Your team sets it on the key in the workspace.
Respond with 2xx
Section titled “Respond with 2xx”Any 2xx response counts as delivered. Store the event, respond, and then process it.
Respond quickly. Luxorr allows 5 seconds to connect and waits up to 10 seconds for your response. If your receiver does its work before it responds, it risks a timeout. A timeout counts as a failed delivery and is retried.
Luxorr retries other responses and timeouts after 1 minute, 5 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, and 12 hours. After 8 attempts, about a day, Luxorr stops trying. 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’s a 64-character hex string. Use the string’s bytes as the HMAC key, not its decoded value.
To verify a webhook, do the following:
- Read the raw body bytes before you parse the JSON. JSON that you serialize again doesn’t match.
- Split the header on
,and=to gettandv1. - Reject a
tthat’s more 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 right away and returns 200 with the result:
| Case | Response |
|---|---|
Your receiver responds with 2xx |
{"responseStatus": 200, "error": null} |
| Your receiver responds with another status | {"responseStatus": 500, "error": "The receiver answered HTTP 500"}, with the actual status |
| Your receiver can’t be reached | {"responseStatus": null, "error": "The webhook could not be reached"}, or another reason |
| The key has no webhook URL | {"responseStatus": null, "error": "This key has no webhook URL"}. Nothing is sent. |
Every call sends a real ping. The API ignores an Idempotency-Key header, so a retry after you fix the receiver shows its new response. Tests aren’t retried and don’t appear in the feed.
The call waits for your receiver, so it can take up to 15 seconds. Set your HTTP client’s timeout above that.
Rotate the secret
Section titled “Rotate the secret”Your Administrator rotates the secret on the key’s page. The old secret stops working right away, so deploy the new one at once. Failed deliveries are retried, and the feed has every event.
Delivery guarantees
Section titled “Delivery guarantees”Luxorr makes the following guarantees:
- At least once. A delivery can repeat. Store the event
ids you processed, and skip duplicates. - No fixed order. A failed delivery is retried later and doesn’t hold back newer events.
occurredAtis the time of the change. - The record is the source of truth. If order matters, read the link or store order again, or compare
updatedAtand keep the newer one.
The event feed
Section titled “The event feed”GET /events?after=<cursor>&limit=100 returns events in the order Luxorr saved them:
{ "items": [{ "id": "…", "type": "personal_link.created", "occurredAt": "…", "environment": "production", "data": { "…": "…" } }], "nextCursor": "b3BhcXVlLWN1cnNvcg"}Keep the following in mind:
- If you don’t send
after, the feed starts at the oldest event it keeps. Events are kept for 30 days. - Store
nextCursor, and send it asafternext time. Treat it as an opaque value. Every page returns it, including the last one. It’snullonly if the feed is empty and you didn’t sendafter. - No event is skipped when you resume from a cursor.
- If your key has the
PERSONAL_LINKSscope, the feed holds your personal link events, including events from before the key existed. If your key has theREWARD_STORESscope, the feed holds the events of the store orders that your key placed. Your key’s brands filter both. - The feed is how you sync links.
GET /personal-linksis for browsing and backfill, for example after more than 30 days without reading the feed.
Recover missed webhooks
Section titled “Recover missed webhooks”Run a reconciler every few minutes. Each run does the following:
- Read the feed from the stored cursor, and process every event.
- Skip events whose
idyou already processed. - Store the new cursor.
After an outage on your side, the reconciler picks up every event 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”While API access is off, webhooks pause and every call returns 401. Events are still recorded. When access is turned on again, the paused webhooks are sent, and the feed is complete.

