From 9fef97167c93e1e3fb581a4a2299f62b92bc5967 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:37:58 +0800 Subject: [PATCH 1/8] docs: add webhooks integration guide Adds payments/webhooks covering event types, payload contract, delivery guarantees (at-least-once, ordering, ~28h retry schedule), signature verification, test delivery, and go-live checklist. Nav item placed directly after Test mode in both doc versions. Co-Authored-By: Claude Fable 5 --- docs.json | 6 +- payments/webhooks.mdx | 273 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 payments/webhooks.mdx diff --git a/docs.json b/docs.json index 4c8612b..ac50230 100644 --- a/docs.json +++ b/docs.json @@ -46,7 +46,8 @@ "payments/fiat-coverage", "payments/wallet-coverage", "payments/cex-coverage", - "payments/test-mode" + "payments/test-mode", + "payments/webhooks" ] }, { @@ -205,7 +206,8 @@ "payments/fiat-coverage", "payments/wallet-coverage", "payments/cex-coverage", - "payments/test-mode" + "payments/test-mode", + "payments/webhooks" ] }, { diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx new file mode 100644 index 0000000..c271a52 --- /dev/null +++ b/payments/webhooks.mdx @@ -0,0 +1,273 @@ +--- +title: "Webhooks" +sidebarTitle: "Webhooks" +description: "Get notified in real time as payments move through their lifecycle — and handle delivery, ordering, and verification correctly." +--- + +Webhooks push payment lifecycle events to an HTTPS endpoint you control, as they happen. Instead of polling for every payment, you register an endpoint once and we `POST` an event to it whenever a payment is created, starts processing, succeeds, fails, expires, is cancelled, or settles. + + +Webhooks are currently rolling out. If you don't see **Webhooks** in your [dashboard](https://merchant.pay.walletconnect.com), [contact us](https://share.hsforms.com/19Dpp4ayYR9uriB3xNAh0JAnxw6s) to get access. + + +## Webhooks vs. polling + +Webhooks tell you *promptly* that something happened. They are not a substitute for the API as the source of truth: + +- **Use webhooks** to react quickly — update order status in your UI, notify a customer, kick off downstream processing. +- **Poll the API for the critical path.** Before you do anything irreversible — releasing goods, crediting a balance, marking an order paid — confirm the payment state with [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status). Treat the webhook as the trigger to go check, not as the authorization itself. + +This matters because webhook delivery is **at-least-once** and **not guaranteed to be in order** (see [Delivery guarantees](#delivery-guarantees)). A well-built integration works correctly even if a webhook arrives twice, late, out of order — or not at all. + +## Events + +Every event is named `payment.`: + +| Event type | Sent when | +|---|---| +| `payment.created` | A payment was created. | +| `payment.processing` | A payment started processing — the buyer has committed funds. | +| `payment.succeeded` | A payment succeeded. | +| `payment.failed` | A payment failed. | +| `payment.expired` | A payment expired before completion. | +| `payment.cancelled` | A payment was cancelled. | +| `payment.settled` | Merchant settlement completed for a succeeded payment. | + +```mermaid actions={false} +flowchart LR + C["payment.created"] --> P["payment.processing"] + P --> S["payment.succeeded"] --> T["payment.settled"] + P --> F["payment.failed"] + P --> E["payment.expired"] + C --> E + C --> X["payment.cancelled"] +``` + +When you register an endpoint you choose which event types it receives — all seven by default. + +## The event payload + +Every event carries the same envelope, and `data` is always a **full snapshot of the payment at the moment the event occurred** — never a delta. You never need a previous event to interpret the current one. + +| Field | Description | +|---|---| +| `id` | Unique event ID (`evt_…`). **Your deduplication key** — the same event is always delivered with the same `id`. | +| `type` | The event type, e.g. `payment.succeeded`. | +| `api_version` | The payload schema version (e.g. `2026-05-18`). Within a version, changes are additive only. | +| `created_at` | When the event occurred (ISO 8601, UTC) — not when it was delivered. | +| `data` | The full payment snapshot at event time. | + +The fields inside `data` you'll use most: + +| Field | Description | +|---|---| +| `payment_id` / `merchant_id` | The same identifiers you use across the Merchant API. | +| `reference_id` | Your own order identifier, as passed when creating the payment (or `null`). | +| `status` | The payment status at event time: `requires_action`, `processing`, `succeeded`, `failed`, `expired`, or `cancelled`. | +| `payment_state_version` | Monotonically increasing integer per payment. **Your ordering key** — see [Out-of-order delivery](#out-of-order-delivery). | +| `live` | `false` for test-mode payments and test deliveries. **Check `live === true` before driving real fulfillment.** | +| `amount` | The requested amount: `{ "unit": "iso4217/USD", "value": "1000" }` — `value` is an integer string in the smallest unit. | +| `processing`, `success`, `failed`, `cancelled`, `expired`, `settled` | Stage detail blocks — `null` until the payment reaches that stage. `success` and `settled` include the transaction hash (`tx_id`). | + +A `payment.succeeded` event looks like this: + +```json +{ + "id": "evt_01HZX4V9K3TQ2M8R6W0B5YD7FN", + "type": "payment.succeeded", + "api_version": "2026-05-18", + "created_at": "2026-06-30T10:00:00Z", + "data": { + "payment_id": "pay_01HZX4V9K3T", + "merchant_id": "mrch_7kBz2qR9xPvLmN4Yw", + "live": true, + "payment_state_version": 2, + "reference_id": "ORDER-1042", + "status": "succeeded", + "amount": { "unit": "iso4217/USD", "value": "1000" }, + "created_at": "2026-06-30T09:59:00Z", + "expires_at": "2026-06-30T10:14:00Z", + "processing": { + "processing_at": "2026-06-30T09:59:10Z", + "option_amount": { + "unit": "caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "value": "10000000" + }, + "settlement_amount": { + "unit": "caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "value": "9710000" + }, + "buyer_caip10": "eip155:8453:0x000000000000000000000000000000000000dEaD", + "chain_caip2": "eip155:8453" + }, + "success": { + "succeeded_at": "2026-06-30T10:00:00Z", + "tx_id": "0x8f1e5c1b9a4a1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c" + }, + "failed": null, + "cancelled": null, + "expired": null, + "settled": null + } +} +``` + + +**Parse tolerantly.** New fields are added to the payload over time without a version bump — that's the additive-only contract. Ignore fields you don't recognize; never reject an event because it contains something new. Strict schema validation that fails on unknown fields **will** break your integration. + + +## Delivery guarantees + +Design your handler around three properties of webhook delivery. Getting these right up front is the difference between an integration that works in a demo and one that works in production. + +### At-least-once delivery + +Every event is delivered *at least* once — which means occasionally more than once. Deduplicate on the event `id`: record each `id` you've processed (with a TTL of a few days), and acknowledge-but-skip any event whose `id` you've already seen. + +### Out-of-order delivery + +Events for the same payment can arrive out of order — a `payment.succeeded` may land before the `payment.processing` that preceded it, especially when retries are involved. Two rules keep you safe: + +1. **Compare `payment_state_version`.** It increases with every state change of a payment. Keep the highest version you've processed per `payment_id`, and ignore any event with a version less than or equal to it — it's stale. +2. **Poll for the critical path.** Since each event is a full snapshot, a late event never corrupts your state if you follow rule 1 — but for irreversible actions, confirm with [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status) rather than acting on the webhook alone. + +### Retries + +An attempt counts as delivered only when your endpoint responds with a **2xx** status. Any other response — including redirects, which are not followed — or a timeout counts as a failure, and the delivery is retried with increasing back-off: + +| Attempt | Time after previous attempt | +|---|---| +| 1 | immediate | +| 2 | 5 seconds | +| 3 | 5 minutes | +| 4 | 30 minutes | +| 5 | 2 hours | +| 6 | 5 hours | +| 7 | 10 hours | +| 8 | 10 hours | + +That's roughly **28 hours** of automatic retries. After the final attempt the delivery is marked failed and is **not retried automatically** — recovering from a longer outage is manual. Check your endpoint's delivery history in the dashboard, and reconcile missed payments by fetching their current state from the API. + + +Acknowledge first, process later. Return `200` as soon as you've verified the signature and durably queued the event — don't make the webhook response wait on your business logic. Slow responses time out, count as failed attempts, and cause redundant redeliveries. + + +## Set up your endpoint + + + + + +1. In the [dashboard](https://merchant.pay.walletconnect.com), make sure the **Test / Live** selector matches the mode you're setting up — endpoints are configured separately per mode. +2. Open **Webhooks** and add your endpoint: a publicly reachable **HTTPS** URL, and the event types you want (all seven by default). +3. Copy the **signing secret** (`whsec_…`) and store it in a secret manager. You'll use it to verify every delivery. + +You can reveal the secret again later, and rotate it from the same page — after a rotation, the old secret stays valid for 24 hours so you can roll over without dropping deliveries. + + +You can register one webhook endpoint per mode. Test and live endpoints are fully separate, each with its own signing secret. + + + + + + +Every delivery is signed so you can confirm it came from WalletConnect Pay and wasn't tampered with. The signature arrives in three headers — `svix-id`, `svix-timestamp`, and `svix-signature` — following the [Svix](https://docs.svix.com/receiving/verifying-payloads/how) webhook signature scheme, so you can verify with any Svix library: + + + +```ts Node.js +import { Webhook } from "svix"; +import express from "express"; + +const app = express(); +const wh = new Webhook(process.env.WCP_WEBHOOK_SECRET); // whsec_… + +app.post( + "/webhooks/walletconnect-pay", + // verification needs the raw body — don't JSON-parse it first + express.raw({ type: "application/json" }), + (req, res) => { + let event; + try { + event = wh.verify(req.body, { + "svix-id": req.header("svix-id"), + "svix-timestamp": req.header("svix-timestamp"), + "svix-signature": req.header("svix-signature"), + }); + } catch { + return res.status(400).send("invalid signature"); + } + + // Acknowledge immediately; process the event asynchronously. + res.status(200).end(); + enqueue(event); + } +); +``` + +```python Python +from svix.webhooks import Webhook, WebhookVerificationError + +wh = Webhook(WCP_WEBHOOK_SECRET) # whsec_… + +@app.post("/webhooks/walletconnect-pay") +async def handle_webhook(request: Request): + payload = await request.body() # raw bytes — don't parse first + try: + event = wh.verify(payload, request.headers) + except WebhookVerificationError: + return Response(status_code=400) + + # Acknowledge immediately; process the event asynchronously. + enqueue(event) + return Response(status_code=200) +``` + + + +Two things trip people up: + +- **Verify the raw request body**, byte for byte. Parsing and re-serializing the JSON changes the bytes and the verification fails. +- The timestamp header is checked against the current time to prevent replay attacks, so make sure your server clock is accurate. + +Reject anything that fails verification with a `400` — never process an unverified payload. If you'd rather verify manually (HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{body}`), see the [signature scheme reference](https://docs.svix.com/receiving/verifying-payloads/how-manual). + + + + + +Two ways to see events hit your endpoint before any real payment exists: + +- **Send a test event.** In the dashboard's **Webhooks** page, send a test event of any type to your endpoint. Test events carry sample data with `live: false` — use them to check signature verification and your 2xx response. +- **Run the full flow in [test mode](/payments/test-mode).** Create a test payment and drive it through its lifecycle with the Test Console or the dashboard's simulation actions. Each transition emits the real webhook — `payment.created` through `payment.succeeded` (or `failed`, `expired`, `cancelled`) — so you can verify your handler against every path, including the ones that are hard to produce on demand with real payments. + +The dashboard shows each endpoint's recent deliveries with response status and timing, which is the first place to look when something doesn't arrive. + + +During local development, expose your handler with a tunnel (e.g. `ngrok`, `cloudflared`) and register the tunnel URL as your test-mode endpoint. + + + + + + +## Go live + +Webhook configuration does **not** carry over from test to live — they are separate environments end to end. When you switch: + +1. Flip the dashboard to **Live** and register your production endpoint URL and event types again. +2. Store the **live signing secret** — it's different from your test secret — and make sure your handler uses the secret matching the environment. +3. Confirm your handler checks `data.live === true` before triggering real fulfillment. Test events and test-mode payments always carry `live: false`. +4. Re-verify the critical path: dedupe by `id`, ordering by `payment_state_version`, and a status poll before anything irreversible. + +## Next steps + + + + Simulate every payment status transition and watch the webhooks arrive. + + + The polling endpoint to confirm state on the critical path. + + From a8d5388128054d7dc38d1f0ebc48597196db32f0 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Tue, 21 Jul 2026 21:42:28 +0800 Subject: [PATCH 2/8] chore: trigger Mintlify preview deployment Co-Authored-By: Claude Fable 5 From c99075fbb11c30daad312baaf9731b6a9c1f3c38 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:44:42 +0800 Subject: [PATCH 3/8] docs(webhooks): apply review feedback - Remove nav entry; page is accessible by direct URL only - Remove rollout disclaimer - Trim page description - Reframe webhooks vs. polling: webhooks notify, polling decides; relax poll interval (e.g. 5s) and confirm state on webhook receipt - Replace all em-dashes with natural connectors Co-Authored-By: Claude Fable 5 --- docs.json | 6 ++-- payments/webhooks.mdx | 65 ++++++++++++++++++++----------------------- 2 files changed, 32 insertions(+), 39 deletions(-) diff --git a/docs.json b/docs.json index ac50230..4c8612b 100644 --- a/docs.json +++ b/docs.json @@ -46,8 +46,7 @@ "payments/fiat-coverage", "payments/wallet-coverage", "payments/cex-coverage", - "payments/test-mode", - "payments/webhooks" + "payments/test-mode" ] }, { @@ -206,8 +205,7 @@ "payments/fiat-coverage", "payments/wallet-coverage", "payments/cex-coverage", - "payments/test-mode", - "payments/webhooks" + "payments/test-mode" ] }, { diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index c271a52..e6b1f96 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -1,23 +1,18 @@ --- title: "Webhooks" sidebarTitle: "Webhooks" -description: "Get notified in real time as payments move through their lifecycle — and handle delivery, ordering, and verification correctly." +description: "Get notified in real time as payments move through their lifecycle." --- Webhooks push payment lifecycle events to an HTTPS endpoint you control, as they happen. Instead of polling for every payment, you register an endpoint once and we `POST` an event to it whenever a payment is created, starts processing, succeeds, fails, expires, is cancelled, or settles. - -Webhooks are currently rolling out. If you don't see **Webhooks** in your [dashboard](https://merchant.pay.walletconnect.com), [contact us](https://share.hsforms.com/19Dpp4ayYR9uriB3xNAh0JAnxw6s) to get access. - - ## Webhooks vs. polling -Webhooks tell you *promptly* that something happened. They are not a substitute for the API as the source of truth: +Webhooks are for **notifications**. They tell you promptly that something happened, so you can react quickly: update the order status in your UI, notify a customer, kick off downstream processing. -- **Use webhooks** to react quickly — update order status in your UI, notify a customer, kick off downstream processing. -- **Poll the API for the critical path.** Before you do anything irreversible — releasing goods, crediting a balance, marking an order paid — confirm the payment state with [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status). Treat the webhook as the trigger to go check, not as the authorization itself. +On the **critical path, always poll**. Before you do anything irreversible, such as releasing goods, crediting a balance, or marking an order paid, confirm the payment state with [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status). Webhooks let you relax the polling schedule (for example, to every 5 seconds instead of something more aggressive), and when a webhook event arrives, confirm it right away by fetching the payment state. Treat the webhook as the trigger to go check, not as the authorization itself. -This matters because webhook delivery is **at-least-once** and **not guaranteed to be in order** (see [Delivery guarantees](#delivery-guarantees)). A well-built integration works correctly even if a webhook arrives twice, late, out of order — or not at all. +This matters because webhook delivery is **at-least-once** and **not guaranteed to be in order** (see [Delivery guarantees](#delivery-guarantees)). A well-built integration works correctly even if a webhook arrives twice, late, out of order, or not at all. ## Events @@ -26,7 +21,7 @@ Every event is named `payment.`: | Event type | Sent when | |---|---| | `payment.created` | A payment was created. | -| `payment.processing` | A payment started processing — the buyer has committed funds. | +| `payment.processing` | A payment started processing: the buyer has committed funds. | | `payment.succeeded` | A payment succeeded. | | `payment.failed` | A payment failed. | | `payment.expired` | A payment expired before completion. | @@ -43,18 +38,18 @@ flowchart LR C --> X["payment.cancelled"] ``` -When you register an endpoint you choose which event types it receives — all seven by default. +When you register an endpoint you choose which event types it receives; by default it receives all seven. ## The event payload -Every event carries the same envelope, and `data` is always a **full snapshot of the payment at the moment the event occurred** — never a delta. You never need a previous event to interpret the current one. +Every event carries the same envelope, and `data` is always a **full snapshot of the payment at the moment the event occurred**, never a delta. You never need a previous event to interpret the current one. | Field | Description | |---|---| -| `id` | Unique event ID (`evt_…`). **Your deduplication key** — the same event is always delivered with the same `id`. | +| `id` | Unique event ID (`evt_…`). **Your deduplication key**: the same event is always delivered with the same `id`. | | `type` | The event type, e.g. `payment.succeeded`. | | `api_version` | The payload schema version (e.g. `2026-05-18`). Within a version, changes are additive only. | -| `created_at` | When the event occurred (ISO 8601, UTC) — not when it was delivered. | +| `created_at` | When the event occurred (ISO 8601, UTC), not when it was delivered. | | `data` | The full payment snapshot at event time. | The fields inside `data` you'll use most: @@ -64,10 +59,10 @@ The fields inside `data` you'll use most: | `payment_id` / `merchant_id` | The same identifiers you use across the Merchant API. | | `reference_id` | Your own order identifier, as passed when creating the payment (or `null`). | | `status` | The payment status at event time: `requires_action`, `processing`, `succeeded`, `failed`, `expired`, or `cancelled`. | -| `payment_state_version` | Monotonically increasing integer per payment. **Your ordering key** — see [Out-of-order delivery](#out-of-order-delivery). | +| `payment_state_version` | Monotonically increasing integer per payment. **Your ordering key**; see [Out-of-order delivery](#out-of-order-delivery). | | `live` | `false` for test-mode payments and test deliveries. **Check `live === true` before driving real fulfillment.** | -| `amount` | The requested amount: `{ "unit": "iso4217/USD", "value": "1000" }` — `value` is an integer string in the smallest unit. | -| `processing`, `success`, `failed`, `cancelled`, `expired`, `settled` | Stage detail blocks — `null` until the payment reaches that stage. `success` and `settled` include the transaction hash (`tx_id`). | +| `amount` | The requested amount: `{ "unit": "iso4217/USD", "value": "1000" }`, where `value` is an integer string in the smallest unit. | +| `processing`, `success`, `failed`, `cancelled`, `expired`, `settled` | Stage detail blocks, `null` until the payment reaches that stage. `success` and `settled` include the transaction hash (`tx_id`). | A `payment.succeeded` event looks like this: @@ -113,7 +108,7 @@ A `payment.succeeded` event looks like this: ``` -**Parse tolerantly.** New fields are added to the payload over time without a version bump — that's the additive-only contract. Ignore fields you don't recognize; never reject an event because it contains something new. Strict schema validation that fails on unknown fields **will** break your integration. +**Parse tolerantly.** New fields are added to the payload over time without a version bump; that is the additive-only contract. Ignore fields you don't recognize, and never reject an event because it contains something new. Strict schema validation that fails on unknown fields **will** break your integration. ## Delivery guarantees @@ -122,18 +117,18 @@ Design your handler around three properties of webhook delivery. Getting these r ### At-least-once delivery -Every event is delivered *at least* once — which means occasionally more than once. Deduplicate on the event `id`: record each `id` you've processed (with a TTL of a few days), and acknowledge-but-skip any event whose `id` you've already seen. +Every event is delivered *at least* once, which means occasionally more than once. Deduplicate on the event `id`: record each `id` you've processed (with a TTL of a few days), and acknowledge but skip any event whose `id` you've already seen. ### Out-of-order delivery -Events for the same payment can arrive out of order — a `payment.succeeded` may land before the `payment.processing` that preceded it, especially when retries are involved. Two rules keep you safe: +Events for the same payment can arrive out of order. A `payment.succeeded` may land before the `payment.processing` that preceded it, especially when retries are involved. Two rules keep you safe: -1. **Compare `payment_state_version`.** It increases with every state change of a payment. Keep the highest version you've processed per `payment_id`, and ignore any event with a version less than or equal to it — it's stale. -2. **Poll for the critical path.** Since each event is a full snapshot, a late event never corrupts your state if you follow rule 1 — but for irreversible actions, confirm with [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status) rather than acting on the webhook alone. +1. **Compare `payment_state_version`.** It increases with every state change of a payment. Keep the highest version you've processed per `payment_id`, and ignore any event with a version less than or equal to it, because it is stale. +2. **Poll for the critical path.** Since each event is a full snapshot, a late event never corrupts your state if you follow rule 1. For irreversible actions, though, confirm with [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status) rather than acting on the webhook alone. ### Retries -An attempt counts as delivered only when your endpoint responds with a **2xx** status. Any other response — including redirects, which are not followed — or a timeout counts as a failure, and the delivery is retried with increasing back-off: +An attempt counts as delivered only when your endpoint responds with a **2xx** status. Any other response, including redirects (which are not followed), or a timeout counts as a failure, and the delivery is retried with increasing back-off: | Attempt | Time after previous attempt | |---|---| @@ -146,10 +141,10 @@ An attempt counts as delivered only when your endpoint responds with a **2xx** s | 7 | 10 hours | | 8 | 10 hours | -That's roughly **28 hours** of automatic retries. After the final attempt the delivery is marked failed and is **not retried automatically** — recovering from a longer outage is manual. Check your endpoint's delivery history in the dashboard, and reconcile missed payments by fetching their current state from the API. +That's roughly **28 hours** of automatic retries. After the final attempt the delivery is marked failed and is **not retried automatically**, so recovering from a longer outage is manual. Check your endpoint's delivery history in the dashboard, and reconcile missed payments by fetching their current state from the API. -Acknowledge first, process later. Return `200` as soon as you've verified the signature and durably queued the event — don't make the webhook response wait on your business logic. Slow responses time out, count as failed attempts, and cause redundant redeliveries. +Acknowledge first, process later. Return `200` as soon as you've verified the signature and durably queued the event, and don't make the webhook response wait on your business logic. Slow responses time out, count as failed attempts, and cause redundant redeliveries. ## Set up your endpoint @@ -158,11 +153,11 @@ Acknowledge first, process later. Return `200` as soon as you've verified the si -1. In the [dashboard](https://merchant.pay.walletconnect.com), make sure the **Test / Live** selector matches the mode you're setting up — endpoints are configured separately per mode. +1. In the [dashboard](https://merchant.pay.walletconnect.com), make sure the **Test / Live** selector matches the mode you're setting up, since endpoints are configured separately per mode. 2. Open **Webhooks** and add your endpoint: a publicly reachable **HTTPS** URL, and the event types you want (all seven by default). 3. Copy the **signing secret** (`whsec_…`) and store it in a secret manager. You'll use it to verify every delivery. -You can reveal the secret again later, and rotate it from the same page — after a rotation, the old secret stays valid for 24 hours so you can roll over without dropping deliveries. +You can reveal the secret again later, and rotate it from the same page. After a rotation, the old secret stays valid for 24 hours so you can roll over without dropping deliveries. You can register one webhook endpoint per mode. Test and live endpoints are fully separate, each with its own signing secret. @@ -172,7 +167,7 @@ You can register one webhook endpoint per mode. Test and live endpoints are full -Every delivery is signed so you can confirm it came from WalletConnect Pay and wasn't tampered with. The signature arrives in three headers — `svix-id`, `svix-timestamp`, and `svix-signature` — following the [Svix](https://docs.svix.com/receiving/verifying-payloads/how) webhook signature scheme, so you can verify with any Svix library: +Every delivery is signed so you can confirm it came from WalletConnect Pay and wasn't tampered with. The signature arrives in three headers (`svix-id`, `svix-timestamp`, and `svix-signature`) and follows the [Svix](https://docs.svix.com/receiving/verifying-payloads/how) webhook signature scheme, so you can verify with any Svix library: @@ -185,7 +180,7 @@ const wh = new Webhook(process.env.WCP_WEBHOOK_SECRET); // whsec_… app.post( "/webhooks/walletconnect-pay", - // verification needs the raw body — don't JSON-parse it first + // verification needs the raw body; don't JSON-parse it first express.raw({ type: "application/json" }), (req, res) => { let event; @@ -213,7 +208,7 @@ wh = Webhook(WCP_WEBHOOK_SECRET) # whsec_… @app.post("/webhooks/walletconnect-pay") async def handle_webhook(request: Request): - payload = await request.body() # raw bytes — don't parse first + payload = await request.body() # raw bytes; don't parse first try: event = wh.verify(payload, request.headers) except WebhookVerificationError: @@ -231,7 +226,7 @@ Two things trip people up: - **Verify the raw request body**, byte for byte. Parsing and re-serializing the JSON changes the bytes and the verification fails. - The timestamp header is checked against the current time to prevent replay attacks, so make sure your server clock is accurate. -Reject anything that fails verification with a `400` — never process an unverified payload. If you'd rather verify manually (HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{body}`), see the [signature scheme reference](https://docs.svix.com/receiving/verifying-payloads/how-manual). +Reject anything that fails verification with a `400`, and never process an unverified payload. If you'd rather verify manually (HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{body}`), see the [signature scheme reference](https://docs.svix.com/receiving/verifying-payloads/how-manual). @@ -239,8 +234,8 @@ Reject anything that fails verification with a `400` — never process an unveri Two ways to see events hit your endpoint before any real payment exists: -- **Send a test event.** In the dashboard's **Webhooks** page, send a test event of any type to your endpoint. Test events carry sample data with `live: false` — use them to check signature verification and your 2xx response. -- **Run the full flow in [test mode](/payments/test-mode).** Create a test payment and drive it through its lifecycle with the Test Console or the dashboard's simulation actions. Each transition emits the real webhook — `payment.created` through `payment.succeeded` (or `failed`, `expired`, `cancelled`) — so you can verify your handler against every path, including the ones that are hard to produce on demand with real payments. +- **Send a test event.** In the dashboard's **Webhooks** page, send a test event of any type to your endpoint. Test events carry sample data with `live: false`; use them to check signature verification and your 2xx response. +- **Run the full flow in [test mode](/payments/test-mode).** Create a test payment and drive it through its lifecycle with the Test Console or the dashboard's simulation actions. Each transition emits the real webhook, from `payment.created` through `payment.succeeded` (or `failed`, `expired`, `cancelled`), so you can verify your handler against every path, including the ones that are hard to produce on demand with real payments. The dashboard shows each endpoint's recent deliveries with response status and timing, which is the first place to look when something doesn't arrive. @@ -254,10 +249,10 @@ During local development, expose your handler with a tunnel (e.g. `ngrok`, `clou ## Go live -Webhook configuration does **not** carry over from test to live — they are separate environments end to end. When you switch: +Webhook configuration does **not** carry over from test to live; they are separate environments end to end. When you switch: 1. Flip the dashboard to **Live** and register your production endpoint URL and event types again. -2. Store the **live signing secret** — it's different from your test secret — and make sure your handler uses the secret matching the environment. +2. Store the **live signing secret**, which is different from your test secret, and make sure your handler uses the secret matching the environment. 3. Confirm your handler checks `data.live === true` before triggering real fulfillment. Test events and test-mode payments always carry `live: false`. 4. Re-verify the critical path: dedupe by `id`, ordering by `payment_state_version`, and a status poll before anything irreversible. From 86d6d5943694c6b202ff232c0f2a184500213421 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:51:20 +0800 Subject: [PATCH 4/8] docs(webhooks): stdlib signature verification + agent prompt Replace Svix-library examples with dependency-free stdlib implementations (Node.js, Python, Go, Java, C#, PHP, Ruby), document the HMAC scheme explicitly, and add a copyable agent prompt with the Svix test vector for one-shot implementation. Co-Authored-By: Claude Fable 5 --- payments/webhooks.mdx | 353 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 310 insertions(+), 43 deletions(-) diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index e6b1f96..31b92a1 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -167,66 +167,333 @@ You can register one webhook endpoint per mode. Test and live endpoints are full -Every delivery is signed so you can confirm it came from WalletConnect Pay and wasn't tampered with. The signature arrives in three headers (`svix-id`, `svix-timestamp`, and `svix-signature`) and follows the [Svix](https://docs.svix.com/receiving/verifying-payloads/how) webhook signature scheme, so you can verify with any Svix library: +Every delivery is signed so you can confirm it came from WalletConnect Pay and wasn't tampered with. The signature is a **standard HMAC-SHA256**, so you don't need any SDK to verify it. It arrives in three headers on every delivery: + +| Header | Contents | +|---|---| +| `svix-id` | The delivery's message ID. | +| `svix-timestamp` | Unix timestamp (seconds) of the attempt. | +| `svix-signature` | Space-separated list of signatures, each formatted `v1,`. There can be more than one, for example during the 24-hour secret rotation overlap. | + +Verification is four steps: + +1. **Derive the key.** Take your signing secret, strip the `whsec_` prefix, and base64-decode the rest. The decoded bytes are the HMAC key (using the string itself is the most common mistake). +2. **Compute the digest.** HMAC-SHA256 over the UTF-8 bytes of `{svix-id}.{svix-timestamp}.{raw body}`, then base64-encode it. +3. **Compare.** The delivery is authentic if your digest equals any `v1` entry in `svix-signature`, using a constant-time comparison. +4. **Check the timestamp.** Reject deliveries whose `svix-timestamp` is more than 5 minutes from the current time, to prevent replay attacks. + +Dependency-free implementations, standard library only: ```ts Node.js -import { Webhook } from "svix"; -import express from "express"; - -const app = express(); -const wh = new Webhook(process.env.WCP_WEBHOOK_SECRET); // whsec_… - -app.post( - "/webhooks/walletconnect-pay", - // verification needs the raw body; don't JSON-parse it first - express.raw({ type: "application/json" }), - (req, res) => { - let event; - try { - event = wh.verify(req.body, { - "svix-id": req.header("svix-id"), - "svix-timestamp": req.header("svix-timestamp"), - "svix-signature": req.header("svix-signature"), - }); - } catch { - return res.status(400).send("invalid signature"); +import { createHmac, timingSafeEqual } from "node:crypto"; + +const TOLERANCE_S = 5 * 60; + +export function verifyWebhook(secret: string, headers: Record, rawBody: string) { + const id = headers["svix-id"]; + const timestamp = headers["svix-timestamp"]; + const signatures = headers["svix-signature"]; + if (!id || !timestamp || !signatures) throw new Error("missing signature headers"); + + const skew = Math.abs(Date.now() / 1000 - Number(timestamp)); + if (!Number.isFinite(skew) || skew > TOLERANCE_S) throw new Error("timestamp outside tolerance"); + + const key = Buffer.from(secret.slice("whsec_".length), "base64"); + const expected = createHmac("sha256", key) + .update(`${id}.${timestamp}.${rawBody}`) + .digest("base64"); + + const ok = signatures.split(" ").some((entry) => { + const [version, sig] = entry.split(","); + return ( + version === "v1" && + sig?.length === expected.length && + timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) + ); + }); + if (!ok) throw new Error("no matching signature"); + return JSON.parse(rawBody); +} +``` + +```python Python +import base64 +import hashlib +import hmac +import json +import time + +TOLERANCE_S = 5 * 60 + +def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> dict: + msg_id = headers.get("svix-id") + timestamp = headers.get("svix-timestamp") + signatures = headers.get("svix-signature") + if not (msg_id and timestamp and signatures): + raise ValueError("missing signature headers") + + if abs(time.time() - float(timestamp)) > TOLERANCE_S: + raise ValueError("timestamp outside tolerance") + + key = base64.b64decode(secret.removeprefix("whsec_")) + signed = f"{msg_id}.{timestamp}.".encode() + raw_body + expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() + + for entry in signatures.split(" "): + version, _, sig = entry.partition(",") + if version == "v1" and hmac.compare_digest(sig, expected): + return json.loads(raw_body) + raise ValueError("no matching signature") +``` + +```go Go +package webhooks + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "math" + "net/http" + "strconv" + "strings" + "time" +) + +const toleranceSeconds = 5 * 60 + +func VerifyWebhook(secret string, headers http.Header, rawBody []byte) error { + id := headers.Get("svix-id") + timestamp := headers.Get("svix-timestamp") + signatures := headers.Get("svix-signature") + if id == "" || timestamp == "" || signatures == "" { + return errors.New("missing signature headers") + } + + ts, err := strconv.ParseFloat(timestamp, 64) + if err != nil || math.Abs(float64(time.Now().Unix())-ts) > toleranceSeconds { + return errors.New("timestamp outside tolerance") + } + + key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_")) + if err != nil { + return err + } + + mac := hmac.New(sha256.New, key) + fmt.Fprintf(mac, "%s.%s.", id, timestamp) + mac.Write(rawBody) + expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + for _, entry := range strings.Split(signatures, " ") { + version, sig, found := strings.Cut(entry, ",") + if found && version == "v1" && hmac.Equal([]byte(sig), []byte(expected)) { + return nil + } + } + return errors.New("no matching signature") +} +``` + +```java Java +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +public final class WebhookVerifier { + private static final long TOLERANCE_S = 5 * 60; + + public static void verify(String secret, String id, String timestamp, + String signatures, byte[] rawBody) throws Exception { + long skew = Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)); + if (skew > TOLERANCE_S) throw new SecurityException("timestamp outside tolerance"); + + byte[] key = Base64.getDecoder().decode(secret.substring("whsec_".length())); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8)); + byte[] expected = Base64.getEncoder().encode(mac.doFinal(rawBody)); + + for (String entry : signatures.split(" ")) { + String[] parts = entry.split(",", 2); + if (parts.length == 2 && parts[0].equals("v1") + && MessageDigest.isEqual(parts[1].getBytes(StandardCharsets.UTF_8), expected)) { + return; + } + } + throw new SecurityException("no matching signature"); } +} +``` - // Acknowledge immediately; process the event asynchronously. - res.status(200).end(); - enqueue(event); - } -); +```csharp C# +using System.Linq; +using System.Security.Cryptography; +using System.Text; + +public static class WebhookVerifier +{ + private const long ToleranceSeconds = 5 * 60; + + public static void Verify(string secret, string id, string timestamp, + string signatures, byte[] rawBody) + { + var skew = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(timestamp)); + if (skew > ToleranceSeconds) + throw new InvalidOperationException("timestamp outside tolerance"); + + var key = Convert.FromBase64String(secret["whsec_".Length..]); + using var hmac = new HMACSHA256(key); + var signed = Encoding.UTF8.GetBytes($"{id}.{timestamp}.").Concat(rawBody).ToArray(); + var expected = Encoding.UTF8.GetBytes(Convert.ToBase64String(hmac.ComputeHash(signed))); + + foreach (var entry in signatures.Split(' ')) + { + var parts = entry.Split(',', 2); + if (parts.Length == 2 && parts[0] == "v1" && + CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(parts[1]), expected)) + return; + } + throw new InvalidOperationException("no matching signature"); + } +} ``` -```python Python -from svix.webhooks import Webhook, WebhookVerificationError +```php PHP + TOLERANCE_S) { + throw new RuntimeException('timestamp outside tolerance'); + } -@app.post("/webhooks/walletconnect-pay") -async def handle_webhook(request: Request): - payload = await request.body() # raw bytes; don't parse first - try: - event = wh.verify(payload, request.headers) - except WebhookVerificationError: - return Response(status_code=400) + $key = base64_decode(substr($secret, strlen('whsec_')), true); + $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true)); - # Acknowledge immediately; process the event asynchronously. - enqueue(event) - return Response(status_code=200) + foreach (explode(' ', $signatures) as $entry) { + [$version, $sig] = array_pad(explode(',', $entry, 2), 2, ''); + if ($version === 'v1' && hash_equals($expected, $sig)) { + return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); + } + } + throw new RuntimeException('no matching signature'); +} +``` + +```ruby Ruby +require "base64" +require "json" +require "openssl" + +TOLERANCE_S = 5 * 60 + +def verify_webhook(secret, headers, raw_body) + id = headers["svix-id"] + timestamp = headers["svix-timestamp"] + signatures = headers["svix-signature"] + raise "missing signature headers" unless id && timestamp && signatures + + raise "timestamp outside tolerance" if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_S + + key = Base64.decode64(secret.delete_prefix("whsec_")) + digest = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{raw_body}") + expected = Base64.strict_encode64(digest) + + ok = signatures.split(" ").any? do |entry| + version, sig = entry.split(",", 2) + version == "v1" && sig && OpenSSL.secure_compare(sig, expected) + end + raise "no matching signature" unless ok + + JSON.parse(raw_body) +end ``` Two things trip people up: -- **Verify the raw request body**, byte for byte. Parsing and re-serializing the JSON changes the bytes and the verification fails. -- The timestamp header is checked against the current time to prevent replay attacks, so make sure your server clock is accurate. - -Reject anything that fails verification with a `400`, and never process an unverified payload. If you'd rather verify manually (HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{body}`), see the [signature scheme reference](https://docs.svix.com/receiving/verifying-payloads/how-manual). +- **Verify the raw request body**, byte for byte, before any body-parsing middleware runs. Parsing and re-serializing the JSON changes the bytes (even a whitespace difference) and the verification fails. +- The timestamp check depends on your server clock being accurate. + +Reject anything that fails verification with a `400`, and never process an unverified payload. If you already use a [Svix library](https://docs.svix.com/receiving/verifying-payloads/how), its `verify` function performs exactly these checks and is equally fine. + +### Or let your coding agent do it + +The prompt below contains the complete spec plus a test vector, so an AI coding agent (Claude Code, Cursor, Codex, and the like) can implement and verify the handler in one shot. Copy it as-is: + +````text Prompt: implement WalletConnect Pay webhook verification +Implement a WalletConnect Pay webhook handler in this codebase, using the +project's existing language and web framework. + +Requirements: + +1. Add a POST route for webhook deliveries (use /webhooks/walletconnect-pay + unless this project has its own convention). The handler must read the RAW + request body bytes before any JSON body-parsing middleware touches it. + +2. Verify the signature of every delivery before doing anything else: + - Read headers: svix-id, svix-timestamp, svix-signature. + - The signing secret comes from the environment variable WCP_WEBHOOK_SECRET + and looks like "whsec_". Base64-decode the part after "whsec_" to + get the HMAC key bytes. Do NOT use the secret string directly as the key. + - Compute HMAC-SHA256 over the UTF-8 bytes of: + ".." + and base64-encode the digest. + - svix-signature contains space-separated entries of the form + "v1," (there can be several during secret rotation). + Verification succeeds if ANY v1 entry equals the expected digest, using a + constant-time comparison. + - Reject the delivery if |now - svix-timestamp| > 5 minutes + (svix-timestamp is unix seconds). + - On any verification failure, respond 400 and do not process the payload. + +3. On success, respond 200 immediately and hand the parsed event off for + asynchronous processing. Do not run business logic before responding. + +4. Process events idempotently and in order: + - Deduplicate on the top-level "id" field (format "evt_..."). + - Drop stale events: track the highest data.payment_state_version seen per + data.payment_id and ignore events with a version <= that. + +5. Parse the payload tolerantly: ignore unknown fields, never fail on new + fields. Event types are payment.created, payment.processing, + payment.succeeded, payment.failed, payment.expired, payment.cancelled, + payment.settled. + +6. Use only the standard library for the crypto. Do not add a dependency. + +Verify the implementation against this test vector (skip the timestamp +tolerance check for the vector only, since the timestamp is in the past): + - secret: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw + - svix-id: msg_p5jXN8AQM9LWM0D4loKWxJek + - svix-timestamp: 1614265330 + - raw body (exact bytes, including the space): + {"test": 2432232314} + - expected svix-signature entry: + v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= + +Write unit tests covering: the vector above verifies successfully; a wrong +signature is rejected; an altered body is rejected; a svix-signature header +with multiple entries where only the second matches is accepted; a timestamp +outside the 5-minute tolerance is rejected. +```` From 8beab8847a32663bd6447c5c56a9662c46727c84 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:58:26 +0800 Subject: [PATCH 5/8] docs(webhooks): collapse agent prompt into a copy button The full prompt no longer renders on the page; it lives in copy-agent-prompt.js (Mintlify injects content-directory .js files on every page) and is copied to the clipboard by a Copy prompt button via event delegation. Co-Authored-By: Claude Fable 5 --- copy-agent-prompt.js | 110 ++++++++++++++++++++++++++++++++++++++++++ payments/webhooks.mdx | 75 +++++++--------------------- 2 files changed, 127 insertions(+), 58 deletions(-) create mode 100644 copy-agent-prompt.js diff --git a/copy-agent-prompt.js b/copy-agent-prompt.js new file mode 100644 index 0000000..a1f15ca --- /dev/null +++ b/copy-agent-prompt.js @@ -0,0 +1,110 @@ +// Powers the "Copy prompt" button on /payments/webhooks. +// Mintlify injects every .js file in the content directory into each page, +// so this uses event delegation and binds only once. +(function () { + if (window.__wcpCopyPromptBound) return; + window.__wcpCopyPromptBound = true; + + var PROMPT = [ + "Implement a WalletConnect Pay webhook handler in this codebase, using the", + "project's existing language and web framework.", + "", + "Requirements:", + "", + "1. Add a POST route for webhook deliveries (use /webhooks/walletconnect-pay", + " unless this project has its own convention). The handler must read the RAW", + " request body bytes before any JSON body-parsing middleware touches it.", + "", + "2. Verify the signature of every delivery before doing anything else:", + " - Read headers: svix-id, svix-timestamp, svix-signature.", + " - The signing secret comes from the environment variable WCP_WEBHOOK_SECRET", + " and looks like \"whsec_\". Base64-decode the part after \"whsec_\" to", + " get the HMAC key bytes. Do NOT use the secret string directly as the key.", + " - Compute HMAC-SHA256 over the UTF-8 bytes of:", + " \"..\"", + " and base64-encode the digest.", + " - svix-signature contains space-separated entries of the form", + " \"v1,\" (there can be several during secret rotation).", + " Verification succeeds if ANY v1 entry equals the expected digest, using a", + " constant-time comparison.", + " - Reject the delivery if |now - svix-timestamp| > 5 minutes", + " (svix-timestamp is unix seconds).", + " - On any verification failure, respond 400 and do not process the payload.", + "", + "3. On success, respond 200 immediately and hand the parsed event off for", + " asynchronous processing. Do not run business logic before responding.", + "", + "4. Process events idempotently and in order:", + " - Deduplicate on the top-level \"id\" field (format \"evt_...\").", + " - Drop stale events: track the highest data.payment_state_version seen per", + " data.payment_id and ignore events with a version <= that.", + "", + "5. Parse the payload tolerantly: ignore unknown fields, never fail on new", + " fields. Event types are payment.created, payment.processing,", + " payment.succeeded, payment.failed, payment.expired, payment.cancelled,", + " payment.settled.", + "", + "6. Use only the standard library for the crypto. Do not add a dependency.", + "", + "Verify the implementation against this test vector (skip the timestamp", + "tolerance check for the vector only, since the timestamp is in the past):", + " - secret: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", + " - svix-id: msg_p5jXN8AQM9LWM0D4loKWxJek", + " - svix-timestamp: 1614265330", + " - raw body (exact bytes, including the space):", + " {\"test\": 2432232314}", + " - expected svix-signature entry:", + " v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=", + "", + "Write unit tests covering: the vector above verifies successfully; a wrong", + "signature is rejected; an altered body is rejected; a svix-signature header", + "with multiple entries where only the second matches is accepted; a timestamp", + "outside the 5-minute tolerance is rejected.", + ].join("\n"); + + function setLabel(btn, text, revertMs) { + var original = btn.getAttribute("data-original-label") || btn.textContent; + btn.setAttribute("data-original-label", original); + btn.textContent = text; + setTimeout(function () { + btn.textContent = original; + }, revertMs); + } + + function copyFallback(text) { + var area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "absolute"; + area.style.left = "-9999px"; + document.body.appendChild(area); + area.select(); + var ok = false; + try { + ok = document.execCommand("copy"); + } catch (e) { + ok = false; + } + document.body.removeChild(area); + return ok; + } + + document.addEventListener("click", function (event) { + var btn = event.target && event.target.closest("[data-wcp-copy-prompt]"); + if (!btn) return; + event.preventDefault(); + + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(PROMPT).then( + function () { + setLabel(btn, "Copied!", 2000); + }, + function () { + setLabel(btn, copyFallback(PROMPT) ? "Copied!" : "Copy failed", 2000); + } + ); + } else { + setLabel(btn, copyFallback(PROMPT) ? "Copied!" : "Copy failed", 2000); + } + }); +})(); diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index 31b92a1..e6f28c5 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -436,64 +436,23 @@ Reject anything that fails verification with a `400`, and never process an unver ### Or let your coding agent do it -The prompt below contains the complete spec plus a test vector, so an AI coding agent (Claude Code, Cursor, Codex, and the like) can implement and verify the handler in one shot. Copy it as-is: - -````text Prompt: implement WalletConnect Pay webhook verification -Implement a WalletConnect Pay webhook handler in this codebase, using the -project's existing language and web framework. - -Requirements: - -1. Add a POST route for webhook deliveries (use /webhooks/walletconnect-pay - unless this project has its own convention). The handler must read the RAW - request body bytes before any JSON body-parsing middleware touches it. - -2. Verify the signature of every delivery before doing anything else: - - Read headers: svix-id, svix-timestamp, svix-signature. - - The signing secret comes from the environment variable WCP_WEBHOOK_SECRET - and looks like "whsec_". Base64-decode the part after "whsec_" to - get the HMAC key bytes. Do NOT use the secret string directly as the key. - - Compute HMAC-SHA256 over the UTF-8 bytes of: - ".." - and base64-encode the digest. - - svix-signature contains space-separated entries of the form - "v1," (there can be several during secret rotation). - Verification succeeds if ANY v1 entry equals the expected digest, using a - constant-time comparison. - - Reject the delivery if |now - svix-timestamp| > 5 minutes - (svix-timestamp is unix seconds). - - On any verification failure, respond 400 and do not process the payload. - -3. On success, respond 200 immediately and hand the parsed event off for - asynchronous processing. Do not run business logic before responding. - -4. Process events idempotently and in order: - - Deduplicate on the top-level "id" field (format "evt_..."). - - Drop stale events: track the highest data.payment_state_version seen per - data.payment_id and ignore events with a version <= that. - -5. Parse the payload tolerantly: ignore unknown fields, never fail on new - fields. Event types are payment.created, payment.processing, - payment.succeeded, payment.failed, payment.expired, payment.cancelled, - payment.settled. - -6. Use only the standard library for the crypto. Do not add a dependency. - -Verify the implementation against this test vector (skip the timestamp -tolerance check for the vector only, since the timestamp is in the past): - - secret: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw - - svix-id: msg_p5jXN8AQM9LWM0D4loKWxJek - - svix-timestamp: 1614265330 - - raw body (exact bytes, including the space): - {"test": 2432232314} - - expected svix-signature entry: - v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= - -Write unit tests covering: the vector above verifies successfully; a wrong -signature is rejected; an altered body is rejected; a svix-signature header -with multiple entries where only the second matches is accepted; a timestamp -outside the 5-minute tolerance is rejected. -```` +An AI coding agent (Claude Code, Cursor, Codex, and the like) can implement and verify the handler in one shot. The prompt contains the complete spec plus a test vector to validate the implementation against, so paste it into your agent as-is: + + From 00d18b4396d9e21616c6522c587e0c45b3f85b34 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:05:27 +0800 Subject: [PATCH 6/8] docs(webhooks): document stopping deliveries via endpoint deletion Co-Authored-By: Claude Fable 5 --- payments/webhooks.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index e6f28c5..0678178 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -143,6 +143,8 @@ An attempt counts as delivered only when your endpoint responds with a **2xx** s That's roughly **28 hours** of automatic retries. After the final attempt the delivery is marked failed and is **not retried automatically**, so recovering from a longer outage is manual. Check your endpoint's delivery history in the dashboard, and reconcile missed payments by fetching their current state from the API. +To stop deliveries entirely (for example, when decommissioning an integration), delete the endpoint from the dashboard's **Webhooks** page. Deletion takes effect immediately and cannot be undone. + Acknowledge first, process later. Return `200` as soon as you've verified the signature and durably queued the event, and don't make the webhook response wait on your business logic. Slow responses time out, count as failed attempts, and cause redundant redeliveries. From 3454379f438fb2b051f9338b1d828a5506b6c2a7 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:24:45 +0800 Subject: [PATCH 7/8] docs(webhooks): address review feedback from xikimay - Reframe polling as primary, webhooks as enhancement; no API call needed inside the handler (full versioned snapshots) - Drop the follow-up GET rule from out-of-order section - Use the golden payment-succeeded fixture verbatim (includes fee) - Add fee.kind extensibility and failure/cancellation_reason rules - Add 15s response deadline and 5-day endpoint auto-disable - Recommend a periodic reconciliation job over manual recovery - Add processing->cancelled and created->failed diagram edges - Note payment.settled carries status succeeded - tx_id is chain-specific and nullable - Rename signature headers to white-labeled webhook-* - Agent prompt: persist/enqueue before responding 200 Co-Authored-By: Claude Fable 5 --- copy-agent-prompt.js | 23 ++++----- payments/webhooks.mdx | 111 +++++++++++++++++++++++++++--------------- 2 files changed, 83 insertions(+), 51 deletions(-) diff --git a/copy-agent-prompt.js b/copy-agent-prompt.js index a1f15ca..9b2a5d0 100644 --- a/copy-agent-prompt.js +++ b/copy-agent-prompt.js @@ -16,23 +16,24 @@ " request body bytes before any JSON body-parsing middleware touches it.", "", "2. Verify the signature of every delivery before doing anything else:", - " - Read headers: svix-id, svix-timestamp, svix-signature.", + " - Read headers: webhook-id, webhook-timestamp, webhook-signature.", " - The signing secret comes from the environment variable WCP_WEBHOOK_SECRET", " and looks like \"whsec_\". Base64-decode the part after \"whsec_\" to", " get the HMAC key bytes. Do NOT use the secret string directly as the key.", " - Compute HMAC-SHA256 over the UTF-8 bytes of:", - " \"..\"", + " \"..\"", " and base64-encode the digest.", - " - svix-signature contains space-separated entries of the form", + " - webhook-signature contains space-separated entries of the form", " \"v1,\" (there can be several during secret rotation).", " Verification succeeds if ANY v1 entry equals the expected digest, using a", " constant-time comparison.", - " - Reject the delivery if |now - svix-timestamp| > 5 minutes", - " (svix-timestamp is unix seconds).", + " - Reject the delivery if |now - webhook-timestamp| > 5 minutes", + " (webhook-timestamp is unix seconds).", " - On any verification failure, respond 400 and do not process the payload.", "", - "3. On success, respond 200 immediately and hand the parsed event off for", - " asynchronous processing. Do not run business logic before responding.", + "3. On success, durably persist or enqueue the parsed event, then respond 200.", + " Do not run business logic before responding. A crash between acknowledging", + " and persisting loses the event with no retry, so persist first.", "", "4. Process events idempotently and in order:", " - Deduplicate on the top-level \"id\" field (format \"evt_...\").", @@ -49,15 +50,15 @@ "Verify the implementation against this test vector (skip the timestamp", "tolerance check for the vector only, since the timestamp is in the past):", " - secret: whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", - " - svix-id: msg_p5jXN8AQM9LWM0D4loKWxJek", - " - svix-timestamp: 1614265330", + " - webhook-id: msg_p5jXN8AQM9LWM0D4loKWxJek", + " - webhook-timestamp: 1614265330", " - raw body (exact bytes, including the space):", " {\"test\": 2432232314}", - " - expected svix-signature entry:", + " - expected webhook-signature entry:", " v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=", "", "Write unit tests covering: the vector above verifies successfully; a wrong", - "signature is rejected; an altered body is rejected; a svix-signature header", + "signature is rejected; an altered body is rejected; a webhook-signature header", "with multiple entries where only the second matches is accepted; a timestamp", "outside the 5-minute tolerance is rejected.", ].join("\n"); diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index 0678178..be3173a 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -8,11 +8,11 @@ Webhooks push payment lifecycle events to an HTTPS endpoint you control, as they ## Webhooks vs. polling -Webhooks are for **notifications**. They tell you promptly that something happened, so you can react quickly: update the order status in your UI, notify a customer, kick off downstream processing. +Polling [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status) is the primary way to validate payment status during the purchase cycle: create the payment, then poll until the status is final. Webhooks come on top as an enhancement. They usually tell you first, and with far fewer requests, so you can update the order status in your UI, notify a customer, or kick off downstream processing the moment something happens. -On the **critical path, always poll**. Before you do anything irreversible, such as releasing goods, crediting a balance, or marking an order paid, confirm the payment state with [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status). Webhooks let you relax the polling schedule (for example, to every 5 seconds instead of something more aggressive), and when a webhook event arrives, confirm it right away by fetching the payment state. Treat the webhook as the trigger to go check, not as the authorization itself. +When a webhook event arrives, its payload already contains everything you need. Every event is a full signed snapshot of the payment and carries a monotonic `payment_state_version`, so verifying the signature, deduplicating by `id`, and applying the version guard is enough; there is no need to call the API from inside your handler. -This matters because webhook delivery is **at-least-once** and **not guaranteed to be in order** (see [Delivery guarantees](#delivery-guarantees)). A well-built integration works correctly even if a webhook arrives twice, late, out of order, or not at all. +Webhook delivery is **at-least-once** and **not guaranteed to be in order** (see [Delivery guarantees](#delivery-guarantees)). A well-built integration works correctly even if a webhook arrives twice, late, out of order, or not at all. ## Events @@ -34,12 +34,18 @@ flowchart LR P --> S["payment.succeeded"] --> T["payment.settled"] P --> F["payment.failed"] P --> E["payment.expired"] + P --> X["payment.cancelled"] + C --> F C --> E - C --> X["payment.cancelled"] + C --> X ``` When you register an endpoint you choose which event types it receives; by default it receives all seven. + +`payment.settled` carries `status: "succeeded"`. Settlement is a stage of a succeeded payment, not a seventh status, so don't look for a `settled` value in the `status` field. + + ## The event payload Every event carries the same envelope, and `data` is always a **full snapshot of the payment at the moment the event occurred**, never a delta. You never need a previous event to interpret the current one. @@ -62,24 +68,27 @@ The fields inside `data` you'll use most: | `payment_state_version` | Monotonically increasing integer per payment. **Your ordering key**; see [Out-of-order delivery](#out-of-order-delivery). | | `live` | `false` for test-mode payments and test deliveries. **Check `live === true` before driving real fulfillment.** | | `amount` | The requested amount: `{ "unit": "iso4217/USD", "value": "1000" }`, where `value` is an integer string in the smallest unit. | -| `processing`, `success`, `failed`, `cancelled`, `expired`, `settled` | Stage detail blocks, `null` until the payment reaches that stage. `success` and `settled` include the transaction hash (`tx_id`). | +| `processing`, `success`, `failed`, `cancelled`, `expired`, `settled` | Stage detail blocks, `null` until the payment reaches that stage. `success` and `settled` include `tx_id` (chain-specific transaction id, may be `null`); `failed` and `cancelled` include human-readable `failure_reason` / `cancellation_reason`. | A `payment.succeeded` event looks like this: ```json { - "id": "evt_01HZX4V9K3TQ2M8R6W0B5YD7FN", + "id": "evt_fixture_payment_succeeded", "type": "payment.succeeded", "api_version": "2026-05-18", "created_at": "2026-06-30T10:00:00Z", "data": { - "payment_id": "pay_01HZX4V9K3T", - "merchant_id": "mrch_7kBz2qR9xPvLmN4Yw", + "payment_id": "pay_fixture_01HZX4V9K3T", + "merchant_id": "merchant_fixture_01HZX4V9K3T", "live": true, "payment_state_version": 2, - "reference_id": "ORDER-1042", + "reference_id": "order_fixture_1042", "status": "succeeded", - "amount": { "unit": "iso4217/USD", "value": "1000" }, + "amount": { + "unit": "iso4217/USD", + "value": "1000" + }, "created_at": "2026-06-30T09:59:00Z", "expires_at": "2026-06-30T10:14:00Z", "processing": { @@ -88,6 +97,25 @@ A `payment.succeeded` event looks like this: "unit": "caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "value": "10000000" }, + "fee": { + "kind": "base_plus_percent", + "base_amount": { + "unit": "caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "value": "0" + }, + "percent": { + "numerator": "29", + "denominator": "1000" + }, + "percent_amount": { + "unit": "caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "value": "290000" + }, + "total_amount": { + "unit": "caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "value": "290000" + } + }, "settlement_amount": { "unit": "caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "value": "9710000" @@ -108,7 +136,11 @@ A `payment.succeeded` event looks like this: ``` -**Parse tolerantly.** New fields are added to the payload over time without a version bump; that is the additive-only contract. Ignore fields you don't recognize, and never reject an event because it contains something new. Strict schema validation that fails on unknown fields **will** break your integration. +**Parse tolerantly.** New fields are added to the payload over time without a version bump; that is the additive-only contract. Strict schema validation that fails on unknown fields **will** break your integration. + +- Ignore fields you don't recognize, and never reject an event because it contains something new. +- Don't switch exhaustively on `fee.kind`. The only value today is `base_plus_percent`, and new kinds can be added within this `api_version`. Whatever the kind, `fee.total_amount` is always present. +- Never branch on `failure_reason` or `cancellation_reason`. They are human-readable diagnostics that may change without notice. Display them if useful, and branch on `type` and `status` only. ## Delivery guarantees @@ -121,14 +153,11 @@ Every event is delivered *at least* once, which means occasionally more than onc ### Out-of-order delivery -Events for the same payment can arrive out of order. A `payment.succeeded` may land before the `payment.processing` that preceded it, especially when retries are involved. Two rules keep you safe: - -1. **Compare `payment_state_version`.** It increases with every state change of a payment. Keep the highest version you've processed per `payment_id`, and ignore any event with a version less than or equal to it, because it is stale. -2. **Poll for the critical path.** Since each event is a full snapshot, a late event never corrupts your state if you follow rule 1. For irreversible actions, though, confirm with [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status) rather than acting on the webhook alone. +Events for the same payment can arrive out of order. A `payment.succeeded` may land before the `payment.processing` that preceded it, especially when retries are involved. The version guard keeps you safe: **compare `payment_state_version`**. It increases with every state change of a payment, so keep the highest version you've processed per `payment_id` and ignore any event with a version less than or equal to it, because it is stale. Since every event is a full snapshot, a late event you process in version order can never corrupt your state. ### Retries -An attempt counts as delivered only when your endpoint responds with a **2xx** status. Any other response, including redirects (which are not followed), or a timeout counts as a failure, and the delivery is retried with increasing back-off: +An attempt counts as delivered only when your endpoint responds with a **2xx** status **within 15 seconds**. Any other response, including redirects (which are not followed), or a slower one counts as a failure, and the delivery is retried with increasing back-off: | Attempt | Time after previous attempt | |---|---| @@ -141,12 +170,14 @@ An attempt counts as delivered only when your endpoint responds with a **2xx** s | 7 | 10 hours | | 8 | 10 hours | -That's roughly **28 hours** of automatic retries. After the final attempt the delivery is marked failed and is **not retried automatically**, so recovering from a longer outage is manual. Check your endpoint's delivery history in the dashboard, and reconcile missed payments by fetching their current state from the API. +That's roughly **28 hours** of automatic retries. After the final attempt the delivery is marked failed and is **not retried automatically**. And if every delivery to an endpoint keeps failing for **5 days**, the endpoint is **disabled** and stops receiving events. + +Because a misconfigured URL or a disabled endpoint drops deliveries silently, don't rely on noticing an outage. Run a **periodic reconciliation job**: poll [`GET /v1/payments/{id}/status`](/api-reference/latest/get-v1-payments-id-status) for any payment your records still show in a non-final state after its `expires_at` has passed. Use the endpoint's delivery history in the dashboard to debug what went wrong. To stop deliveries entirely (for example, when decommissioning an integration), delete the endpoint from the dashboard's **Webhooks** page. Deletion takes effect immediately and cannot be undone. -Acknowledge first, process later. Return `200` as soon as you've verified the signature and durably queued the event, and don't make the webhook response wait on your business logic. Slow responses time out, count as failed attempts, and cause redundant redeliveries. +Process later, acknowledge fast. Verify the signature, durably persist or enqueue the event, then return `200`; don't run business logic before responding. Responses slower than 15 seconds count as failed attempts and cause redundant redeliveries. ## Set up your endpoint @@ -173,16 +204,16 @@ Every delivery is signed so you can confirm it came from WalletConnect Pay and w | Header | Contents | |---|---| -| `svix-id` | The delivery's message ID. | -| `svix-timestamp` | Unix timestamp (seconds) of the attempt. | -| `svix-signature` | Space-separated list of signatures, each formatted `v1,`. There can be more than one, for example during the 24-hour secret rotation overlap. | +| `webhook-id` | The delivery's message ID. | +| `webhook-timestamp` | Unix timestamp (seconds) of the attempt. | +| `webhook-signature` | Space-separated list of signatures, each formatted `v1,`. There can be more than one, for example during the 24-hour secret rotation overlap. | Verification is four steps: 1. **Derive the key.** Take your signing secret, strip the `whsec_` prefix, and base64-decode the rest. The decoded bytes are the HMAC key (using the string itself is the most common mistake). -2. **Compute the digest.** HMAC-SHA256 over the UTF-8 bytes of `{svix-id}.{svix-timestamp}.{raw body}`, then base64-encode it. -3. **Compare.** The delivery is authentic if your digest equals any `v1` entry in `svix-signature`, using a constant-time comparison. -4. **Check the timestamp.** Reject deliveries whose `svix-timestamp` is more than 5 minutes from the current time, to prevent replay attacks. +2. **Compute the digest.** HMAC-SHA256 over the UTF-8 bytes of `{webhook-id}.{webhook-timestamp}.{raw body}`, then base64-encode it. +3. **Compare.** The delivery is authentic if your digest equals any `v1` entry in `webhook-signature`, using a constant-time comparison. +4. **Check the timestamp.** Reject deliveries whose `webhook-timestamp` is more than 5 minutes from the current time, to prevent replay attacks. Dependency-free implementations, standard library only: @@ -194,9 +225,9 @@ import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_S = 5 * 60; export function verifyWebhook(secret: string, headers: Record, rawBody: string) { - const id = headers["svix-id"]; - const timestamp = headers["svix-timestamp"]; - const signatures = headers["svix-signature"]; + const id = headers["webhook-id"]; + const timestamp = headers["webhook-timestamp"]; + const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) throw new Error("missing signature headers"); const skew = Math.abs(Date.now() / 1000 - Number(timestamp)); @@ -230,9 +261,9 @@ import time TOLERANCE_S = 5 * 60 def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> dict: - msg_id = headers.get("svix-id") - timestamp = headers.get("svix-timestamp") - signatures = headers.get("svix-signature") + msg_id = headers.get("webhook-id") + timestamp = headers.get("webhook-timestamp") + signatures = headers.get("webhook-signature") if not (msg_id and timestamp and signatures): raise ValueError("missing signature headers") @@ -269,9 +300,9 @@ import ( const toleranceSeconds = 5 * 60 func VerifyWebhook(secret string, headers http.Header, rawBody []byte) error { - id := headers.Get("svix-id") - timestamp := headers.Get("svix-timestamp") - signatures := headers.Get("svix-signature") + id := headers.Get("webhook-id") + timestamp := headers.Get("webhook-timestamp") + signatures := headers.Get("webhook-signature") if id == "" || timestamp == "" || signatures == "" { return errors.New("missing signature headers") } @@ -374,9 +405,9 @@ const TOLERANCE_S = 300; function verify_webhook(string $secret, array $headers, string $rawBody): array { - $id = $headers['svix-id'] ?? ''; - $timestamp = $headers['svix-timestamp'] ?? ''; - $signatures = $headers['svix-signature'] ?? ''; + $id = $headers['webhook-id'] ?? ''; + $timestamp = $headers['webhook-timestamp'] ?? ''; + $signatures = $headers['webhook-signature'] ?? ''; if ($id === '' || $timestamp === '' || $signatures === '') { throw new RuntimeException('missing signature headers'); } @@ -406,9 +437,9 @@ require "openssl" TOLERANCE_S = 5 * 60 def verify_webhook(secret, headers, raw_body) - id = headers["svix-id"] - timestamp = headers["svix-timestamp"] - signatures = headers["svix-signature"] + id = headers["webhook-id"] + timestamp = headers["webhook-timestamp"] + signatures = headers["webhook-signature"] raise "missing signature headers" unless id && timestamp && signatures raise "timestamp outside tolerance" if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE_S @@ -434,7 +465,7 @@ Two things trip people up: - **Verify the raw request body**, byte for byte, before any body-parsing middleware runs. Parsing and re-serializing the JSON changes the bytes (even a whitespace difference) and the verification fails. - The timestamp check depends on your server clock being accurate. -Reject anything that fails verification with a `400`, and never process an unverified payload. If you already use a [Svix library](https://docs.svix.com/receiving/verifying-payloads/how), its `verify` function performs exactly these checks and is equally fine. +Reject anything that fails verification with a `400`, and never process an unverified payload. The scheme is the one implemented by [Svix libraries](https://docs.svix.com/receiving/verifying-payloads/how) (they accept the `webhook-*` header names too), so if you already use one, its `verify` function performs exactly these checks and is equally fine. ### Or let your coding agent do it From 50917125de5edcaad6446ad2f85ae82c5026a4f4 Mon Sep 17 00:00:00 2001 From: Cali93 <32299095+Cali93@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:52:49 +0800 Subject: [PATCH 8/8] docs(webhooks): apply second-round review feedback - Intro frames webhooks as enhancing the integration (riveign) - Agent copy-prompt option now comes before the manual implementations in the verify step (riveign) - Go-live checklist drops the status-poll-before-irreversible leftover (xikimay) Co-Authored-By: Claude Fable 5 --- payments/webhooks.mdx | 50 +++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index be3173a..a8c6cc6 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Webhooks" description: "Get notified in real time as payments move through their lifecycle." --- -Webhooks push payment lifecycle events to an HTTPS endpoint you control, as they happen. Instead of polling for every payment, you register an endpoint once and we `POST` an event to it whenever a payment is created, starts processing, succeeds, fails, expires, is cancelled, or settles. +Webhooks push payment lifecycle events to an HTTPS endpoint you control, as they happen. Enhance your integration by registering an endpoint once: we `POST` an event to it whenever a payment is created, starts processing, succeeds, fails, expires, is cancelled, or settles. ## Webhooks vs. polling @@ -200,7 +200,31 @@ You can register one webhook endpoint per mode. Test and live endpoints are full -Every delivery is signed so you can confirm it came from WalletConnect Pay and wasn't tampered with. The signature is a **standard HMAC-SHA256**, so you don't need any SDK to verify it. It arrives in three headers on every delivery: +Every delivery is signed so you can confirm it came from WalletConnect Pay and wasn't tampered with. The signature is a **standard HMAC-SHA256**, so you don't need any SDK to verify it. + +### Let your coding agent do it + +The fastest path: an AI coding agent (Claude Code, Cursor, Codex, and the like) can implement and verify the handler in one shot. The prompt contains the complete spec plus a test vector to validate the implementation against, so paste it into your agent as-is: + + + +### Or implement it by hand + +The signature arrives in three headers on every delivery: | Header | Contents | |---|---| @@ -467,26 +491,6 @@ Two things trip people up: Reject anything that fails verification with a `400`, and never process an unverified payload. The scheme is the one implemented by [Svix libraries](https://docs.svix.com/receiving/verifying-payloads/how) (they accept the `webhook-*` header names too), so if you already use one, its `verify` function performs exactly these checks and is equally fine. -### Or let your coding agent do it - -An AI coding agent (Claude Code, Cursor, Codex, and the like) can implement and verify the handler in one shot. The prompt contains the complete spec plus a test vector to validate the implementation against, so paste it into your agent as-is: - - - @@ -513,7 +517,7 @@ Webhook configuration does **not** carry over from test to live; they are separa 1. Flip the dashboard to **Live** and register your production endpoint URL and event types again. 2. Store the **live signing secret**, which is different from your test secret, and make sure your handler uses the secret matching the environment. 3. Confirm your handler checks `data.live === true` before triggering real fulfillment. Test events and test-mode payments always carry `live: false`. -4. Re-verify the critical path: dedupe by `id`, ordering by `payment_state_version`, and a status poll before anything irreversible. +4. Re-verify the safety rails: dedupe by `id`, ordering by `payment_state_version`. ## Next steps