Skip to content

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 /events at your own pace. It includes everything a webhook might have missed.

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 CANCELLED link, records no event.
  • If approval is required, a link sends personal_link.created with status REQUESTED, and then personal_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.sent and order.completed are 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.

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.

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.

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:

  1. Read the raw body bytes before you parse the JSON. JSON that you serialize again doesn’t match.
  2. Split the header on , and = to get t and v1.
  3. Reject a t that’s 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 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.

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.

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. occurredAt is the time of the change.
  • The record is the source of truth. If order matters, read the link or store order again, or compare updatedAt and keep the newer one.

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 as after next time. Treat it as an opaque value. Every page returns it, including the last one. It’s null only if the feed is empty and you didn’t send after.
  • No event is skipped when you resume from a cursor.
  • If your key has the PERSONAL_LINKS scope, the feed holds your personal link events, including events from before the key existed. If your key has the REWARD_STORES scope, 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-links is for browsing and backfill, for example after more than 30 days without reading the feed.

Run a reconciler every few minutes. Each run does the following:

  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 on your side, the reconciler picks up every event without waiting for webhook retries. Retried webhooks that arrive later are skipped as duplicates.

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.