Docs/Reference/Receipts and webhooks
Receipts and webhooks
How brands confirm signed operations with receipts, account-level webhooks, and polling fallbacks.
Receipt model#
A receipt is the completed evidence your brand can reconcile.
Account-level webhooks notify the brand about operation outcomes, while indexed issuance and redemption receipts remain the source of completed onchain truth. Keep polling or direct chain reads as reconciliation fallbacks.
Webhook delivery#
Verify the raw request body before parsing JSON.
Configure up to five endpoints from the Loyfin account page. Each endpoint selects its event types and has a generated signing secret that is shown only when the endpoint is created or its secret is rotated. Store it in your secret manager.
| Field | Type | Required | Meaning |
|---|---|---|---|
X-Loyfin-Event | string | yes | One of loyfin.issued, loyfin.redeemed, loyfin.expired, or loyfin.rejected. |
X-Loyfin-Delivery-Id | string | yes | Stable delivery identifier. Store it as an idempotency key before applying business changes. |
X-Loyfin-Timestamp | Unix seconds | yes | Included in the signed payload. Reject stale timestamps; five minutes is a practical default. |
X-Loyfin-Signature | v1=<hex HMAC> | yes | HMAC-SHA-256 over timestamp + '.' + the exact raw request body. |
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLoyfinWebhook({ rawBody, timestamp, signature, secret }) {
const timestampNumber = Number(timestamp);
if (!Number.isFinite(timestampNumber)) throw new Error("Invalid webhook timestamp");
if (Math.abs(Date.now() / 1000 - timestampNumber) > 300) {
throw new Error("Stale webhook delivery");
}
const suppliedHex = signature.replace(/^v1=/, "");
const expectedHex = createHmac("sha256", secret)
.update(timestamp + "." + rawBody, "utf8")
.digest("hex");
const supplied = Buffer.from(suppliedHex, "hex");
const expected = Buffer.from(expectedHex, "hex");
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) {
throw new Error("Invalid webhook signature");
}
}- Read the raw body bytes before JSON parsing or middleware reformatting.
- Compare the HMAC in constant time, reject stale timestamps, and process each delivery ID once.
- Return a 2xx response only after the event is durably recorded. Loyfin retries non-2xx responses and delivery errors.
- Use
payload.idempotencyKeyas an additional event-level deduplication key.
Polling#
- After
POST /issuancesorPOST /redemptions, save the returned sessionid, operationid,nonce, andoperationHash. - Poll
GET /operations/:iduntil status ismined,rejected,cancelled, orexpired, or until your own timeout is reached. - For completed rows, fetch
GET /issuances?operationHash=...orGET /redemptions?operationHash=...and store thetxHash,blockNumber, andlogIndex. - For Add from wallet backfills, query
GET /redemptions?from=...&token=...&chainId=8453and process only burn receipts that are not already marked completed in your brand database. - Use cursor pagination for backfills. Do not assume one poll will catch every status transition during an outage.
Failure handling#
pendingmeans Created in product surfaces. It is not final.requires_holder_signaturemeans the checkout session still needs the recipient or holder wallet authorization.submittedmeans a relayer has sent a transaction, but the brand should still wait for a mined receipt.rejectedmeans the brand should release, retry, or manually review the internal operation based on the reason.cancelledandexpiredare terminal. Release reservations or route them to manual review according to the brand's policy.- Use direct chain reads as a fallback if the API is unavailable and the brand needs independent confirmation.