Docs/Operate your integration/Receipts and webhooks
Receipts and webhooks
How brands confirm signed operations with receipts, account-level webhooks, and polling fallbacks.
Receipt model#
Update points after finalized completion.
Use verified completion webhooks to confirm conversions. Before delivering a completion event, Loyfin checks that the transaction succeeded and its receipt is canonical and finalized. A wallet receipt or an indexed mined status alone does not establish finality. When reconciling through polling or direct chain reads, check the canonical finalized receipt before changing an account balance.
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 |
|---|---|---|---|
webhook-id | string | yes | Stable event ID, equal to payload.idempotencyKey across retries, replays and endpoints. |
webhook-timestamp | Unix seconds | yes | Fresh for every attempt. Reject timestamps more than five minutes in the past or future. |
webhook-signature | v1,base64 | yes | Standard Webhooks HMAC-SHA-256 over id + '.' + timestamp + '.' + raw body, using the base64-decoded whsec_ key. Multiple signatures are separated by spaces during rotation. |
schemaVersion | payload integer | yes | Currently 1. Event payloads remain unchanged on retry and replay. |
Webhooks cover only issuance and redemption operations created through the Loyfin API, using either the Business UI or an API key; direct onchain actions without a matching stored operation are excluded.
Both issuance and redemption authorizations are encrypted at rest. Receipt matching uses operation identity fields, not stored signature ciphertext.
import { Webhook } from "standardwebhooks";
const verifier = new Webhook(process.env.LOYFIN_WEBHOOK_SECRET);
// Read the original request bytes, before JSON parsing middleware.
const rawBody = await request.text();
const event = verifier.verify(rawBody, {
"webhook-id": request.headers.get("webhook-id"),
"webhook-timestamp": request.headers.get("webhook-timestamp"),
"webhook-signature": request.headers.get("webhook-signature"),
});
// The verifier rejects invalid signatures and timestamps outside five minutes.
if (event.schemaVersion !== 1) throw new Error("Unsupported event schema");
if (event.type === "loyfin.test") return new Response(null, { status: 204 });
// Durably record the verified event before returning 2xx.
// Deduplicate event.idempotencyKey and reconcile balances atomically.Delivery is at least once, with no ordering guarantee. Completion events wait for chain finality, which can delay the first attempt. Failed requests retry with exponential backoff and jitter for up to 72 hours after the first dispatch (at most 100 attempts). Retry-After is respected up to six hours; HTTP 410 ends retries. Other non-2xx responses, redirects and network errors are retried. Requests time out after ten seconds.
If retries are exhausted, Loyfin emails the business account owner (at most once per endpoint per day). In Business → Integrations, send a test, inspect attempt history, pause an endpoint, or replay a delivery. Replay preserves its event ID and payload, opens a new retry window, and rechecks completion finality. Cancelled receipts cannot be replayed. Tests use loyfin.test and must never change balances.
Rotating a secret signs deliveries with both keys for 24 hours. Install the new key during that overlap. Another rotation is blocked until the overlap ends. Endpoints receive only events created while active and subscribed; newly created or paused endpoints do not receive an automatic backfill.
- Read the raw body bytes before JSON parsing or middleware reformatting.
- Compare the HMAC in constant time and reject stale timestamps before trusting the payload.
- Return a 2xx response only after the event is durably recorded. Loyfin retries non-2xx responses and delivery errors.
- Deduplicate with
payload.idempotencyKey. Write the processed-event record and account balance change in the same database transaction. Keep a unique receipt record too, so webhook delivery and polling cannot credit the same burn twice.
| Field | Type | Required | Meaning |
|---|---|---|---|
loyfin.issued | completion | yes | Finalized Add to wallet issuance. Mark the reserved conversion complete. |
loyfin.redeemed | completion | yes | Finalized Add from wallet burn. Verify the program, wallet/account mapping, amount, and operation reference before crediting points once. |
loyfin.expired | expiry | yes | Chain-confirmed expiry. Release reservations only when the verified payload carries reservationReleaseSafe: true, or after equivalent finalized chain checks. |
loyfin.rejected | failure | yes | A rejection outcome. Reconcile the reason; this label alone does not revoke an executable signature or authorize reservation release. |
loyfin.reversed | reversal | yes | A previously delivered receipt was removed. Reconcile the balance change identified by data.originalIdempotencyKey. Reversals follow the original endpoint subscription. |
{
"idempotencyKey": "loyfin.redeemed:8453:0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:0:0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"type": "loyfin.redeemed",
"createdAt": "2026-09-05T10:25:00.000Z",
"data": {
"chainId": "8453",
"txHash": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"logIndex": 0,
"blockHash": "0xdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"blockNumber": "29188900",
"finality": "finalized",
"issuer": "0x1111111111111111111111111111111111111111",
"from": "0x2222222222222222222222222222222222222222",
"loyaltyAddress": "0x4444444444444444444444444444444444444444",
"loyaltyId": "0x4242424242424242424242424242424242424242424242424242424242424242",
"amount": "250",
"expiresAt": "0",
"nonce": "0x7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b7b",
"operationHash": "0x9999999999999999999999999999999999999999999999999999999999999999"
}
}A reversal has its own idempotency key. Find the original processed event through data.originalIdempotencyKey, then reverse or flag its account adjustment according to your ledger policy in one database transaction. If the original event did not change a balance, record the reversal without inventing an adjustment. Completion keys include the block hash, so a receipt re-included in another block has a new event key; reconcile its previous state before applying it again.
Polling#
| Field | Type | Required | Meaning |
|---|---|---|---|
session.id | UUID | yes | Public checkout session ID. Use it in /issuances/:id or /redemptions/:id with the private claim token. |
session.operationId | numeric string | yes | Stored operation ID. Use it in GET /operations/:id?chainId=8453; public responses omit signatures. |
operationHash | bytes32 | yes | Your stored random or HMAC reference linking one conversion to your database. |
nonce | bytes32 | yes | Issuer replay key shared across action kinds. It is not an API row ID. |
chainId, txHash, logIndex | receipt identity | yes | Identifies a scalar issuance or redemption receipt. Also retain its block identity and reversal state. |
// After POST /issuances:
const { issuance: session } = await response.json();
const result = await fetch(
"https://api.loyfin.com/operations/" + session.operationId +
"?chainId=" + session.chainId
);
if (!result.ok) throw new Error(await result.text());
const { operation } = await result.json();
// session.id is the checkout UUID; operation.id is the numeric operation ID.- After
POST /issuancesorPOST /redemptions, savesession.id,session.operationId,nonce, andoperationHashwith your internal conversion. - Poll
GET /operations/:iduntil status ismined,rejected,cancelled, orexpired, or until your own timeout is reached. - For mined rows, fetch the matching issuance or redemption receipt. Read its transaction onchain, check success and that its block is canonical and no later than the finalized block. Match the expected log, issuer, program, wallet, amount, and operation reference before applying a balance change. Store the transaction hash, log index, block number, and block hash.
- 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_wallet_authorizationmeans the checkout session still needs the recipient or holder wallet authorization.submittedmeans a transaction has been sent. Wait for verified finalized completion.rejectedorcancelledrequires reconciliation. A failed wallet prompt, failed transaction, or API state does not itself revoke a signed issuance.submissionClosedor a local timeout stops the checkout flow; neither proves that a transaction did not execute before the deadline.- Release reserved points after a verified
loyfin.expiredevent withreservationReleaseSafe: true. The expiry check requires a finalized block strictly after the signed deadline and an unused issuer nonce at that block. Equivalent direct chain checks are a valid fallback. deadline: "0"means no execution deadline and cannot use deadline-based expiry release. Keep unresolved reservations under reconciliation or manual review.- Use direct chain reads as a fallback if the API is unavailable and the brand needs independent confirmation.