# Webhooks

Cryptonly signs each webhook with HMAC-SHA256 over the raw JSON body.

Webhooks are sent for invoice, deposit, and withdrawal status updates.

:::note
Invoice, deposit, and withdrawal webhooks are sent on every status change, not only when the resource reaches a final state.
:::

***

## How webhooks are sent

### How webhook signing works

Each webhook request includes a signature header:

```http
x-webhook-signature: <signature>
```

The signature is generated using **HMAC-SHA256** over the exact raw JSON request body.

Your server should calculate the same signature using your Cryptonly webhook signing key. If the calculated signature matches the received signature, the webhook can be trusted.

***

### Webhook HTTP contract

| Item             | Value                 |
| ---------------- | --------------------- |
| Method           | `POST`                |
| Content type     | `application/json`    |
| Signature header | `x-webhook-signature` |
| Signature format | Lowercase hex string  |
| Encoding         | UTF-8 JSON body       |
| Redirects        | Not followed          |

Request headers:

| Header                | Value                                                                                              |
| --------------------- | -------------------------------------------------------------------------------------------------- |
| `Content-Type`        | `application/json`                                                                                 |
| `User-Agent`          | `Cryptonly-Webhooks/1`                                                                             |
| `x-webhook-id`        | Delivery id (UUID). The same on every attempt of one delivery, including retries and manual resends |
| `x-webhook-timestamp` | Unix time in seconds when this attempt was sent                                                    |
| `x-webhook-event`     | Event name, the same as `event` in the body                                                        |
| `x-webhook-signature` | HMAC-SHA256 of the raw body.                               |

:::note
Only the request body is signed. `x-webhook-id`, `x-webhook-timestamp`, and `x-webhook-event` are informational and are not covered by the signature. Use the signed `timestamp` field in the body for replay checks.
:::

Example webhook body:

```json
{
  "event": "invoice.statusChanged",
  "data": {},
  "timestamp": "2026-01-01T12:00:00.000Z"
}
```

The `timestamp` field is included in every webhook body and can be used for replay protection. It is set when each attempt is sent, so a retry carries a newer `timestamp` and a different signature than the attempt before it.

***

### Webhook payload shape

The `data` object contains a snapshot of the resource at the time of the event. All event types may include an optional `previousStatus` field — the status immediately before this transition. On invoice create, `previousStatus` is omitted.

| Event                      | `data` shape                                                                                                                       |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `invoice.statusChanged`    | Invoice object (same fields as merchant GET invoice) + optional `previousStatus`                                                   |
| `deposit.statusChanged`    | Deposit object + optional `previousStatus` and deposit-specific fields below                                                       |
| `withdrawal.statusChanged` | Withdrawal object (same fields as merchant GET withdrawal, with `tenantAccountId` sent as `accountId`) + optional `previousStatus` |

**Deposit-specific optional fields** in `data`:

* `depositTransactionHash`
* `actuallyReceivedAmount`
* `actuallyReceivedAmountUsd`

For full field lists and status values, see:

* [Invoices](https://docs.cryptonly.net/guides/invoices.md)
* [Deposits](https://docs.cryptonly.net/guides/deposits.md)
* [Withdrawals](https://docs.cryptonly.net/guides/withdrawals.md)

***

### Supported webhook events

Cryptonly currently sends signed webhooks for status changes such as:

| Event                      | Description                                                                |
| -------------------------- | -------------------------------------------------------------------------- |
| `invoice.statusChanged`    | Sent when an invoice status changes                                        |
| `deposit.statusChanged`    | Sent when a standalone deposit status changes                              |
| `withdrawal.statusChanged` | Sent when a withdrawal status changes                                      |

Webhook events are emitted whenever the resource status changes.

For example, an invoice may trigger multiple webhook events during its lifecycle.

**When events fire**

| Event                      | When Cryptonly sends it                                                                                                                        |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `invoice.statusChanged`    | On invoice create (initial `created`) and on every subsequent status transition                                                                |
| `deposit.statusChanged`    | On each deposit status transition only (not on initial create)                                                                                 |
| `withdrawal.statusChanged` | On each post-commit status transition; the first webhook typically arrives when the withdrawal moves to `processing` (see Withdrawal statuses) |

**Deposit webhooks are not sent** when a deposit is linked to an invoice (`invoiceId` is set). For invoice checkout payments, listen for `invoice.statusChanged` instead.

**No webhook is sent** if no webhook URL resolves for the resource (see Default webhook URLs below).

***

### Default webhook URLs

You can configure default webhook URLs in the Cryptonly merchant dashboard.

Go to:

```
Settings → Integration
```

You can configure default HTTPS webhook endpoints for invoices, deposits, and withdrawals.

When creating an invoice or deposit, or committing a withdrawal:

* if the request includes a `webhookUrl`, that URL is used;
* if the request does not include a `webhookUrl`, Cryptonly uses the organization default for that resource type;
* an explicit `webhookUrl` in the request always takes priority.

Per-resource URL resolution:

| Resource   | Resolution order                                                                                   |
| ---------- | -------------------------------------------------------------------------------------------------- |
| Invoice    | Request `webhookUrl` → `defaultInvoiceWebhookUrl`                                                  |
| Withdrawal | Request `webhookUrl` → `defaultWithdrawalWebhookUrl`                                               |
| Deposit    | Request `webhookUrl` → `defaultDepositWebhookUrl`                                                  |

If no URL resolves after the cascade, Cryptonly does not send a webhook for that event.

:::caution
Webhook URLs must use HTTPS.
:::

***

## Signing keys

### Getting your webhook signing key

The webhook signing key belongs to an account. It is available in the Cryptonly admin dashboard on that account's **Security** page, under **API Keys**.

Go to:

```
Accounts → Settings → Security
```

The page shows a fingerprint of the current key. Only organization admins can view the fingerprint or rotate the key.

:::caution
The full webhook signing key is shown only once after rotation. Copy it immediately and store it securely.
:::

**How to obtain the key**

1. Open the Cryptonly merchant/admin dashboard.
2. Go to **Accounts** and select the account.
3. Open the **Settings** tab, then **Security**.
4. Find the **Webhook signing key** section.
5. Click **Rotate key**.
6. Confirm the rotation.
7. Copy the new signing key immediately.
8. Store it in your server environment variables.

Example:

```bash
CRYPTONLY_WEBHOOK_SECRET=your_signing_key_here
```

***

### Important key rotation behavior

When you rotate the webhook signing key:

* new webhook deliveries are signed with the new key;
* your server must be updated to use the new key;
* verification with the old key will fail for new webhooks.

If your integration has never copied a signing key before, rotate the key once and use the newly generated value for your webhook verifier.

***

## Verifying webhooks

### Signature format

Cryptonly uses:

```
HMAC-SHA256(rawBody, webhookSigningKey)
```

Where:

| Part                | Description                                              |
| ------------------- | -------------------------------------------------------- |
| `rawBody`           | Exact raw UTF-8 bytes of the JSON body sent by Cryptonly |
| `webhookSigningKey` | Your organization webhook signing key                    |
| Output              | Lowercase hex string                                     |

:::danger
Do not parse the JSON body and then stringify it again before verification. The signature must be calculated using the raw request body exactly as received.
:::

***

### Replay protection

The webhook signature proves that the request body was signed with your Cryptonly signing key.

However, the signature alone does not prevent replay attacks. If an attacker captures a valid webhook request, they could try to send the same request again.

To reduce replay risk, your server should do at least one of the following:

* reject webhooks with old `timestamp` values;
* deduplicate deliveries by the `x-webhook-id` header;
* deduplicate events using stable identifiers from `data`;
* store processed invoice, deposit, or withdrawal status transitions.

Example replay protection strategy:

```
Reject webhook events older than 5 minutes.
```

You can also deduplicate by values such as:

* invoice `id`;
* invoice `orderId`;
* deposit `id`;
* withdrawal `id`;
* event type;
* current status;
* previous status.

***

### Recommended verification flow

Your webhook endpoint should follow this order:

1. Receive the webhook request.
2. Read the raw request body.
3. Read the `x-webhook-signature` header.
4. Calculate the expected signature using your webhook signing key.
5. Compare signatures using a timing-safe comparison.
6. Reject invalid signatures with `401 Unauthorized`.
7. Parse the JSON body only after signature verification succeeds.
8. Optionally validate timestamp and deduplicate the event.
9. Process the webhook.
10. Return a `2xx` response.

:::note
Return a successful `2xx` response only after the webhook has been accepted by your server.
:::

***

### Verify a webhook

<Tabs>
<TabItem label="Express">

Use `express.raw()` for the webhook route so you can verify the exact raw request body.

```ts

const app = express();

function verifyCryptonlySignature(
  rawBody: Buffer,
  signatureHeader: string | undefined,
  secret: string,
): boolean {
  if (!signatureHeader || signatureHeader.trim() === '') {
    return false;
  }

  const expectedHex = createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  const expected = expectedHex.toLowerCase();
  const received = signatureHeader.trim().toLowerCase();

  if (expected.length !== received.length || expected.length % 2 !== 0) {
    return false;
  }

  try {
    return timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(received, 'hex'),
    );
  } catch {
    return false;
  }
}

app.post(
  '/webhooks/cryptonly',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const rawBody = req.body as Buffer;
    const signature = req.headers['x-webhook-signature'];
    const secret = process.env.CRYPTONLY_WEBHOOK_SECRET;

    if (!secret) {
      return res.status(500).send('webhook secret is not configured');
    }

    const isValid = verifyCryptonlySignature(
      rawBody,
      typeof signature === 'string' ? signature : undefined,
      secret,
    );

    if (!isValid) {
      return res.status(401).send('invalid signature');
    }

    const webhook = JSON.parse(rawBody.toString('utf8')) as {
      event: string;
      data: unknown;
      timestamp: string;
    };

    // Optional: validate webhook.timestamp for replay protection.
    // Optional: deduplicate by req.headers['x-webhook-id'].

    switch (webhook.event) {
      case 'invoice.statusChanged':
        // Handle invoice status update.
        break;

      case 'withdrawal.statusChanged':
        // Handle withdrawal status update.
        break;

      case 'deposit.statusChanged':
        // Handle deposit status update.
        break;

      default:
        // Unknown event type.
        break;
    }

    return res.status(200).send('ok');
  },
);
```

***

</TabItem>
<TabItem label="@cryptonly/sdk">

If you use the Cryptonly SDK, you can verify invoice, deposit, and withdrawal webhooks with helper functions.

```ts

  verifyInvoiceWebhook,
  verifyWithdrawalWebhook,
  verifyDepositWebhook,
} from '@cryptonly/sdk';

export function cryptonlyWebhookHandler(req: Request, res: Response) {
  const rawBody = req.body as Buffer;
  const signature = req.headers['x-webhook-signature'];
  const secret = process.env.CRYPTONLY_WEBHOOK_SECRET;

  if (!secret) {
    return res.status(500).json({
      error: 'webhook secret is not configured',
    });
  }

  const signatureHeader =
    typeof signature === 'string' ? signature : undefined;

  const invoiceResult = verifyInvoiceWebhook(
    rawBody,
    signatureHeader,
    secret,
  );

  if (invoiceResult.ok) {
    const { event, data, timestamp } = invoiceResult.envelope;

    // Handle invoice.statusChanged.
    // Optional: validate timestamp.
    // Optional: deduplicate event.

    return res.status(200).json({ received: true });
  }

  const withdrawalResult = verifyWithdrawalWebhook(
    rawBody,
    signatureHeader,
    secret,
  );

  if (withdrawalResult.ok) {
    const { event, data, timestamp } = withdrawalResult.envelope;

    // Handle withdrawal.statusChanged.
    // Optional: validate timestamp.
    // Optional: deduplicate event.

    return res.status(200).json({ received: true });
  }

  const depositResult = verifyDepositWebhook(
    rawBody,
    signatureHeader,
    secret,
  );

  if (depositResult.ok) {
    const { event, data, timestamp } = depositResult.envelope;

    // Handle deposit.statusChanged.
    // Optional: validate timestamp.
    // Optional: deduplicate event.

    return res.status(200).json({ received: true });
  }

  return res.status(401).json({
    error: 'webhook verification failed',
  });
}
```

:::caution
The route must still use a raw body parser. SDK verification also depends on the exact raw request body.
:::

</TabItem>
</Tabs>

### Framework notes

Some frameworks parse JSON automatically. For Cryptonly webhook verification, configure raw body handling for the webhook route.

Examples:

| Framework | Recommendation                                                          |
| --------- | ----------------------------------------------------------------------- |
| Express   | Use `express.raw({ type: 'application/json' })` on the webhook route    |
| Next.js   | Disable body parser for the API route and read the raw request stream   |
| Fastify   | Configure a raw body parser for `application/json` on the webhook route |

Do not use the parsed JSON object for signature verification.

***

### Testing webhook verification

You can send a test webhook from the Cryptonly dashboard.

Go to:

```
Settings → Integration → Test webhooks
```

Three test kinds are available:

| Kind               | Event sent                             |
| ------------------ | -------------------------------------- |
| `deposit`          | Sample `invoice.statusChanged` webhook |
| `merchant_deposit` | `deposit.statusChanged`                |
| `withdrawal`       | `withdrawal.statusChanged`             |

Use this to verify that:

* your webhook URL is reachable;
* your server receives the request;
* your server verifies the signature successfully;
* your handler returns a `2xx` response;
* your event processing logic works as expected.

To verify webhooks fired from a real (or simulated) status change instead of a synthetic one, see [Testing your integration](https://docs.cryptonly.net/guides/testing-your-integration.md).

***

## Delivery

### Delivery behavior and retries

Cryptonly expects your server to respond with a successful `2xx` status code.

| Behavior          | Description                                                                 |
| ----------------- | --------------------------------------------------------------------------- |
| Success           | Any `2xx` response is treated as successful                                 |
| Failure           | Non-`2xx` responses, including `3xx` redirects, are treated as failed       |
| Timeout           | Webhook delivery has a 10-second timeout                                    |
| Invalid signature | Your server should return `401 Unauthorized`                                |
| Retry policy      | Cryptonly makes up to 15 delivery attempts in total                         |
| Retry delay       | Retries use exponential backoff starting from 20 seconds                    |
| Not retried       | An invalid URL, or a URL that resolves to a private or reserved address     |

If your endpoint is temporarily unavailable, Cryptonly will retry delivery automatically.

The retry delay grows after each failed attempt. In practice, this means retries are spaced out approximately like this:

| Retry      | Approximate delay after previous failed attempt |
| ---------- | ----------------------------------------------- |
| 1st retry  | 20 seconds                                      |
| 2nd retry  | 40 seconds                                      |
| 3rd retry  | 80 seconds                                      |
| 4th retry  | about 2.7 minutes (160 seconds)                 |
| 5th retry  | about 5.3 minutes (320 seconds)                 |
| 6th retry  | about 10.7 minutes (640 seconds)                |
| 7th retry  | about 21 minutes (1,280 seconds)                |
| 8th retry  | about 43 minutes (2,560 seconds)                |
| 9th retry  | about 85 minutes (5,120 seconds)                |
| 10th retry | about 2.8 hours (10,240 seconds)                |
| 11th retry | about 5.7 hours (20,480 seconds)                |
| 12th retry | about 11.4 hours (40,960 seconds)               |
| 13th retry | about 22.8 hours (81,920 seconds)               |
| 14th retry | about 45.5 hours (163,840 seconds)              |

With 15 total attempts, this usually means:

```
1 initial delivery attempt + up to 14 retry attempts
```

The last retry happens about 3 days 19 hours after the first attempt. After that the delivery is marked `failed`, and an organization admin can still resend it from the delivery log.

:::note
Retry timing may vary slightly depending on queue load and infrastructure conditions.
:::

Your handler should avoid long-running processing before responding. If needed, store the event and process it asynchronously.

***

### Webhook delivery log and manual resend

Every webhook Cryptonly sends is recorded in the merchant dashboard under **Webhooks**. You can also open it from **Settings → Integration → Webhook delivery log**, or from **View deliveries** in a transaction's details.

For each delivery, the log shows:

* the event, the resource it belongs to, and the endpoint URL;
* the delivery status: `pending`, `retrying`, `succeeded` (shown as Delivered), or `failed`;
* every attempt with the exact request headers and body that were sent, and the response status, selected response headers, and the first 4 KB of the response body, or the connection error.

You can filter by status, event, resource type, and date, and search by delivery id (`x-webhook-id`), resource id, or `orderId`. Test webhooks sent from Settings are shown when you filter by the **Test webhooks** resource type.

Organization admins can **Resend now** a delivery that is `failed` or `succeeded`. A resend:

* goes to the endpoint URL recorded on the delivery;
* uses the same `x-webhook-id` and the same `data`;
* is signed with your current signing key and carries a new `timestamp`;
* is recorded as a manual attempt together with who triggered it.

A delivery that is still being retried automatically cannot be resent until it finishes.

***

### Handling duplicate events

Your webhook handler should be idempotent.

Retries and manual resends of one delivery always carry the same `x-webhook-id`. Store the ids you have processed and skip repeats:

```
x-webhook-id
```

Two different deliveries can still describe the same status transition, for example if a status event is emitted twice. To guard against that as well, also deduplicate by:

```
event + resource id + current status
```

Examples:

```
invoice.statusChanged + invoice.id + invoice.status
deposit.statusChanged + deposit.id + deposit.status
withdrawal.statusChanged + withdrawal.id + withdrawal.status
```

If your system already processed the same status transition, return `200 OK` and do not repeat the action.

***

## Going live

### Security checklist

Before going live, make sure that:

* your webhook URL uses HTTPS;
* your server verifies `x-webhook-signature`;
* invalid signatures are rejected with `401 Unauthorized`;
* verification uses the raw request body;
* signature comparison is timing-safe;
* the webhook signing key is stored securely;
* replay protection or event deduplication is implemented;
* webhook processing is idempotent;
* test webhooks pass successfully.

***

### Common mistakes

**Parsing JSON before verification**

Do not verify using a re-stringified JSON object.

Use the raw request body.

**Using Base64**

Cryptonly webhook signatures are lowercase hex strings, not Base64.

**Decoding the signing key as hex bytes**

Use the signing key string as provided.

**Ignoring replay protection**

A valid signature does not automatically prevent a captured request from being replayed.

Use timestamp checks and/or deduplication.

**Returning success before validation**

Verify the signature before doing meaningful work or returning success.

***

## Summary

To verify webhooks correctly:

1. Get your webhook signing key from the account's **Accounts → Settings → Security** page.
2. Configure your webhook URL in **Settings → Integration**.
3. Use a raw body parser for your webhook route.
4. Verify `x-webhook-signature` with HMAC-SHA256.
5. Reject invalid signatures.
6. Add timestamp checks or deduplication.
7. Process the webhook idempotently.

## Related pages

- [Authorization](https://docs.cryptonly.net/api-reference/authorization.md): Where the webhook signing key is issued
- [Testing your integration](https://docs.cryptonly.net/guides/testing-your-integration.md): Send a signed test webhook from the dashboard
