From cf310278a7665d740ecc7a9740980d413ccf9a0a Mon Sep 17 00:00:00 2001 From: MantisClone Date: Mon, 31 Aug 2026 23:11:41 +0200 Subject: [PATCH] chore: streamline webhook reconciliation guide --- use-cases/webhook-reconciliation.mdx | 159 +++++++++++++-------------- 1 file changed, 79 insertions(+), 80 deletions(-) diff --git a/use-cases/webhook-reconciliation.mdx b/use-cases/webhook-reconciliation.mdx index 3cd3dd9..5a70361 100644 --- a/use-cases/webhook-reconciliation.mdx +++ b/use-cases/webhook-reconciliation.mdx @@ -1,51 +1,36 @@ --- title: "Webhook reconciliation" -description: "Real-time payment notifications wired into your accounting, fulfillment, and order systems. The reliable way to detect payments without polling." +description: "Verify signed Request Network webhook deliveries and safely update your application without polling." --- ## What you'll build -A webhook handler that receives signed payment events from Request Network, verifies the signature, and triggers your downstream systems — order fulfillment, invoice closeout, accounting entries, customer email. Polling-free, idempotent, retry-safe. +A webhook handler that receives signed Request Network events, verifies the signature, and triggers your downstream systems — order fulfillment, invoice closeout, accounting entries, and customer email. It is idempotent and safe to retry. **Audience:** any backend integrating Request Network where payment events drive state changes downstream. -## The 10 events +## Choose the events to handle -| Category | Event | When it fires | -| --- | --- | --- | -| Payment (core) | `payment.confirmed` | Payment fully settled on-chain | -| | `payment.partial` | Partial payment received, more expected | -| | `payment.failed` | Payment execution failed (recurring, cross-chain) | -| | `payment.refunded` | Payment refunded to payer | -| Processing | `payment.processing` | Crypto-to-fiat offramp in progress (with detailed `subStatus`) | -| Request | `request.recurring` | A new recurring billing cycle fired | -| Compliance | `compliance.updated` | KYC or agreement status changed | -| Bank details | `payment_detail.updated` | Bank account verification status changed | -| Secure Payment Page | `secure_payment.user_event` | Payer progressed through a step of the Secure Payment Page (`userEvent`: `wallet_connected`, `payment_sent_to_wallet`, `payment_approved_in_wallet`) — funnel telemetry, **not** a settlement signal | -| Secure Payment | `secure_payment.access_rejected` | A wallet not on a payer-wallet allowlist tried to access or pay the payment | - -`secure_payment.user_event` is best-effort browser telemetry, and the stream is intentionally incomplete: the payer's browser can fail to reach the API, and retries begin only once the API has accepted the event. A missing event is not evidence that the payer skipped the step, so do not drive drop-off, notification, or reconciliation logic off its absence. - -For the full payload schemas, see the [Webhooks reference](/api-reference/webhooks). +See the [Webhooks reference](/api-reference/webhooks) for the current event catalog, recipient routing, payload examples, and [legacy integrations](/api-reference/webhooks#legacy-integrations). Use it to choose which events your handler needs; this guide focuses on processing each delivery safely. ## Setup - - Complete steps 1–3 of the [Quickstart](/use-cases/quickstart). Note your `clientId`. + + To receive events as a platform, note its `clientId`. To receive events as an orchestrator, use the key assigned to that orchestrator. - - `POST https://auth.request.network/v1/webhook` with header `x-client-id: ` and body `{ "url": "https://yourapp.com/webhooks/request-network" }`. - - Save the returned `secret` immediately — it's only shown once. + + Follow [platform Client ID webhook setup](/api-reference/webhooks#register-a-platform-client-id-webhook) or [orchestrator webhook setup](/api-reference/webhooks#orchestrator-webhooks). Save the signing secret immediately; Request Network returns it only once. - Fire a test event from the [auth API docs](https://auth.request.network/open-api/#tag/webhook/POST/v1/webhook/test) with body `{ "eventType": "payment.confirmed" }`. The request will arrive with header `x-request-network-test: true`. + Send a test delivery with the relevant platform or orchestrator endpoint in the [Webhooks reference](/api-reference/webhooks). Test deliveries include `x-request-network-test: true` and placeholder data. +This example covers one endpoint registration, either for a platform's Client ID or for an orchestrator. Set `WEBHOOK_SECRET` to the signing secret from that registration. + ## Handler — reference implementation A signature-verifying Express handler. It verifies against the **raw** body, uses constant-time comparison, passes the delivery ID to business handlers as their idempotency key, and lets Request Network retry a failed handler. @@ -55,7 +40,25 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const app = express(); -const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!; + +const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; + +if (!WEBHOOK_SECRET) { + throw new Error("WEBHOOK_SECRET is required"); +} + +function signatureMatches(rawBody: Buffer, signature: string, secret: string) { + const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); + const signatureBuffer = Buffer.from(signature, "hex"); + const expectedBuffer = Buffer.from(expected, "hex"); + const hasExpectedLength = signatureBuffer.length === expectedBuffer.length; + const comparableSignature = hasExpectedLength + ? signatureBuffer + : Buffer.alloc(expectedBuffer.length); + + const isMatch = timingSafeEqual(comparableSignature, expectedBuffer); + return hasExpectedLength && isMatch; +} app.post( "/webhooks/request-network", @@ -68,26 +71,25 @@ app.post( return res.status(400).send("missing headers"); } - // 1. Verify signature against the RAW body — never re-stringify - const expected = createHmac("sha256", WEBHOOK_SECRET) - .update(req.body) - .digest("hex"); + // 1. Verify against the RAW body — never re-stringify. + const hasValidSignature = signatureMatches(req.body, signature, WEBHOOK_SECRET); - const sigBuf = Buffer.from(signature, "hex"); - const expBuf = Buffer.from(expected, "hex"); - - if ( - sigBuf.length !== expBuf.length || - !timingSafeEqual(sigBuf, expBuf) - ) { + if (!hasValidSignature) { return res.status(401).send("invalid signature"); } - // 2. Parse and route. Each business operation uses deliveryId as an - // idempotency key in its own durable store. - const event = JSON.parse(req.body.toString("utf8")); + // 2. A verified test delivery uses placeholder data. Acknowledge it + // before it can change application state. + if (req.headers["x-request-network-test"] === "true") { + console.info("received verified Request Network test webhook"); + return res.status(200).send("ok"); + } try { + // 3. Parse and route. Each business operation uses deliveryId as an + // idempotency key in its own durable store. + const event = JSON.parse(req.body.toString("utf8")); + console.info({ deliveryId, event: event.event }); await handleEvent(event, deliveryId); return res.status(200).send("ok"); } catch (err) { @@ -99,36 +101,41 @@ app.post( async function handleEvent(event: any, deliveryId: string) { switch (event.event) { + // Hosted onboarding, delivered to an orchestrator endpoint. + case "client_id.linked": + await recordLinkedPlatform( + event.clientId, + event.linkId, + event.intentId, + deliveryId, + ); + break; + case "payment.confirmed": await markOrderPaid(event.requestId, event.txHash, deliveryId); break; - case "payment.partial": - await recordPartialPayment( + case "payment.failed": + await flagFailedPayment( event.requestId, - event.amount, - event.totalAmountPaid, + event.subStatus, deliveryId, ); break; - case "payment.failed": - await flagFailedPayment(event.requestId, deliveryId); - break; - - case "request.recurring": - await onRecurringInvoice(event.originalRequestId, event.requestId, deliveryId); - break; - - case "compliance.updated": - await syncKycStatus(event.clientUserId, event.kycStatus, deliveryId); + case "kyt.screening.completed": + await recordKytResult( + event.paymentToken, + event.status, + event.provider, + deliveryId, + ); break; - // Payer-funnel telemetry from the Secure Payment Page. Never reconcile - // money off this — a payer can approve in their wallet and still have the - // transaction fail on-chain. Wait for payment.confirmed for settlement. + // Payer activity from the Secure Payment Page. Do not use it as a + // settlement signal; use payment.confirmed instead. case "secure_payment.user_event": - await recordFunnelStep( + await recordPayerActivity( event.securePaymentToken, event.userEvent, deliveryId, @@ -145,32 +152,28 @@ async function handleEvent(event: any, deliveryId: string) { ); break; - // ... others + default: + // Acknowledge events this handler does not use so they do not retry. + console.info({ deliveryId, event: event.event }, "ignoring event"); } } ``` Webhook delivery is at least once, not exactly once. Each business operation must atomically record the delivery ID with the state it changes, then make a repeat delivery a successful no-op. If an operation calls another service, pass the delivery ID as that service's idempotency key too. A process can fail after a side effect but before it returns `200`. -## Headers reference +## Headers used by this handler + +This example uses the following headers. See the [Webhooks reference](/api-reference/webhooks#request-headers) for the complete header contract. -| Header | Description | +| Header | Purpose in this handler | | --- | --- | | `x-request-network-signature` | HMAC-SHA256 of the raw JSON body, hex-encoded | | `x-request-network-delivery` | ULID — use as idempotency key | -| `x-request-network-retry-count` | `0`–`3`, current retry attempt | -| `x-request-network-test` | `true` only for `/v1/webhook/test` deliveries | +| `x-request-network-test` | `true` only for test deliveries | -## Retry policy +## Retry behavior -| Attempt | Delay | Cumulative time | -| --- | --- | --- | -| 0 (initial) | — | t=0 | -| 1 | 1s | t+1s | -| 2 | 5s | t+6s | -| 3 | 15s | t+21s | - -After 4 total attempts (initial + 3 retries) the delivery is dropped. Triggers: any non-2xx response, timeout, connection error. Default request timeout is 5s. +Request Network retries a non-2xx response, timeout, or connection error. Return `2xx` only after the event has been durably processed. See the [retry policy](/api-reference/webhooks#retry-logic) for the current attempt schedule and timeout. ## Common patterns @@ -206,9 +209,9 @@ async function markOrderPaid( } ``` -### Routing by Client ID +### Route events by Client ID -If your platform has many merchants, give each their own Client ID. The webhook payload includes `clientId` so you can route events to the right tenant. +If you are an orchestrator working with several linked platforms, use `clientId` to identify the platform for an event. Store that Client ID with your own platform record when you link it. ### Slack alerts on failure @@ -223,17 +226,13 @@ case "payment.failed": break; ``` -### Crypto-to-fiat status tracking - -The `payment.processing` event includes a `subStatus` field that progresses through `initiated → pending_internal_assessment → ongoing_checks → sending_fiat → fiat_sent`. Surface this in your UI so the payee sees real-time offramp progress. - ## Local development Use [ngrok](https://ngrok.com) to expose localhost during development: ```bash ngrok http 3000 -# Pass the https://xxxxx.ngrok-free.app URL to POST /v1/webhook (above) +# Pass the https://xxxxx.ngrok-free.app URL when you register your endpoint ``` Local URLs (`localhost`, `127.0.0.1`) are accepted by the auth API for testing. HTTPS is required in production.