# Webhooks

Receive a signed POST on your server whenever an order or a refund changes. That way you don't need to poll the API every few seconds.

- [Manage webhooks in the dashboard](https://bytemax.exchange/en/webhooks)
- [Download OpenAPI 3.1](https://bytemax.exchange/api-v1.openapi.json)
- [Documentation page](https://bytemax.exchange/en/docs/webhooks)

## How it works

1. **Register the webhook**
   Register in your [webhooks panel](https://bytemax.exchange/en/webhooks) or with `POST /webhooks`, using your server URL and the events you want to receive. Your account needs an active API key. The URL must start with `https://` and use a public domain that resolves in DNS. IP addresses and URLs containing a username or password are rejected with `INVALID_INPUT`.

2. **Store the signing secret**
   It comes in the creation response, in the format `whsec_…`, and can be read again with `GET /webhooks/{id}?include_signing_secret=true`. It cannot be regenerated: if it leaks, delete the webhook and create a new one. Do not confuse it with the API key.

3. **Receive the POST**
   Each event arrives as a POST, with the event JSON in the body and the `svix-id`, `svix-timestamp` and `svix-signature` headers.

4. **Verify, store and respond 2xx**
   Check the signature, store the event and only then respond. Any response other than 2xx, or no response, triggers new attempts.

## Events

| Event | When it is sent |
| --- | --- |
| `order.created` | Order created. Query the order for its final amounts and payment. |
| `order.completed` | The order was paid and completed. Confirm with a query (`status` `completed`) before releasing the purchase. |
| `order.expired` | The order expired without payment. |
| `order.canceled` | ByteMax canceled the order, for example because the payment could not be accepted and was returned to the payer. |
| `order.updated` | Order data changed without a status change: the refunded amount, the blocked amount or the payment details. It does not mean payment: a paid order sends `order.completed`. |
| `refund.created` | A refund requested by your account, through the API or the dashboard, was recorded. Refunds made by support only show up in `order.updated`. |
| `refund.succeeded` | The refund finished: the amount was returned to the payer. |
| `refund.failed` | The refund could not be completed. |
| `webhook.test` | Test event, sent only to the webhook you tested. |

Each event carries `type`, which says what happened, and `related_object`: the object's `type` (`order`, `refund` or `webhook`), its `id` and its `url`, the query path on `https://bytemax.exchange`. To act, query that `url`: it always returns the current state, even when the event arrives late, repeated or out of order.

**Example: order.completed**

```json
{
  "id": "evt_example",
  "type": "order.completed",
  "created_at": "2026-09-10T13:05:00.000Z",
  "related_object": {
    "type": "order",
    "id": "ord_example",
    "url": "/api/v1/orders/ord_example"
  }
}
```

### Publication states

`GET /events` and `GET /events/{id}` include `publication_status`, which tells whether the event left ByteMax. The result on your server is in the deliveries.

- `pending`: Recorded and waiting to be sent, or being retried. ByteMax keeps trying to publish for about 2 hours; after that the event becomes `not_published`.
- `published`: Handed over for delivery. The result of each attempt is in the deliveries.
- `not_published`: Not sent: your account had no active API key, none of your active webhooks subscribed to this event type at the time, or publication failed. It cannot be resent; look up the order or refund for its current state.

## Receive and verify

Verify with the secret and the raw request body, before parsing the JSON. Use the official Svix library for your language: it checks the signature and the time tolerance of `svix-timestamp`. Do not implement the verification by hand.

[How to verify signatures with the official library (docs.svix.com)](https://docs.svix.com/receiving/verifying-payloads/how)

**What reaches your server**

```http
POST /webhooks/bytemax HTTP/1.1
Content-Type: application/json
svix-id: msg_2gkQ7Wc9cD6yYq1xkKz7GJb3wQm
svix-timestamp: 1789045500
svix-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhUjM8G3xRk2w1vNq4T0=

{ "id": "evt_example", "type": "order.completed", ... }
```

## Process without duplicates

Events can arrive more than once and out of order. Five rules prevent duplicate effects:

- Use the event `id` as a unique key: store it before responding and ignore repeats.
- Don't rely on arrival order: on each event, query the order or refund and act on the state the query returns.
- Store the event's effect and the processed mark in the same transaction.
- Respond 2xx only after storing. If storing fails, respond 5xx to receive it again.
- Slow work stays out of the response: queue it and respond quickly.

## Test and resend

`POST /webhooks/{id}/test-events` with `Content-Type: application/json` and `{}` as the body sends a `webhook.test` to that webhook, which must be enabled. Follow its publication with `GET /events/{id}` and check on your server that the event arrived and the signature was accepted. The test proves delivery and signature; to test how you handle orders and refunds, follow a real order.

Failed deliveries get up to 7 automatic retries: after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. A webhook that keeps failing for 5 days is disabled automatically. The history of every attempt is in the dashboard and in `GET /webhooks/{id}/deliveries`. To resend specific events, use `POST /webhooks/{id}/redeliveries` with up to 100 IDs. The call answers 200 even when some events are refused: read the `status` of each item.

- `queued`: The resend was accepted.
- `rejected`: Nothing was resent. `code` says why: `EVENT_NOT_FOUND` (the ID does not exist), `EVENT_NOT_ACCEPTED` (the webhook does not subscribe to this event type, or the test belongs to another webhook), `EVENT_NOT_PUBLISHED` (the event is still `pending` or ended `not_published`) or `REDELIVERY_REJECTED` (the delivery service refused it). A `pending` event can be resent once `GET /events/{id}` shows `published`.
- `retry`: The resend did not happen or could not be confirmed: the call ran out of time, or the delivery service was busy or failed. Send it again; if the event arrives twice, its `id` prevents a duplicate effect.

## Limits

- Up to 200 webhooks per account, one per URL.
- Events can be queried for 30 days after publication.
- Lists use `items` and `next_cursor`: 30 per page, at most 100. A cursor only works on the same list with the same filters.

## Errors

Authentication, general errors and the call limit are on the [API page](https://bytemax.exchange/en/docs/api). These codes are specific to webhooks:

- `WEBHOOK_URL_EXISTS` (HTTP 409): You already have a webhook with this URL.
- `WEBHOOK_LIMIT_REACHED` (HTTP 409): You already have 200 webhooks. Delete one before creating another.
- `WEBHOOK_NOT_ENABLED_FOR_V1` (HTTP 409): Tests and resends need an enabled v1 webhook. Enable it or, if it came from the previous API, migrate it to v1 with `PATCH`.
- `WEBHOOK_VERSION_REQUIRED` (HTTP 400): A webhook from the previous API can only be changed together with its migration to v1: send `api_version: "v1"` and the new `events`.
- `WEBHOOK_NOT_FOUND` (HTTP 404): No webhook with this ID in your account.
- `EVENT_NOT_FOUND` (HTTP 404): No event with this ID. Events are kept for 30 days after publication.
- `INVALID_CURSOR` (HTTP 400): The cursor does not belong to this list with these filters. Start again without `cursor`.

A 503 `SERVICE_UNAVAILABLE` answer means the delivery service is temporarily unavailable. Retry after a few seconds.

## Reference

Ten operations, all with `Authorization: Bearer <API_KEY>`. The three most used appear in full; the others follow the same format.

### Create webhook

`POST /webhooks`

Creates the webhook and returns the signing secret.

**JSON body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | `string` | Yes | `https://` address of your server that receives the events, on a public domain. |
| `events` | `array` | Yes | Event types the webhook receives, without repeats: one or more of the eight order and refund types in Events. `webhook.test` cannot be subscribed to: it only arrives through the test. |
| `enabled` | `boolean` | No | Whether the webhook receives events. Default: `true`. |

**Request**

```bash
curl --request POST "https://bytemax.exchange/api/v1/webhooks" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://merchant.example/webhooks/bytemax","events":["order.created","order.completed","order.expired","order.canceled","order.updated","refund.created","refund.succeeded","refund.failed"],"enabled":true}'
```

**Response 201**

```json
{
  "id": "ep_example",
  "url": "https://merchant.example/webhooks/bytemax",
  "events": [
    "order.created",
    "order.completed",
    "order.expired",
    "order.canceled",
    "order.updated",
    "refund.created",
    "refund.succeeded",
    "refund.failed"
  ],
  "enabled": true,
  "api_version": "v1",
  "signing_secret": "<WEBHOOK_SIGNING_SECRET>"
}
```

### Send test

`POST /webhooks/{id}/test-events`

Sends a `webhook.test` to this webhook. The body is an empty JSON object.

**Parameters**

| Parameter | Where | Required |
| --- | --- | --- |
| `id` | path | Yes |

**Request**

```bash
curl --request POST "https://bytemax.exchange/api/v1/webhooks/ep_example/test-events" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{}'
```

**Response 202**

```json
{
  "id": "evt_test_example",
  "type": "webhook.test",
  "created_at": "2026-09-10T13:05:00.000Z",
  "related_object": {
    "type": "webhook",
    "id": "ep_example",
    "url": "/api/v1/webhooks/ep_example"
  },
  "publication_status": "pending"
}
```

### Resend events

`POST /webhooks/{id}/redeliveries`

Resends up to 100 events to this webhook. The response includes the result of each one.

**Parameters**

| Parameter | Where | Required |
| --- | --- | --- |
| `id` | path | Yes |

**JSON body**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `event_ids` | `array` | Yes | IDs of the events to resend, 1 to 100, without repeats. |

**Request**

```bash
curl --request POST "https://bytemax.exchange/api/v1/webhooks/ep_example/redeliveries" \
  --header "Authorization: Bearer <API_KEY>" \
  --header "Content-Type: application/json" \
  --data '{"event_ids":["evt_example"]}'
```

**Response 200**

```json
{
  "items": [
    {
      "event_id": "evt_example",
      "status": "queued"
    }
  ]
}
```

### Other operations

| Operation | Name | What it does |
| --- | --- | --- |
| `GET /webhooks` | List webhooks | All webhooks in your account, including those created with the previous API (`api_version: "legacy"`). |
| `GET /webhooks/{id}` | Get webhook | One webhook. With `include_signing_secret=true`, includes the signing secret. |
| `PATCH /webhooks/{id}` | Update webhook | URL, events or activation. To migrate a webhook created in the previous API to v1, send `api_version: "v1"` together with the new `events`. |
| `DELETE /webhooks/{id}` | Delete webhook | Deletes the webhook. |
| `GET /events` | List events | Events from the last 30 days and their publication state. |
| `GET /events/{id}` | Get event | One event and its publication state. |
| `GET /webhooks/{id}/deliveries` | List deliveries | Delivery attempts of a webhook: `succeeded` when your server answered 2xx and `failed` when it did not answer or answered another status. Filters by state and type. |
