> For the complete index of the 402pay docs, see [llms.txt](https://developer.402pay.co/llms.txt).

# Webhooks

Signed events for every change, retried until your server answers.

Webhooks tell your server when something changes, so you never poll. Add an endpoint in the dashboard or with [`POST /webhooks`](https://developer.402pay.co/api/webhooks/create.md), choose the event types it receives, and save its signing secret. The secret starts with `whsec_`, the Standard Webhooks prefix, and is shown once.

Delivery, Headers:

```http
POST /webhooks/402pay HTTP/1.1
Content-Type: application/json
webhook-id: evt_JxmQNXpkZZN8mpqE
webhook-timestamp: 1790457795
webhook-signature: v1,Usi/akbM9PxJChbtzMSCZXf783T+z0FaQRv68dL2fwQ=
```

Delivery, Body:

```json
{
  "id": "evt_JxmQNXpkZZN8mpqE",
  "kind": "event",
  "type": "payment.succeeded",
  "subject": {
    "kind": "payment",
    "id": "pmt_QI02vLdJGd48hBbg"
  },
  "data": {
    "id": "pmt_QI02vLdJGd48hBbg",
    "kind": "payment",
    "status": "succeeded",
    "amount": 4900,
    "currency": "USD",
    "fee_payer": "business",
    "customer_fee": 0,
    "amount_received": 4900,
    "reporting": {
      "currency": "USD",
      "amount": 4900,
      "fee": 0,
      "transaction_fee": 25,
      "customer_fee": 0,
      "net": 4875
    },
    "fee_rate_bps": 0,
    "method": {
      "rail": "crypto",
      "asset": "USDC",
      "network": "polygon",
      "amount": "49.00",
      "expected_amount": "49.00",
      "overpaid_amount": null,
      "from_address": "0x136e44a2738dea5217e8d6745463d2a9a84d1421",
      "tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
      "explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
    },
    "settlement": {
      "destination": "wallet",
      "asset": "USDC",
      "network": "polygon",
      "amount": "49.00",
      "wallet_id": "wal_1fIGZOrILmsCO0jw",
      "address": "0x9a3c416D4A81669C01f4515bBDEB93C45d3813fe",
      "tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20",
      "explorer_url": "https://polygonscan.com/tx/0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
    },
    "customer_id": "cst_gWFWcc7Ga0Pv7LSC",
    "link_id": null,
    "url": "https://checkout.402pay.co/checkout/2jrsrcxv7k",
    "reference": "order_1042",
    "metadata": {
      "order_id": "1042"
    },
    "success_url": "https://example.com/thanks",
    "cancel_url": "https://example.com/cart",
    "expires_at": "2026-09-27T21:22:47.038Z",
    "canceled_at": null,
    "checkout_id": "chk_C5yhPQvPqpYgdDgj",
    "description": "Pro plan, monthly",
    "country": "US",
    "failure_code": null,
    "failure_message": null,
    "confirmed_at": "2026-09-26T21:23:13.447Z",
    "created_at": "2026-09-26T21:22:47.038Z",
    "updated_at": "2026-09-26T21:23:13.447Z",
    "events": [
      {
        "type": "created",
        "created_at": "2026-09-26T21:22:47.038Z",
        "data": null
      },
      {
        "type": "method_selected",
        "created_at": "2026-09-26T21:22:47.047Z",
        "data": {
          "rail": "crypto",
          "asset": "USDC",
          "network": "polygon"
        }
      },
      {
        "type": "detected",
        "created_at": "2026-09-26T21:23:07.047Z",
        "data": {
          "amount": "49.00",
          "network": "polygon",
          "tx_hash": "0xc16a611d90302ba917593ed9743f5e734a556e8d2f35bb0d60a2ca02ac99bb20"
        }
      },
      {
        "type": "confirmation",
        "created_at": "2026-09-26T21:23:08.647Z",
        "data": {
          "current": 1,
          "required": 4
        }
      },
      {
        "type": "confirmation",
        "created_at": "2026-09-26T21:23:10.247Z",
        "data": {
          "current": 2,
          "required": 4
        }
      },
      {
        "type": "confirmation",
        "created_at": "2026-09-26T21:23:11.847Z",
        "data": {
          "current": 3,
          "required": 4
        }
      },
      {
        "type": "confirmation",
        "created_at": "2026-09-26T21:23:13.447Z",
        "data": {
          "current": 4,
          "required": 4
        }
      },
      {
        "type": "succeeded",
        "created_at": "2026-09-26T21:23:13.447Z",
        "data": null
      }
    ]
  },
  "actor": {
    "kind": "system",
    "id": null,
    "name": null
  },
  "mode": "live",
  "api_version": "v1",
  "created_at": "2026-09-26T21:23:15.256Z"
}
```

## Verify deliveries

Deliveries are signed with the Standard Webhooks scheme, so any library that implements it can check them. Each request carries three headers.

| Header | Value |
| --- | --- |
| `webhook-id` | The event's ID. It stays the same across retries, so use it to skip duplicates. |
| `webhook-timestamp` | Seconds since the Unix epoch. Refuse deliveries more than five minutes old. |
| `webhook-signature` | `v1,` then a base64 HMAC-SHA256 of `{id}.{timestamp}.{body}`, keyed with the base64-decoded part of the secret after `whsec_`. |

The body above is formatted to read. Your server receives it compact, and the signature covers those exact bytes, so verify the raw body before you parse it.

Verify a delivery, Node.js:

```js
import crypto from "node:crypto";

// secret is the endpoint's whsec_ value; body is the raw request body.
export function verifyWebhook(secret, headers, body) {
  const id = String(headers["webhook-id"] ?? "");
  const timestamp = String(headers["webhook-timestamp"] ?? "");
  const signatures = String(headers["webhook-signature"] ?? "");

  // Refuse replays: the delivery must be less than five minutes old.
  const sent = Number(timestamp);
  if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 300) return false;

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${body}`)
    .digest("base64");

  // During a rotation the header carries one signature per secret.
  return signatures.split(" ").some((entry) => {
    const [version, signature = ""] = entry.split(",");
    return (
      version === "v1" &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    );
  });
}
```

Verify a delivery, Python:

```python
import base64
import hashlib
import hmac
import time

# secret is the endpoint's whsec_ value; body is the raw request body.
def verify_webhook(secret: str, headers, body: bytes) -> bool:
    msg_id = headers.get("webhook-id", "")
    timestamp = headers.get("webhook-timestamp", "")

    # Refuse replays: the delivery must be less than five minutes old.
    if not timestamp.isdecimal() or abs(time.time() - int(timestamp)) > 300:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    # During a rotation the header carries one signature per secret.
    return any(
        hmac.compare_digest(entry[3:].encode(), expected)
        for entry in headers.get("webhook-signature", "").split(" ")
        if entry.startswith("v1,")
    )
```

After you rotate a secret, the old one keeps signing alongside the new one for 24 hours, separated by a space, so you can switch without dropping a delivery.

## Responses and retries

Answer with any 2xx status within 15 seconds. Anything else, or no answer, is tried again up to 7 more times, after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. Do slow work after you respond, and handle each `webhook-id` once. Delivery is at least once and not in order: a retry can arrive after a later event, so read the object's current state before you act on one. Failed deliveries can be resent from the dashboard or with [`POST /webhook-deliveries/{id}/resend`](https://developer.402pay.co/api/webhooks/resend.md).

- Event (signed delivery) → Your endpoint (answers in 15 seconds)
- Your endpoint (answers in 15 seconds) → Delivered (any 2xx)
- Your endpoint (answers in 15 seconds) → Retry (up to 7 more times)
- Retry (up to 7 more times) → Failed (resend it any time)
- Retry (up to 7 more times) → Your endpoint (answers in 15 seconds): waits longer each time

Turning an endpoint off stops new deliveries, and drops any retry that comes due while it's off, so nothing piles up to arrive at once when you turn it back on. Deleting an endpoint removes its deliveries, pending retries included.

## Event fields

| Field | Meaning |
| --- | --- |
| `id` | The event's ID, the same as the `webhook-id` header. |
| `type` | What happened, such as `payment.succeeded`. |
| `subject` | The `kind` and `id` of the object it's about. |
| `data` | The subject as it was when the event happened. Fetch it again for its state now. |
| `actor` | Who caused it: `user` (a person in the dashboard), `api_key`, `customer` (at checkout; `name` is their email when they gave one) or `system` (402pay, such as a payment confirming), with an `id` and `name` where there is one. |
| `mode` | `test` or `live`, for the kind of key behind the event. Always `live` during the beta, when test and live keys share data. |
| `api_version` | The version of the event's shape, `v1` today. |
| `test` | Only on test deliveries, and then `true`. |
| `created_at` | When it happened. |

## Test deliveries

[`POST /webhooks/{id}/test`](https://developer.402pay.co/api/webhooks/test.md) sends a signed sample of an event type the endpoint receives. Its payload has `test: true` and a made-up subject, such as `pmt_test_wcKbRA2NP8gXhlmr`, so check for `test` before you act on an event. Test deliveries go out once and aren't retried.

## Troubleshooting

| Symptom | Likely cause |
| --- | --- |
| Every signature fails | The body was parsed and re-serialized before verifying. Sign the raw bytes you received. |
| Some signatures fail | Your server's clock is off, or you rotated the secret and still check only the new one. |
| Events arrive twice | A slow response was retried. Skip any webhook-id you've already handled. |
| Nothing arrives | The endpoint is disabled, not subscribed to the event type, or not reachable over public HTTPS. Read each attempt and your server's answer in the dashboard or with GET /webhook-deliveries. A local 402pay records deliveries without sending them. |

## Event types

Every type an endpoint can subscribe to, what its `data` holds and the order a payment's events arrive in are in the [webhook event catalog](https://developer.402pay.co/guides/webhook-events.md#event-types).
