From 6346ab82598a6ef31c795ad510ff93afcd697b7e Mon Sep 17 00:00:00 2001 From: rodrigopavezi Date: Wed, 5 Aug 2026 21:29:20 -0300 Subject: [PATCH 1/7] docs: document the secure_payment.user_event webhook Documents the new payer-funnel webhook emitted by the Secure Payment Page: the three userEvent values (wallet_connected, payment_sent_to_wallet, payment_approved_in_wallet), a payload example, and the field semantics -- notably that occurredAt and properties are client-reported telemetry and that wallet addresses appear only as hashes. Signature verification, delivery headers and retry behaviour are unchanged, so the existing guidance applies as-is. --- api-features/webhooks-events.mdx | 4 +++- api-reference/webhooks.mdx | 41 ++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) 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..c286d91 100644 --- a/api-reference/webhooks.mdx +++ b/api-reference/webhooks.mdx @@ -104,6 +104,26 @@ 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. `properties` keys mirror the Secure Payment Page's analytics vocabulary, and wallet addresses appear only as sha256 hashes (`wallet_address_hashed`), never in raw form. + + ## Security Implementation ### Signature Verification @@ -303,6 +323,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. From 0ecfd3c61e236ff4525295774931680e17e6c608 Mon Sep 17 00:00:00 2001 From: rodrigopavezi Date: Wed, 5 Aug 2026 22:01:38 -0300 Subject: [PATCH 2/7] docs: add the new event to the reconciliation and quickstart catalogs Both guides present their event lists as complete -- "The 12 events" and "All supported webhook events" -- so leaving them untouched would have told integrators the payer-funnel event does not exist. Adds `secure_payment.user_event` to both tables, bumps the reconciliation heading to 13, and adds a handler case to the reconciliation switch example with an explicit warning not to reconcile money off it: a payer can approve in their wallet and the transaction can still fail on-chain, so payment.confirmed remains the settlement signal. --- use-cases/quickstart.mdx | 1 + use-cases/webhook-reconciliation.mdx | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) 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..e85fbde 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). @@ -143,6 +144,13 @@ async function handleEvent(event: any) { await syncKycStatus(event.clientUserId, event.kycStatus); 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); + break; + // ... others } } From deb6ec0aed6814d7b00cd1c88ee24b7556b026c9 Mon Sep 17 00:00:00 2001 From: MantisClone Date: Fri, 7 Aug 2026 22:36:35 +0200 Subject: [PATCH 3/7] docs: clarify secure payment user event delivery --- api-reference/webhooks.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api-reference/webhooks.mdx b/api-reference/webhooks.mdx index c286d91..7adcb8f 100644 --- a/api-reference/webhooks.mdx +++ b/api-reference/webhooks.mdx @@ -121,7 +121,11 @@ The `userEvent` field distinguishes the 3 funnel steps: | `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. `properties` keys mirror the Secure Payment Page's analytics vocabulary, and wallet addresses appear only as sha256 hashes (`wallet_address_hashed`), never in raw form. +`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 From d1eba480914cfbe4538ed3bf9cd6ba3e295c3da6 Mon Sep 17 00:00:00 2001 From: MantisClone Date: Fri, 7 Aug 2026 22:57:00 +0200 Subject: [PATCH 4/7] docs: allow webhook handler retries after failure --- use-cases/webhook-reconciliation.mdx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/use-cases/webhook-reconciliation.mdx b/use-cases/webhook-reconciliation.mdx index e85fbde..b3a4ea1 100644 --- a/use-cases/webhook-reconciliation.mdx +++ b/use-cases/webhook-reconciliation.mdx @@ -49,7 +49,7 @@ 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 with delivery-ID deduplication. It verifies against the **raw** body, uses constant-time comparison, and lets Request Network retry a failed handler. ```typescript import { createHmac, timingSafeEqual } from "node:crypto"; @@ -105,7 +105,8 @@ app.post( try { await handleEvent(event); } catch (err) { - // Return non-2xx to trigger retry (1s, 5s, 15s — up to 3 retries) + // Release the claim before returning non-2xx so Request Network can retry. + await redis.del(`webhook:${deliveryId}`); console.error("handler failed", err); return res.status(500).send("handler error"); } @@ -156,6 +157,8 @@ async function handleEvent(event: any) { } ``` +Webhook delivery is at least once, not exactly once. The delivery ID prevents ordinary duplicate deliveries, but downstream work must also be idempotent: a process can fail after a side effect but before it returns `200`. + ## Headers reference | Header | Description | From 875264522e298c81f026d0d18c4e7c74b474474c Mon Sep 17 00:00:00 2001 From: MantisClone Date: Fri, 7 Aug 2026 23:02:30 +0200 Subject: [PATCH 5/7] docs: record webhook completion after handling --- use-cases/webhook-reconciliation.mdx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/use-cases/webhook-reconciliation.mdx b/use-cases/webhook-reconciliation.mdx index b3a4ea1..5c51d66 100644 --- a/use-cases/webhook-reconciliation.mdx +++ b/use-cases/webhook-reconciliation.mdx @@ -87,15 +87,9 @@ 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) { + // 2. Idempotency: dedupe deliveries that completed successfully. + const completedKey = `webhook:${deliveryId}`; + if (!isTest && (await redis.exists(completedKey))) { return res.status(200).send("duplicate, ignored"); } @@ -104,14 +98,14 @@ app.post( try { await handleEvent(event); + + // Record completion only after downstream processing succeeds. + await redis.set(completedKey, "1", "EX", 86400); // 24h + return res.status(200).send("ok"); } catch (err) { - // Release the claim before returning non-2xx so Request Network can retry. - await redis.del(`webhook:${deliveryId}`); console.error("handler failed", err); return res.status(500).send("handler error"); } - - res.status(200).send("ok"); }, ); From 3d35b4217f9d7b995f601b2e237dea11e3513295 Mon Sep 17 00:00:00 2001 From: MantisClone Date: Fri, 7 Aug 2026 23:35:58 +0200 Subject: [PATCH 6/7] docs: simplify webhook idempotency guidance --- use-cases/webhook-reconciliation.mdx | 40 ++++++++++++---------------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/use-cases/webhook-reconciliation.mdx b/use-cases/webhook-reconciliation.mdx index 5c51d66..42afc57 100644 --- a/use-cases/webhook-reconciliation.mdx +++ b/use-cases/webhook-reconciliation.mdx @@ -49,15 +49,13 @@ For the full payload schemas, see the [Webhooks reference](/api-reference/webhoo ## Handler — reference implementation -A signature-verifying Express handler with delivery-ID deduplication. It verifies against the **raw** body, uses constant-time comparison, and lets Request Network retry a failed handler. +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( @@ -66,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"); @@ -87,20 +84,12 @@ app.post( return res.status(401).send("invalid signature"); } - // 2. Idempotency: dedupe deliveries that completed successfully. - const completedKey = `webhook:${deliveryId}`; - if (!isTest && (await redis.exists(completedKey))) { - 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); - - // Record completion only after downstream processing succeeds. - await redis.set(completedKey, "1", "EX", 86400); // 24h + await handleEvent(event, deliveryId); return res.status(200).send("ok"); } catch (err) { console.error("handler failed", err); @@ -109,12 +98,12 @@ app.post( }, ); -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": @@ -124,26 +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); + await recordFunnelStep( + event.securePaymentToken, + event.userEvent, + deliveryId, + ); break; // ... others @@ -151,7 +145,7 @@ async function handleEvent(event: any) { } ``` -Webhook delivery is at least once, not exactly once. The delivery ID prevents ordinary duplicate deliveries, but downstream work must also be idempotent: a process can fail after a side effect but before it returns `200`. +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 @@ -177,7 +171,7 @@ 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`. +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. ### Routing by Client ID From 9cd416cbe12f8d1a9286facd2cbaa137af2eaad9 Mon Sep 17 00:00:00 2001 From: MantisClone Date: Fri, 7 Aug 2026 23:39:19 +0200 Subject: [PATCH 7/7] docs: demonstrate atomic webhook idempotency --- use-cases/webhook-reconciliation.mdx | 34 ++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/use-cases/webhook-reconciliation.mdx b/use-cases/webhook-reconciliation.mdx index 42afc57..d386869 100644 --- a/use-cases/webhook-reconciliation.mdx +++ b/use-cases/webhook-reconciliation.mdx @@ -173,18 +173,38 @@ After 4 total attempts (initial + 3 retries) the delivery is dropped. Triggers: 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. -### 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. +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