> ## Documentation Index
> Fetch the complete documentation index at: https://walletconnect-pay-docs-docs-webhooks-corrections.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

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

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.

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.

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.<stage>`:

| 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} theme={null}
flowchart LR
    C["payment.created"] --> P["payment.processing"]
    P --> S["payment.succeeded"] --> T["payment.settled"]
    P --> F["payment.failed"]
    P --> E["payment.expired"]
    C --> F
    C --> E
    C --> X["payment.cancelled"]
```

When you register an endpoint you choose which event types it receives; by default it receives all seven.

<Note>
  `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.
</Note>

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

| 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.                                                                                                                                                                                     |
| `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.**                                                                                                                                      |
| `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`. |

A `payment.succeeded` event looks like this:

```json theme={null}
{
  "id": "evt_fixture_payment_succeeded",
  "type": "payment.succeeded",
  "api_version": "2026-05-18",
  "created_at": "2026-06-30T10:00:00.271Z",
  "data": {
    "payment_id": "pay_fixture_01HZX4V9K3T",
    "merchant_id": "merchant_fixture_01HZX4V9K3T",
    "live": true,
    "payment_state_version": 2,
    "reference_id": "order_fixture_1042",
    "status": "succeeded",
    "amount": {
      "unit": "iso4217/USD",
      "value": "1000"
    },
    "created_at": "2026-06-30T09:59:00.084Z",
    "expires_at": "2026-06-30T10:14:00.000Z",
    "processing": {
      "processing_at": "2026-06-30T09:59:10.512Z",
      "option_amount": {
        "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"
      },
      "buyer_caip10": "eip155:8453:0x000000000000000000000000000000000000dEaD",
      "chain_caip2": "eip155:8453"
    },
    "success": {
      "succeeded_at": "2026-06-30T10:00:00.271Z",
      "tx_id": "0x8f1e5c1b9a4a1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6f708192a3b4c"
    },
    "failed": null,
    "cancelled": null,
    "expired": null,
    "settled": null
  }
}
```

<Warning>
  **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 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.
</Warning>

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

<Warning>
  **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.
</Warning>

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

| 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**, 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, 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.

<Tip>
  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.
</Tip>

## Set up your endpoint

<Steps>
  <Step title="Register the endpoint" stepNumber="1">
    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.

    <Note>
      You can register one webhook endpoint per mode. Test and live endpoints are fully separate, each with its own signing secret.
    </Note>
  </Step>

  <Step title="Verify signatures" stepNumber="2">
    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:

    <button
      type="button"
      data-wcp-copy-prompt
      style={{
padding: "0.5rem 1.25rem",
background: "#0988f0",
color: "#ffffff",
fontWeight: 600,
border: "none",
borderRadius: "0.5rem",
cursor: "pointer"
}}
    >
      Copy prompt
    </button>

    ### Or implement it by hand

    The signature arrives in three headers on every delivery:

    | Header              | Contents                                                                                                                                                     |
    | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
    | `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,<base64 digest>`. 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 `{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:

    <CodeGroup>
      ```ts Node.js theme={null}
      import { createHmac, timingSafeEqual } from "node:crypto";

      const TOLERANCE_S = 5 * 60;

      export function verifyWebhook(secret: string, headers: Record<string, string>, rawBody: string) {
        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));
        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 theme={null}
      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("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")

          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 theme={null}
      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("webhook-id")
      	timestamp := headers.Get("webhook-timestamp")
      	signatures := headers.Get("webhook-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 theme={null}
      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");
          }
      }
      ```

      ```csharp C# theme={null}
      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");
          }
      }
      ```

      ```php PHP theme={null}
      <?php

      const TOLERANCE_S = 300;

      function verify_webhook(string $secret, array $headers, string $rawBody): array
      {
          $id = $headers['webhook-id'] ?? '';
          $timestamp = $headers['webhook-timestamp'] ?? '';
          $signatures = $headers['webhook-signature'] ?? '';
          if ($id === '' || $timestamp === '' || $signatures === '') {
              throw new RuntimeException('missing signature headers');
          }

          if (abs(time() - (int) $timestamp) > TOLERANCE_S) {
              throw new RuntimeException('timestamp outside tolerance');
          }

          $key = base64_decode(substr($secret, strlen('whsec_')), true);
          $expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true));

          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 theme={null}
      require "base64"
      require "json"
      require "openssl"

      TOLERANCE_S = 5 * 60

      def verify_webhook(secret, headers, raw_body)
        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

        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
      ```
    </CodeGroup>

    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. 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.
  </Step>

  <Step title="Test the delivery" stepNumber="3">
    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`), 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.

    <Tip>
      During local development, expose your handler with a tunnel (e.g. `ngrok`, `cloudflared`) and register the tunnel URL as your test-mode endpoint.
    </Tip>
  </Step>
</Steps>

## Allowlist webhook source IPs (optional)

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:

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 safety rails: dedupe by `id`, ordering by `payment_state_version`.

## Next steps

<CardGroup cols={2}>
  <Card title="Test mode" icon="flask" href="/payments/test-mode">
    Simulate every payment status transition and watch the webhooks arrive.
  </Card>

  <Card title="Get the payment status" icon="magnifying-glass" href="/api-reference/latest/get-v1-payments-id-status">
    The polling endpoint to confirm state on the critical path.
  </Card>
</CardGroup>
