From 128e8fd87fbcd7d311835d5aba9e5923690ddbf9 Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Fri, 31 Jul 2026 11:04:22 +0200 Subject: [PATCH 1/6] docs(webhooks): version-0 guard, timestamp precision, source IPs, manual resend - Warn that the first event of a payment carries payment_state_version 0, in the out-of-order section, the envelope table, and the agent prompt. A counter initialized to 0 silently drops the first event of every payment. - Note that created_at fractional-second precision varies between events; recommend a full ISO 8601 parser over a fixed format string. - Add the optional source IP allowlist section (EU set, IPv6 included). - Mention manual resend from the dashboard delivery history, shipped since. Co-Authored-By: Claude Fable 5 --- copy-agent-prompt.js | 4 +++- payments/webhooks.mdx | 30 +++++++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/copy-agent-prompt.js b/copy-agent-prompt.js index 9b2a5d0..07822ee 100644 --- a/copy-agent-prompt.js +++ b/copy-agent-prompt.js @@ -38,7 +38,9 @@ "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.", + " data.payment_id and ignore events with a version <= that. The first event", + " of a payment carries version 0, so represent \"no version seen yet\" as an", + " absence (missing row or NULL), never as an initial counter of 0.", "", "5. Parse the payload tolerantly: ignore unknown fields, never fail on new", " fields. Event types are payment.created, payment.processing,", diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index a8c6cc6..41fc3de 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -55,7 +55,7 @@ Every event carries the same envelope, and `data` is always a **full snapshot of | `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. The number of fractional-second digits varies between events, from none up to nanosecond precision, so parse it with a full ISO 8601 parser, not a fixed format string. | | `data` | The full payment snapshot at event time. | The fields inside `data` you'll use most: @@ -65,7 +65,7 @@ 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, starting at `0` on the first event. **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 `tx_id` (chain-specific transaction id, may be `null`); `failed` and `cancelled` include human-readable `failure_reason` / `cancellation_reason`. | @@ -155,6 +155,10 @@ Every event is delivered *at least* once, which means occasionally more than onc 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. + +**The first event of every payment carries `payment_state_version: 0`.** Represent "no version processed yet" as an *absence* (a missing row or `NULL`), never as `0`. If you initialize a version counter to `0` — the natural default for an integer column or a `?? 0` fallback — the `less than or equal` guard silently drops the first event of every payment, and the integration still looks like it works because later events pass. + + ### Retries 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: @@ -170,7 +174,7 @@ 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**. And if every delivery to an endpoint keeps failing for **5 days**, the endpoint is **disabled** and stops receiving events. +That's roughly **28 hours** of automatic retries. After the final attempt the delivery is marked failed and is **not retried automatically**, but you can resend it yourself from the endpoint's delivery history in the dashboard. 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. @@ -510,6 +514,26 @@ During local development, expose your handler with a tunnel (e.g. `ngrok`, `clou +## Allowlist webhook source IPs (optional) + +{/* Source: hosted webhook provider's published EU source-IP list (https://docs.svix.com/webhook-ips.json, `eu` block). Verified 2026-07-27. IPv4 entries unchanged since June 2023; review yearly. */} + +If your webhook endpoint sits behind a firewall, allow inbound HTTPS requests from these addresses. Deliveries always originate from this set. + +- `52.215.16.239` +- `54.216.8.72` +- `63.33.109.123` +- `2a05:d028:17:8000::/56` + +Allowlisting is defense in depth. It does not authenticate a delivery and is not a substitute for signature verification: always verify the signature before accepting an event. + +Two things to get right: + +- **Allow the whole set, not the address you happen to observe first.** Deliveries are sent from a pool and the source address varies between events. Allowing a single observed address will silently block later deliveries. +- **Include the IPv6 range** if your network is dual-stack, otherwise a share of deliveries will be dropped. + +If deliveries stop after you enable the allowlist, or your firewall logs show traffic from an address outside this set, contact WalletConnect support with the UTC time window and any event ID you have. Do not silently drop the traffic. + ## Go live Webhook configuration does **not** carry over from test to live; they are separate environments end to end. When you switch: From 1673a01d2adeb3cb0f04aeafae958e0d264cf264 Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Fri, 31 Jul 2026 11:10:32 +0200 Subject: [PATCH 2/6] docs(webhooks): document the three recovery options for exhausted deliveries Single resend from delivery history, bulk resend of failed deliveries, and replay of never-received events, all shipped in the dashboard. States the 14-day reach of replay operations. Co-Authored-By: Claude Fable 5 --- payments/webhooks.mdx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index 41fc3de..df8609f 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -174,7 +174,13 @@ 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**, but you can resend it yourself from the endpoint's delivery history in the dashboard. And if every delivery to an endpoint keeps failing for **5 days**, the endpoint is **disabled** and stops receiving events. +That's roughly **28 hours** of automatic retries. After the final attempt the delivery is marked failed and is **not retried automatically**, but you can recover it yourself from the dashboard: + +- **Resend one delivery** from the endpoint's delivery history. +- **Bulk-resend every failed delivery** since a point in time. +- **Replay events the endpoint never received**, for example events sent before the endpoint was created. + +Replay operations reach back 14 days at most. 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. From 9e748b4e33cccede6c5b9ca193174743c53e8431 Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Fri, 31 Jul 2026 11:27:39 +0200 Subject: [PATCH 3/6] docs(webhooks): timestamps are one canonical millisecond form The payload builder now normalizes every timestamp to RFC 3339 UTC with millisecond precision, so the variable-precision warning is replaced by the stable statement. Merge after the merchant-api normalization deploys. Co-Authored-By: Claude Fable 5 --- payments/webhooks.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index df8609f..83a4bcc 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -55,7 +55,7 @@ Every event carries the same envelope, and `data` is always a **full snapshot of | `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. The number of fractional-second digits varies between events, from none up to nanosecond precision, so parse it with a full ISO 8601 parser, not a fixed format string. | +| `created_at` | When the event occurred (ISO 8601, UTC, millisecond precision), not when it was delivered. Parse timestamps with a full ISO 8601 parser rather than a fixed format string; the fractional precision is not part of the contract. | | `data` | The full payment snapshot at event time. | The fields inside `data` you'll use most: From 805d81cc6baea54080d7e4713184705154a0fef1 Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Fri, 31 Jul 2026 12:21:43 +0200 Subject: [PATCH 4/6] docs(webhooks): drop processing to cancelled arrow from lifecycle diagram Core only allows cancellation from requires_action; the contract is being re-cut to match (fix/webhook-cancelled-no-processing in MX). Co-Authored-By: Claude Fable 5 --- payments/webhooks.mdx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index 83a4bcc..87efcf1 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -34,10 +34,9 @@ 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 + C --> X["payment.cancelled"] ``` When you register an endpoint you choose which event types it receives; by default it receives all seven. From 79a1c372a8b6f697d1e231ed50213c07bce4aa34 Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Fri, 31 Jul 2026 12:21:43 +0200 Subject: [PATCH 5/6] docs(webhooks): reference_id is non-null Aligns with the reference_id re-cut (fix/webhook-reference-id-non-null in MX): the field is required at creation and never null. Co-Authored-By: Claude Fable 5 --- payments/webhooks.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index 87efcf1..03b4aa0 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -62,7 +62,7 @@ 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`). | +| `reference_id` | Your own order identifier, as passed when creating the payment. | | `status` | The payment status at event time: `requires_action`, `processing`, `succeeded`, `failed`, `expired`, or `cancelled`. | | `payment_state_version` | Monotonically increasing integer per payment, starting at `0` on the first event. **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.** | From dacc37ff2d2fd20be7993347582e7f4aab00acf5 Mon Sep 17 00:00:00 2001 From: Cheun Kitabayashi Date: Fri, 31 Jul 2026 18:20:06 +0200 Subject: [PATCH 6/6] docs(webhooks): integration-simulation fixes Fresh-eyes integration pass findings: the succeeded example is now the golden fixture verbatim (canonical millisecond timestamps), side effects are driven from the snapshot status rather than the event type (settled can arrive before succeeded), the reconciliation job also covers lost settlement via GET /v1/payments, fee nullability is stated where fee.kind is discussed, and test mode notes that settlement completes automatically. Co-Authored-By: Claude Fable 5 --- payments/webhooks.mdx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/payments/webhooks.mdx b/payments/webhooks.mdx index 03b4aa0..f126ecd 100644 --- a/payments/webhooks.mdx +++ b/payments/webhooks.mdx @@ -76,7 +76,7 @@ A `payment.succeeded` event looks like this: "id": "evt_fixture_payment_succeeded", "type": "payment.succeeded", "api_version": "2026-05-18", - "created_at": "2026-06-30T10:00:00Z", + "created_at": "2026-06-30T10:00:00.271Z", "data": { "payment_id": "pay_fixture_01HZX4V9K3T", "merchant_id": "merchant_fixture_01HZX4V9K3T", @@ -88,10 +88,10 @@ A `payment.succeeded` event looks like this: "unit": "iso4217/USD", "value": "1000" }, - "created_at": "2026-06-30T09:59:00Z", - "expires_at": "2026-06-30T10:14:00Z", + "created_at": "2026-06-30T09:59:00.084Z", + "expires_at": "2026-06-30T10:14:00.000Z", "processing": { - "processing_at": "2026-06-30T09:59:10Z", + "processing_at": "2026-06-30T09:59:10.512Z", "option_amount": { "unit": "caip19/eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "value": "10000000" @@ -123,7 +123,7 @@ A `payment.succeeded` event looks like this: "chain_caip2": "eip155:8453" }, "success": { - "succeeded_at": "2026-06-30T10:00:00Z", + "succeeded_at": "2026-06-30T10:00:00.271Z", "tx_id": "0x8f1e5c1b9a4a1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c" }, "failed": null, @@ -138,7 +138,7 @@ 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. 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. +- 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 when a fee applies; `processing.fee` is `null` when none is configured. - 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. @@ -154,6 +154,8 @@ Every event is delivered *at least* once, which means occasionally more than onc 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. +Drive side effects from the snapshot, not the event type. A `payment.settled` can arrive before the `payment.succeeded` it supersedes, and the guard then rightly drops the older event, so a handler that only fulfills on `type: "payment.succeeded"` never fulfills. Fulfill when `data.status` first becomes `succeeded`, whatever the event's type. + **The first event of every payment carries `payment_state_version: 0`.** Represent "no version processed yet" as an *absence* (a missing row or `NULL`), never as `0`. If you initialize a version counter to `0` — the natural default for an integer column or a `?? 0` fallback — the `less than or equal` guard silently drops the first event of every payment, and the integration still looks like it works because later events pass. @@ -181,7 +183,7 @@ That's roughly **28 hours** of automatic retries. After the final attempt the de Replay operations reach back 14 days at most. 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. +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, and [`GET /v1/payments`](/api-reference/latest/get-v1-payments), which returns `settled`, for succeeded payments still awaiting settlement. 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. @@ -507,7 +509,7 @@ Reject anything that fails verification with a `400`, and never process an unver 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, 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. +- **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`), and a succeeded test payment settles automatically, so `payment.settled` arrives too. 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.