diff --git a/api-features/webhooks-events.mdx b/api-features/webhooks-events.mdx
index dea2cef..60aca95 100644
--- a/api-features/webhooks-events.mdx
+++ b/api-features/webhooks-events.mdx
@@ -9,7 +9,7 @@ Webhooks provide real-time notifications when payment and request events occur,
## Event Categories
-The Request Network webhook system emits **12 event types** across four categories:
+The Request Network webhook system emits **13 event types** across five categories:
| Category | Events |
| --- | --- |
@@ -19,6 +19,7 @@ The Request Network webhook system emits **12 event types** across four categori
| **Processing** (crypto-to-fiat) | `payment.processing` (with `subStatus`) |
| **Request** | `request.recurring` |
| **Compliance / Bank** | `compliance.updated`, `payment_detail.updated` |
+| **Secure Payment Page** (payer funnel) | `secure_payment.user_event` (with `userEvent`) |
The `.client_id` and `.checkout` variants are emitted in addition to the base `.confirmed` / `.partial` events when the request was created via a Client ID or as a checkout / secure payment, respectively. They include extra metadata (`clientId`, `origin`).
@@ -67,6 +68,7 @@ graph LR
- **Subscription management:** Handle `request.recurring` for automatic billing renewals
- **Compliance workflows:** Update user permissions when `compliance.updated` shows KYC approval
- **Real-time dashboards:** Display live payment status using `payment.processing` subStatus values
+- **Payer-funnel visibility:** Track wallet connection and signature progress on the Secure Payment Page via `secure_payment.user_event`
## Implementation
diff --git a/api-reference/webhooks.mdx b/api-reference/webhooks.mdx
index b0f630a..7adcb8f 100644
--- a/api-reference/webhooks.mdx
+++ b/api-reference/webhooks.mdx
@@ -104,6 +104,30 @@ Emitted **in addition to** the core events when the request was created via a Se
| `compliance.updated` | KYC or agreement status changed | **kycStatus values:** not_started, pending, approved, rejected, retry_required
**agreementStatus values:** not_started, pending, completed, rejected, failed | Update user permissions, notify status |
| `payment_detail.updated` | Bank account verification status updated | States: approved, failed, pending | Enable fiat payments, update profiles |
+### Secure Payment Page Events (payer funnel)
+
+| Event | Description | Context | Primary Use |
+|-------|-------------|---------|-------------|
+| `secure_payment.user_event` | Payer progressed through a step of the Secure Payment Page | **userEvent values:** wallet_connected, payment_sent_to_wallet, payment_approved_in_wallet | Real-time payer-funnel visibility, drop-off analytics |
+
+Sent to the same registered webhook endpoints as every other event — same Client ID scoping, `x-request-network-signature` HMAC verification, delivery headers, timeout, and 1s/5s/15s retry semantics described elsewhere on this page.
+
+The `userEvent` field distinguishes the 3 funnel steps:
+
+| `userEvent` | Meaning |
+|-------------|---------|
+| `wallet_connected` | The payer successfully connected a wallet on the secure payment page |
+| `payment_sent_to_wallet` | The payment transaction was handed to the payer's wallet for signature |
+| `payment_approved_in_wallet` | The payer approved/signed the payment in their wallet. `properties` includes the submission id (e.g. tx hash / user-operation hash) |
+
+
+`securePaymentToken` is the platform's correlation key, returned when the secure payment was created. `requestId` is present only when exactly one request is linked to the secure payment (see `requestIds` for the full list). `timestamp` is server-stamped on receipt. `occurredAt` and `properties` are **client-reported telemetry from the payer's browser** — useful for analytics, but not authoritative.
+
+`secure_payment.user_event` is best-effort browser telemetry. Navigation, network errors, or browser extensions can prevent the API from receiving it. Webhook retries begin only after the API accepts the event. Do not treat an absent event as evidence that the payer did not take the step; use `payment.confirmed` for settlement and reconciliation.
+
+When the Secure Payment Page includes wallet information in `properties`, it uses `wallet_address_hashed` rather than a raw wallet address.
+
+
## Security Implementation
### Signature Verification
@@ -303,6 +327,27 @@ All payment events include an `explorer` field linking to [Request Scan](https:/
}
```
+### Secure Payment User Event
+```json
+{
+ "event": "secure_payment.user_event",
+ "userEvent": "payment_approved_in_wallet",
+ "securePaymentToken": "spt_3fk29ax7...",
+ "requestId": "01JD3E6JD46KY4KKV7X9V0MZ7W",
+ "requestIds": ["01JD3E6JD46KY4KKV7X9V0MZ7W"],
+ "orchestratorId": "orch_12345",
+ "occurredAt": "2026-08-05T14:03:21.512Z",
+ "timestamp": "2026-08-05T14:03:22.104Z",
+ "properties": {
+ "wallet_provider": "metamask",
+ "payment_submission_id": "0x6a4f...e21b",
+ "payment_submission_id_type": "evm_tx_hash",
+ "selected_source_chain": "base",
+ "payment_type": "single"
+ }
+}
+```
+
## Implementation Examples
For a complete working example, see [Webhook reconciliation](/use-cases/webhook-reconciliation) which implements webhook handling for payment notifications.
diff --git a/use-cases/quickstart.mdx b/use-cases/quickstart.mdx
index a878c37..bc6cac9 100644
--- a/use-cases/quickstart.mdx
+++ b/use-cases/quickstart.mdx
@@ -312,6 +312,7 @@ Key fields to look for:
| `request.recurring` | A recurring request fired |
| `payment_detail.updated` | Payment detail metadata changed |
| `compliance.updated` | Compliance status changed |
+| `secure_payment.user_event` | Payer progressed through a Secure Payment Page step (`userEvent`: `wallet_connected`, `payment_sent_to_wallet`, `payment_approved_in_wallet`) — funnel telemetry, not a settlement signal |
## Step 5: Create a Secure Payment (Payment Link)
diff --git a/use-cases/webhook-reconciliation.mdx b/use-cases/webhook-reconciliation.mdx
index 6fcd9aa..d386869 100644
--- a/use-cases/webhook-reconciliation.mdx
+++ b/use-cases/webhook-reconciliation.mdx
@@ -9,7 +9,7 @@ A webhook handler that receives signed payment events from Request Network, veri
**Audience:** any backend integrating Request Network where payment events drive state changes downstream.
-## The 12 events
+## The 13 events
| Category | Event | When it fires |
| --- | --- | --- |
@@ -25,6 +25,7 @@ A webhook handler that receives signed payment events from Request Network, veri
| 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 |
For the full payload schemas, see the [Webhooks reference](/api-reference/webhooks).
@@ -48,15 +49,13 @@ For the full payload schemas, see the [Webhooks reference](/api-reference/webhoo
## Handler — reference implementation
-A signature-verifying Express handler with idempotency. Hardened for production: verifies against the **raw** body, uses constant-time comparison, deduplicates on `x-request-network-delivery`.
+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.
```typescript
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
-import { Redis } from "ioredis";
const app = express();
-const redis = new Redis(process.env.REDIS_URL!);
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
app.post(
@@ -65,7 +64,6 @@ app.post(
async (req, res) => {
const signature = req.headers["x-request-network-signature"] as string;
const deliveryId = req.headers["x-request-network-delivery"] as string;
- const isTest = req.headers["x-request-network-test"] === "true";
if (!signature || !deliveryId) {
return res.status(400).send("missing headers");
@@ -86,39 +84,26 @@ app.post(
return res.status(401).send("invalid signature");
}
- // 2. Idempotency: dedupe on delivery ID
- const claim = await redis.set(
- `webhook:${deliveryId}`,
- "1",
- "EX",
- 86400, // 24h
- "NX",
- );
- if (!claim && !isTest) {
- return res.status(200).send("duplicate, ignored");
- }
-
- // 3. Parse and route
+ // 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"));
try {
- await handleEvent(event);
+ await handleEvent(event, deliveryId);
+ return res.status(200).send("ok");
} catch (err) {
- // Return non-2xx to trigger retry (1s, 5s, 15s — up to 3 retries)
console.error("handler failed", err);
return res.status(500).send("handler error");
}
-
- res.status(200).send("ok");
},
);
-async function handleEvent(event: any) {
+async function handleEvent(event: any, deliveryId: string) {
switch (event.event) {
case "payment.confirmed":
case "payment.confirmed.client_id":
case "payment.confirmed.checkout":
- await markOrderPaid(event.requestId, event.txHash);
+ await markOrderPaid(event.requestId, event.txHash, deliveryId);
break;
case "payment.partial":
@@ -128,19 +113,31 @@ async function handleEvent(event: any) {
event.requestId,
event.amount,
event.totalAmountPaid,
+ deliveryId,
);
break;
case "payment.failed":
- await flagFailedPayment(event.requestId);
+ await flagFailedPayment(event.requestId, deliveryId);
break;
case "request.recurring":
- await onRecurringInvoice(event.originalRequestId, event.requestId);
+ await onRecurringInvoice(event.originalRequestId, event.requestId, deliveryId);
break;
case "compliance.updated":
- await syncKycStatus(event.clientUserId, event.kycStatus);
+ await syncKycStatus(event.clientUserId, event.kycStatus, 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.
+ case "secure_payment.user_event":
+ await recordFunnelStep(
+ event.securePaymentToken,
+ event.userEvent,
+ deliveryId,
+ );
break;
// ... others
@@ -148,6 +145,8 @@ async function handleEvent(event: any) {
}
```
+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
| Header | Description |
@@ -172,20 +171,40 @@ After 4 total attempts (initial + 3 retries) the delivery is dropped. Triggers:
### Idempotency
-The same `payment.confirmed` event might arrive twice (network blip, retry overlap). Dedupe on `x-request-network-delivery`.
-
-### Routing by Client ID
+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.
-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.
+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:
```typescript
-async function markOrderPaid(requestId: string, txHash: string) {
- const order = await db.orders.findOne({ where: { requestId } });
- if (!order) return; // not ours
- await db.orders.update({ where: { id: order.id }, data: { paidAt: new Date(), txHash } });
+async function markOrderPaid(
+ requestId: string,
+ txHash: string,
+ deliveryId: string,
+) {
+ await db.transaction(async (tx) => {
+ const order = await tx.orders.findOne({ where: { requestId } });
+ if (!order) return; // not ours
+
+ const claim = await tx.execute(
+ `INSERT INTO webhook_deliveries (delivery_id)
+ VALUES ($1)
+ ON CONFLICT (delivery_id) DO NOTHING`,
+ [deliveryId],
+ );
+ if (claim.rowCount === 0) return; // already applied
+
+ await tx.orders.update({
+ where: { id: order.id },
+ data: { paidAt: new Date(), txHash },
+ });
+ });
}
```
+### Routing 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.
+
### Slack alerts on failure
```typescript