Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 79 additions & 80 deletions use-cases/webhook-reconciliation.mdx
Original file line number Diff line number Diff line change
@@ -1,61 +1,64 @@
---
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

<Steps>
<Step title="Get a Client ID">
Complete steps 1–3 of the [Quickstart](/use-cases/quickstart). Note your `clientId`.
<Step title="Choose the endpoint owner">
To receive events as a platform, note its `clientId`. To receive events as an orchestrator, use the key assigned to that orchestrator.
</Step>

<Step title="Register your webhook URL">
`POST https://auth.request.network/v1/webhook` with header `x-client-id: <yours>` and body `{ "url": "https://yourapp.com/webhooks/request-network" }`.

Save the returned `secret` immediately — it's only shown once.
<Step title="Register an endpoint">
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.
</Step>

<Step title="Test delivery">
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.
</Step>
</Steps>

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.

Check warning on line 36 in use-cases/webhook-reconciliation.mdx

View check run for this annotation

Mintlify / Mintlify Validation (requestnetwork) - vale-spellcheck

use-cases/webhook-reconciliation.mdx#L36

Did you really mean 'idempotency'?

```typescript
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",
Expand All @@ -68,26 +71,25 @@
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);
Comment thread
MantisClone marked this conversation as resolved.

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) {
Expand All @@ -99,36 +101,41 @@

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,
Expand All @@ -145,38 +152,34 @@
);
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`.

Check warning on line 162 in use-cases/webhook-reconciliation.mdx

View check run for this annotation

Mintlify / Mintlify Validation (requestnetwork) - vale-spellcheck

use-cases/webhook-reconciliation.mdx#L162

Did you really mean 'idempotency'?

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

Check warning on line 171 in use-cases/webhook-reconciliation.mdx

View check run for this annotation

Mintlify / Mintlify Validation (requestnetwork) - vale-spellcheck

use-cases/webhook-reconciliation.mdx#L171

Did you really mean 'idempotency'?
| `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

### Idempotency

Check warning on line 180 in use-cases/webhook-reconciliation.mdx

View check run for this annotation

Mintlify / Mintlify Validation (requestnetwork) - vale-spellcheck

use-cases/webhook-reconciliation.mdx#L180

Did you really mean 'Idempotency'?

The same `payment.confirmed` event might arrive twice (network blip, retry overlap). Use `x-request-network-delivery` as the idempotency key. Record it atomically with the business update in your durable store; do not use a check-then-act cache lookup, because overlapping deliveries can both pass the check.

Check warning on line 182 in use-cases/webhook-reconciliation.mdx

View check run for this annotation

Mintlify / Mintlify Validation (requestnetwork) - vale-spellcheck

use-cases/webhook-reconciliation.mdx#L182

Did you really mean 'idempotency'?

For a local database update, add a `webhook_deliveries` table with a unique `delivery_id` column, then insert that ID in the same transaction as the business update:

Expand Down Expand Up @@ -206,9 +209,9 @@
}
```

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

Expand All @@ -223,17 +226,13 @@
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.
Expand Down