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.

FieldTypeRequiredMeaning
X-Loyfin-EventstringyesOne of loyfin.issued, loyfin.redeemed, loyfin.expired, or loyfin.rejected.
X-Loyfin-Delivery-IdstringyesStable delivery identifier. Store it as an idempotency key before applying business changes.
X-Loyfin-TimestampUnix secondsyesIncluded in the signed payload. Reject stale timestamps; five minutes is a practical default.
X-Loyfin-Signaturev1=<hex HMAC>yesHMAC-SHA-256 over timestamp + '.' + the exact raw request body.
Verify a deliverytypescript
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.idempotencyKey as an additional event-level deduplication key.

Polling#

  • After POST /issuances or POST /redemptions, save the returned session id, operation id, nonce, and operationHash.
  • Poll GET /operations/:id until status is mined, rejected, cancelled, or expired, or until your own timeout is reached.
  • For completed rows, fetch GET /issuances?operationHash=... or GET /redemptions?operationHash=... and store the txHash, blockNumber, and logIndex.
  • For Add from wallet backfills, query GET /redemptions?from=...&token=...&chainId=8453 and 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#

  • pending means Created in product surfaces. It is not final.
  • requires_holder_signature means the checkout session still needs the recipient or holder wallet authorization.
  • submitted means a relayer has sent a transaction, but the brand should still wait for a mined receipt.
  • rejected means the brand should release, retry, or manually review the internal operation based on the reason.
  • cancelled and expired are 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.
Loyfin is built onBase