> For the complete documentation index, see [llms.txt](https://developers.bead.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developers.bead.xyz/payments/payment-webhooks.md).

# Payment Webhooks

Payment webhooks let Bead send real-time payment status updates to your system. Instead of polling, your server receives an HTTP `POST` whenever a payment's `statusCode` changes.

Payment webhook notifications are typically used together with the Payments and Reporting APIs.

**When to use payment webhooks**

Use payment webhooks when you want to:

* update orders or invoices as soon as a payment completes
* react to `underpaid`, `overpaid`, `expired`, `invalid`, or `cancelled` outcomes
* trigger downstream workflows such as fulfilment, refunds, customer notifications, or support tickets
* track payments without polling `GET /Payments/{paymentId}/tracking`

**How payment webhooks work**

1. You configure a default webhook URL for a terminal, or provide `webhookUrls` when creating a payment.
2. Bead delivers an HTTP `POST` when a payment status changes.
3. Your server verifies the webhook signature using the stored `signingSecret`.
4. Your system records the event and updates internal state.
5. If needed, your system confirms the latest state using `GET /Payments/{paymentId}/tracking`.

**Configure payment webhook delivery**

Payment webhooks are configured per terminal.

**Default terminal webhook**

Use the terminal webhook endpoint to set the default payment webhook URL for a terminal:

`PUT /Terminals/{id}/webhook`

The webhook URL should be an HTTPS endpoint that you control.

A successful setup response includes:

* `url`
* `signingSecret`

Store `signingSecret` securely. You will use it to verify incoming payment webhooks.

**Optional per-payment webhook URLs**

When creating a payment with `POST /Payments/crypto`, you can also provide `webhookUrls` for payment-specific delivery in addition to the terminal's default webhook.

Use this when you want a payment to notify a specific backend flow without changing the terminal's default webhook configuration.

**Where to get the signing secret**

The payment webhook signing secret is associated with the terminal webhook configuration and should be stored securely when the webhook is set.

Important notes:

* do not expose `signingSecret` in client-side code
* do not expect the incoming webhook payload to include the secret
* do not compare the incoming signature header directly to the secret itself

The correct verification pattern is:

1. configure the webhook
2. save the `signingSecret`
3. receive the webhook delivery
4. verify the signature using the steps below
5. only trust the event if verification passes

**Event delivery**

For each payment update, Bead sends a `POST` request to your webhook URL.

General behavior:

* method: `POST`
* content type: `application/json`
* one event per payment status change
* retries occur if your endpoint does not return a successful `2xx` response

Your webhook handler should:

* log every webhook request for debugging and audit
* preserve the raw request body before JSON parsing
* preserve request headers
* verify the signature before processing the payload
* return a successful response quickly after safely persisting or queueing the event

**Current payment signature header**

Current payment webhook deliveries include the signature in:

```
x-webhook-signature: t=<unix_timestamp_ms>,s=<base64_signature>
```

Example:

```
x-webhook-signature: t=1781811428956,s=FK/SW9lIK0CXpNnfweTN3ZbJ8Nvbm1RF69Nm6XE8w3O=
```

<table><thead><tr><th width="85">Field</th><th>Description</th></tr></thead><tbody><tr><td><code>t</code></td><td>Unix epoch timestamp in <strong>milliseconds</strong> when Bead generated the event</td></tr><tr><td><code>s</code></td><td><strong>Base64-encoded</strong> HMAC-SHA256 digest of the signed message <code>t + "." + rawBody</code>, computed using the decoded bytes of the terminal's <code>signingSecret</code></td></tr></tbody></table>

**Verifying the webhook**

Always verify the payment webhook before processing the JSON body.

1. Capture the raw request body exactly as received. Do not parse or reserialize JSON before this step.
2. Read the `x-webhook-signature` header and parse the `t` and `s` values.
3. Validate the timestamp by confirming `t` (in milliseconds) is within 5 minutes of the current time. Reject requests outside this window to prevent replay attacks.
4. Decode `signingSecret` from base64 to raw bytes. Use those bytes as the HMAC key.
5. Construct the signed message by concatenating `t`, a literal period, and the raw request body: `message = t + "." + rawBody`.
6. Compute `HMAC-SHA256(key=decodedSecretBytes, message=message)` and base64-encode the digest.
7. Compare your computed base64 digest to `s` using constant-time equality. Reject the request if they do not match.

For a full code example, see [How do I verify that a webhook really came from Bead?](/faqs-and-troubleshooting/webhooks-and-error-codes/how-do-i-verify-that-a-webhook-really-came-from-bead.md)

**Example payload**

{% code expandable="true" %}

```json
{
  "paymentId": "pay_d3594f0680964156b21fab60f8573bb4",
  "trackingId": "d3594f0680964156b21fab60f8573bb4",
  "paymentCode": "HM9N44Z43VTW",
  "statusCode": "cancelled",
  "amounts": {
    "requested": {
      "inPaymentCurrency": {
        "amount": 1,
        "currency": {
          "id": 16,
          "code": "USDC_BASE",
          "name": "USDC Base",
          "symbol": "USDC"
        }
      },
      "inRequestedCurrency": {
        "amount": 1,
        "currency": {
          "id": 1,
          "code": "USD",
          "name": "USD",
          "symbol": "$"
        }
      }
    },
    "paid": {
      "inPaymentCurrency": {
        "amount": 0,
        "currency": {
          "id": 16,
          "code": "USDC_BASE",
          "name": "USDC Base",
          "symbol": "USDC"
        }
      }
    }
  },
  "reference": "ORDER123",
  "description": null,
  "receivedTime": "2026-03-30T13:39:29.948491+00:00",
  "terminalId": "69aae38b1f3fe8c6698663dc",
  "merchantId": "664c5e3b0517b0a8a6321c9a",
  "errorMessage": null
}
```

{% endcode %}

**Common payment status values**

Payment webhook events may include status values such as `created`, `processing`, `underpaid`, `overpaid`, `completed`, `expired`, `invalid`, and `cancelled`.

Your system should treat `statusCode` as the primary event classifier.

**Key fields**

**`paymentId`**

This is the primary identifier for the payment. Use it to call `GET /Payments/{paymentId}/tracking` to confirm or refresh payment state and correlate status checks with your internal payment or order record.

**`trackingId`**

This is the tracking identifier returned when the payment was created. Use it to correlate the webhook to your internal payment or order record, reconcile webhook delivery with reporting and support workflows, and key idempotency checks alongside `statusCode`.

**`paymentCode`**

This is the hosted payment code associated with the checkout.

**`statusCode`**

This tells you the payment's current lifecycle state.

**`terminalId`**

This identifies the terminal that produced the payment.

**`merchantId`**

This identifies the merchant associated with the payment.

**`receivedTime`**

This is the timestamp associated with the event payload.

**Recommended processing model**

1. Receive the request.
2. Preserve the raw request body and headers.
3. Read `x-webhook-signature` and parse `t` and `s`.
4. Validate the timestamp — confirm `t` (in milliseconds) is within 5 minutes of the current time.
5. Decode `signingSecret` from base64 to bytes. Build the signed message as `t + "." + rawBody`. Compute `HMAC-SHA256(key=decodedSecretBytes, message=signedMessage)` and base64-encode the digest.
6. Compare the computed digest to `s` using constant-time equality. Reject the request if they do not match.
7. Parse the JSON payload only after verification succeeds.
8. Persist the event or enqueue it for processing.
9. Update your internal order or invoice state based on `statusCode`.
10. Return a successful `2xx` response quickly.

**Idempotency and duplicate handling**

Treat payment webhook delivery as at least once.

A good idempotent processing strategy uses `trackingId` + `statusCode` as the primary key, with the raw body hash or received timestamp optionally stored for debugging.

Your system should be able to safely ignore duplicate deliveries without creating duplicate business actions.

**Delivery mechanics**

* respond quickly, ideally in under one second when possible
* do heavy work asynchronously after the event is safely persisted or queued
* expect retries with backoff when your endpoint fails or times out
* rely on signature verification rather than source IP allowlisting unless Bead explicitly publishes and supports source IP controls for your environment

**Confirming status with the Tracking endpoint**

If you need to confirm the latest state during support, reconciliation, or after a missed event, call the tracking endpoint using the payment's `paymentId`.

**Endpoint**

`GET /Payments/{paymentId}/tracking`

**Headers**

* `X-Api-Key: {apiKey}`
* `Accept: application/json`

**Notes**

* `apiKey` is the secret credential
* `maskedApiKey` is not usable
* the header name must be exactly `X-Api-Key`

**Testing webhooks in Sandbox**

1. Configure a webhook URL for your Sandbox terminal.
2. Create a Sandbox payment with `POST /Payments/crypto`.
3. Complete the payment on the hosted page.
4. Confirm your webhook received payment status updates.
5. Confirm your listener captures `x-webhook-signature`.
6. Verify the signature using the stored `signingSecret`.
7. Optionally confirm the final state using `GET /Payments/{paymentId}/tracking`.

**Troubleshooting**

**I am receiving the webhook but verification fails**

Check:

* you are signing `t + "." + rawBody`, not the raw body alone
* you are decoding `signingSecret` from base64 to bytes before using it as the HMAC key
* you are using the raw request body, not a reserialized JSON body
* you are producing a base64 digest, not a hex digest
* you are using the `signingSecret` for the correct terminal

**Webhook not received**

Check:

* the terminal has a webhook URL configured
* your endpoint is publicly reachable over HTTPS
* your endpoint returns a successful `2xx` response quickly
* your server logs show inbound requests and any verification or parsing failures

**Receiving repeated webhook events**

This usually means your endpoint is timing out or returning a non-`2xx` response. Return success quickly after safely persisting or queueing the event, and make your processing idempotent.

**401 Unauthorized when checking status**

Payments endpoints use `X-Api-Key`. Confirm the API key is present, valid for the environment, and that the header name is exactly `X-Api-Key`.

**403 Forbidden when checking status**

The API key is valid but not permitted for the payment context, or you are mixing environments. Confirm you are using the correct environment base URL and the correct terminal API key.

**Next steps**

* Review [Payment Statuses](/payments/payment-statuses.md) for the full list of `statusCode` values
* Review [Create Payment](/payments/create-payment.md) for `webhookUrls` support when you need per-payment delivery
* Review [Webhook Event Reference](/reference-guide/operational-guides/webhook-event-reference.md) for the shared signature and delivery reference
* Use [Reporting](/reporting.md) and [Settlement](/settlement.md) for historical views and reconciliation


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://developers.bead.xyz/payments/payment-webhooks.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
