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.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.

Delivery body
{
  "id": "delivery-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": "reusable",
      "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 those can differ — a pay-by-bank settlement mints on whatever chain the provider uses.

A crypto settlement reports 8453 or 84532 (Base mainnet / Sepolia), but do not treat that as the whole set — a pay-by-bank settlement can report Gnosis (100/10200), Polygon or Ethereum. A chainId !== 8453 check would book a real Gnosis settlement as test money.

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), so the delivery alone is enough to map a payment onto your own order — no lookup table keyed by publicId. The returnUrl and cancelUrlare deliberately not included: they are for the payer's browser and would only inflate the signed payload.

data.attempt.simulated is always present and true whenever no funds moved — an attempt created by the test-mode simulate-payment endpoint, or a pay-by-bank settlement running against the provider's mock mode. Either way the 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 PEERPAY-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/peerpay-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 PeerPaySignatureVerificationError.

app/api/webhooks/peerpay/route.ts
import {
  constructEvent,
  PeerPaySignatureVerificationError,
} from "@genesis-tech/peerpay-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("peerpay-signature") ?? "";

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

  if (event.type === "payment.confirmed") {
    // event.data.link, event.data.attempt — already verified.
  }

  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 verifyPeerPaySignature(
  header: string, // PEERPAY-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

Failed deliveries are retried up to 3 times with exponential backoff. The Developers page shows a per-endpoint delivery log with status, attempt count, and the last error — use it to debug your receiver. Respond with a 2xx quickly and do heavy work asynchronously.

Idempotency

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

Retries. Up to 3 attempts per delivery, 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.

Two events per single-use link. One confirmed payment on a single link emits payment.confirmed and link.paid, each as its own delivery, with an identical data.link block. Reusable links only ever emit payment.confirmed, once per confirmed payment. Branch on event.type and pick one event to fulfil on.

event.id is the delivery id: stable across every retry of that delivery, and distinct per event type and per endpoint. Persist it before you act and ignore ids you have already stored — that makes fulfilment exactly-once under retries.

Dedupe on event.id
const event = await constructEvent(rawBody, signature, secret);

// event.id is the delivery id — stable across every retry of that delivery.
// Claim it in the database, not in memory, so concurrent retries race on a
// primary key instead of on your fulfilment:
//   create table processed_webhooks (event_id text primary key, ...);
// markProcessed = INSERT ... ON CONFLICT DO NOTHING, returning whether it inserted.
const isNew = await markProcessed(event.id);

if (!isNew) {
  return new Response(null, { status: 204 }); // already handled
}

if (event.type === "payment.confirmed") {
  await fulfil(event.data);
}

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 ?peerpay_link_id=<publicId>&peerpay_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, credits, or access only after a verified payment.confirmed delivery, or after checkout.retrieve(publicId) reports paid: true. Never on the query string.