Webhooks

Signed payment events

Register an HTTPS endpoint on the dashboard's Developers page and GenesisPay POSTs you a signed JSON payload whenever money moves. The signing secret is shown once at creation.

Events

payment.fulfilled is the canonical notification for paid fulfillment. Its data is strict FulfillmentEvidence plus an explicit nullable clientReferenceId; the envelope adds apiVersion: "2026-08-26" and livemode. Amounts remain integer decimal strings. Simulation never emits this event. The name means payment evidence is available, not that your application already delivered goods. Always call authenticated fulfillment.verify with your frozen expected contract.

payment.confirmed — a payment attempt was verified on-chain (fires for every confirmed payment, including each payment against a reusable link). link.paid — a single-use link reached its paid state. product.purchased — a confirmed payment for a product minted its entitlement; the body carries data.product, data.entitlement (with the redemption path and expiry) and data.attempt. Fires at most once per purchase, on the first mint — settlement replays never re-fire it, and a delivery that fails mid-flight is retried by the webhook infrastructure like any other event. Check data.attempt.simulated before fulfilling: simulated purchases deliver this webhook too, marked, so you can test your handler end-to-end — and the same rule applies to the verify endpoint, where simulated matters as much as valid.

Delivery body
{
  "id": "evt_payment_confirmed_attempt-uuid",
  "type": "payment.confirmed",
  "createdAt": "2026-07-08T12:00:00.000Z",
  "data": {
    "link": {
      "publicId": "abc123",
      "title": "Market report",
      "asset": "USDC",
      "amount": "5",
      "amountUsdcMinor": "5000000",
      "amountUsdc": "5",
      "linkType": "single",
      "metadata": { "orderId": "A-1042" },
      "clientReferenceId": "order_1042"
    },
    "attempt": {
      "id": "attempt-uuid",
      "txHash": "0x...",
      "chainId": 84532,
      "payerWallet": "0x...",
      "createdAt": "2026-07-08T11:59:40.000Z",
      "confirmedAt": "2026-07-08T12:00:00.000Z",
      "simulated": false
    },
    "occurredAt": "2026-07-08T12:00:00.000Z"
  }
}

data.link.amount is the decimal amount and data.link.asset is the currency it is in — euros on an EURC link, dollars on a USDC one. Read the asset before you book the payment. data.link.amountUsdc is the deprecated alias: it carries the identical value and still ships on every delivery, but on an EURC link its name is wrong, which is why amount replaced it. data.link.amountUsdcMinor keeps its name and its meaning — the integer minor units of that same amount.

data.attempt.chainId is the chain data.attempt.txHash is on. Read the two together: the same hash resolves to nothing on the wrong chain, and without the chain a testnet delivery and a mainnet one are the same payload. It sits on the attempt rather than the link because the two can differ — a settlement broadcast by a provider lands on whatever chain that provider uses.

Today every settlement reports 8453 or 84532 (Base mainnet / Sepolia), but do not hard-code that as the whole set. Match the chain you expect and treat anything else as unrecognized rather than as test money: a bare chainId !== 8453 check inverts on the first chain that is added.

Both are null together when there is nothing to resolve. Beware the polarity trap on an older delivery that predates the field: undefined !== 8453 is true, so a naive if (chainId !== 8453) reads a missing chain as testnet and its mirror reads it as mainnet. Branch on presence first.

Note that amount is normalized, not zero-padded: a 5.00 USDC link delivers "5", not "5.00". Compare on amountUsdcMinor — an exact integer — rather than string-matching amount against your own order total, and never parseFloat it.

data.link always carries the metadata and clientReferenceId you set when creating the link (null when you set neither). Use these as correlation hints, then verify the payment against your saved purchase intent and its publicId mapping before fulfillment. The returnUrl and cancelUrl are deliberately not included: they are for the payer's browser and would only inflate the signed payload.

The correlation echo is exact: non-empty metadata keys and values round-trip unchanged; an empty metadata object is normalized to null; clientReferenceId is trimmed and a blank value becomes null. Both appear on create / retrieve responses and on this webhook payload.

The hosted checkout may also carry a cs query parameter. That is GenesisPay's internal payer checkout-session identity — not a merchant correlation field. Do not read it or rely on it; use metadata and clientReferenceId for correlation instead.

data.attempt.simulated is always present and true whenever no funds moved — an attempt created by the test-mode simulate-payment endpoint. Such a delivery carries txHash: null, because there is no transaction to point at. Branch on the flag rather than on the missing hash — a pending real attempt has no hash either.

It is the fabricated-payment axis, not the environment one: a real settlement on Base Sepolia, or through a provider sandbox, reports simulated: false. Test money is still a real transfer. Use your own API key's environment to tell those apart.

Mandates emit their own events through the same signed mechanism: mandate.active, mandate.charged, mandate.charge_failed, mandate.revoked, subscription.renewed, and subscription.past_due. Their data carries mandate and charge (null for lifecycle events without one) instead of link/attempt; data.mandate.subscriptionPlanId names the plan the mandate was signed on, or is null when it was proposed directly through POST /api/v1/mandates. Full payload on the subscriptions page; if a delivery is missed, the mandates are also readable from GET /api/v1/mandates.

Verify the signature

Every delivery carries a GENESISPAY-SIGNATURE: t=<unix seconds>,v1=<hex> header, where v1 is the HMAC-SHA256 of `${t}.${rawBody}` computed with your endpoint secret. Never trust a payload you have not verified.

On JavaScript and TypeScript, use constructEvent from @genesis-tech/genesispay-seller. It parses the header, enforces a 300-second replay window in both directions, compares the signature in constant time, and only then parses the JSON. It is async because it runs on WebCrypto, so the same code works on Node, Edge runtimes, Cloudflare Workers, and Bun. Every failure throws GenesisPaySignatureVerificationError.

app/api/webhooks/genesispay/route.ts
import {
  constructEvent,
  GenesisPaySignatureVerificationError,
} from "@genesis-tech/genesispay-seller";

export async function POST(request: Request) {
  // Read the RAW body. Never JSON.parse and re-stringify before verifying —
  // that changes the bytes and invalidates the signature.
  const rawBody = await request.text();
  const signature = request.headers.get("genesispay-signature") ?? "";

  let event;
  try {
    event = await constructEvent(
      rawBody,
      signature,
      process.env.GENESISPAY_WEBHOOK_SECRET!,
    );
  } catch (error) {
    if (error instanceof GenesisPaySignatureVerificationError) {
      return new Response(error.message, { status: 400 });
    }
    throw error;
  }

  // Persist a durable inbox row keyed by event.id before returning 2xx.
  // A worker calls fulfillment.verify with your stored expected contract.
  // See the fulfillment example below; signature validity is not authority.

  return new Response(null, { status: 204 });
}

Tune the replay window with constructEvent(rawBody, signature, secret, { toleranceSeconds: 60 }). Multiple v1 values in one header are all checked, so you can rotate an endpoint secret without dropping deliveries.

Verifying without the SDK

Outside JavaScript, reimplement the same four checks: parse the header tolerantly, reject timestamps more than 300 seconds away in either direction, compare in constant time, and accept the delivery if any v1 matches. Verify against the raw request bytes — a framework that hands you a parsed body has already destroyed the signature.

verify-signature.ts (manual)
import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verifyGenesisPaySignature(
  header: string, // GENESISPAY-SIGNATURE header value
  rawBody: string,
  secret: string,
): boolean {
  let timestamp: string | undefined;
  const signatures: string[] = [];

  for (const part of header.split(",")) {
    const index = part.indexOf("=");
    if (index <= 0) return false;
    const key = part.slice(0, index).trim();
    const value = part.slice(index + 1).trim();
    if (key === "t") timestamp = value;
    else if (key === "v1") signatures.push(value);
  }

  if (!timestamp || signatures.length === 0) return false;

  // Reject stale AND future timestamps.
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(skew) || skew > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const expectedBytes = Buffer.from(expected, "hex");

  // Any v1 may match — that is what lets you rotate the secret. Decode as hex,
  // not as UTF-8: timingSafeEqual compares BYTE length, and a header with a
  // multi-byte character would otherwise pass the string-length check and then
  // throw RangeError — turning a 400 into an unhandled 500.
  let matched = false;

  for (const signature of signatures) {
    const candidate = Buffer.from(signature, "hex");
    // No early exit, and no short-circuit that skips later candidates: the work
    // must not depend on which v1 matched. (`some()` stops at the first hit.)
    const isMatch =
      candidate.length === expectedBytes.length &&
      timingSafeEqual(expectedBytes, candidate);
    matched = isMatch || matched;
  }

  return matched;
}

Delivery and retries

Source changes and events commit together in a durable outbox. A minute worker publishes and delivers them. There are at most nine sends per cycle: initial send, then 30 seconds, 2 minutes, 5 minutes, 15 minutes, 1 hour, 6 hours, 12 hours and 24 hours (±10% jitter), with a 72-hour cycle limit. A timeout can consume an attempt even when the receiver committed successfully. Persist a durable inbox before returning 2xx. Failed or exhausted deliveries require explicit audited replay; replay preserves the event ID and body. The normal log view covers 90 days plus older dead letters; history is not deleted.

Idempotency

Your handler will see the same payment more than once. Two independent reasons:

Retries. Up to nine sends per cycle, and an attempt counts as failed whenever we do not read a 2xx in time — so a receiver that fulfils and then times out gets the delivery again.

Multiple event types. A strict confirmed payment emits payment.fulfilled alongside legacy notifications. A single-use payment also emits link.paid; URL products emit product.purchased when the entitlement is minted. These can arrive in any order. Fulfill only through one shared verification and credit transaction.

New event.id values identify a source event and are stable across endpoints, retries and replay. Legacy queued deliveries retain their older IDs. Event deduplication protects inbox processing; economic deduplication must use the verified payment.attemptId and the purchase intent in the same transaction as the credit ledger. A crash between a separate processed marker and credit write loses the purchase. Conflicting identities must stop for investigation.

One transaction for the verified purchase
// Run from a durable inbox worker; these storage helpers belong to your app.
if (event.type !== "payment.fulfilled") return;
const intent = await loadPurchaseIntent(event.data.clientReferenceId);
if (!intent) throw new Error("Purchase correlation requires investigation");
const result = await genesispay.fulfillment.verify({
  locator: { attemptId: event.data.attemptId },
  expected: intent.expected, // frozen before creating the checkout
});
if (!result.verified) throw new Error("Payment not verified; do not credit");
if (!intent.linkId || result.payment.linkId !== intent.linkId) {
  throw new Error("Recover checkout correlation before crediting");
}

await db.transaction(async (tx) => {
  // UNIQUE(attempt_id) across webhook, reconciliation AND browser fallback.
  // Also enforce one credited purchase per intent and reject conflicts.
  const claimed = await claimVerifiedPurchase(tx, intent.id, result.payment.attemptId);
  if (claimed) await grantPurchasedCredits(tx, intent.accountId, intent.credits);
  await markInboxCompleted(tx, event.id);
});

Fulfil on webhooks, not on the return URL

The return URL is not proof of payment

After a confirmed payment the checkout offers a link back to your returnUrl with ?genesispay_link_id=<publicId>&genesispay_status=paid appended. Those parameters are unsigned — anyone who knows a publicId can open that URL without paying. Treat them as a UI hint only.

Release goods or credits only after authenticated fulfillment.verify returns verified: true against your stored expected contract. Tolerant checkout display state and a valid webhook signature do not authorize fulfillment.

Persist a unique purchase intent before checkout creation and use its ID as clientReferenceId and checkout idempotency key. Freeze account, credits and expected payment contract there. Do not derive those values from the webhook. A notification may precede the checkout response; retain it until correlation can complete.

Recover missing notifications with fulfillment.listAttempts({ clientReferenceId: "your-intent-id" }) and verify each candidate by attempt ID. The list supports bounded timestamp windows and opaque keyset cursors. Finish every page, retain the original filters, and repeat scans to catch late commits. An expired or cancelled browser flow can still contain a paid attempt: reconcile it too. Historical candidates without strict evidence need operator attention and never authorize credits. Browser return may call the same verification path, but no worker depends on that return.

The event freezes fee and entitlement state at creation; later fee collection or entitlement minting does not rewrite it. Authenticated verification returns current validity. A missing entitlement can be retried; revoked or conflicting evidence requires explicit handling.