Skip to content

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

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


Each webhook request includes a signature header:

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.


ItemValue
MethodPOST
Content typeapplication/json
Signature headerx-webhook-signature
Signature formatLowercase hex string
EncodingUTF-8 JSON body
RedirectsNot followed

Request headers:

HeaderValue
Content-Typeapplication/json
User-AgentCryptonly-Webhooks/1
x-webhook-idDelivery id (UUID). The same on every attempt of one delivery, including retries and manual resends
x-webhook-timestampUnix time in seconds when this attempt was sent
x-webhook-eventEvent name, the same as event in the body
x-webhook-signatureHMAC-SHA256 of the raw body.

Example webhook body:

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


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.

Eventdata shape
invoice.statusChangedInvoice object (same fields as merchant GET invoice) + optional previousStatus
deposit.statusChangedDeposit object + optional previousStatus and deposit-specific fields below
withdrawal.statusChangedWithdrawal 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:


Cryptonly currently sends signed webhooks for status changes such as:

EventDescription
invoice.statusChangedSent when an invoice status changes
deposit.statusChangedSent when a standalone deposit status changes
withdrawal.statusChangedSent 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

EventWhen Cryptonly sends it
invoice.statusChangedOn invoice create (initial created) and on every subsequent status transition
deposit.statusChangedOn each deposit status transition only (not on initial create)
withdrawal.statusChangedOn 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).


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:

ResourceResolution order
InvoiceRequest webhookUrl → defaultInvoiceWebhookUrl
WithdrawalRequest webhookUrl → defaultWithdrawalWebhookUrl
DepositRequest webhookUrl → defaultDepositWebhookUrl

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


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.

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:

Terminal window
CRYPTONLY_WEBHOOK_SECRET=your_signing_key_here

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.


Cryptonly uses:

HMAC-SHA256(rawBody, webhookSigningKey)

Where:

PartDescription
rawBodyExact raw UTF-8 bytes of the JSON body sent by Cryptonly
webhookSigningKeyYour organization webhook signing key
OutputLowercase hex string

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.

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.

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

import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
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');
},
);

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

Examples:

FrameworkRecommendation
ExpressUse express.raw({ type: 'application/json' }) on the webhook route
Next.jsDisable body parser for the API route and read the raw request stream
FastifyConfigure a raw body parser for application/json on the webhook route

Do not use the parsed JSON object for signature verification.


You can send a test webhook from the Cryptonly dashboard.

Go to:

Settings → Integration → Test webhooks

Three test kinds are available:

KindEvent sent
depositSample invoice.statusChanged webhook
merchant_depositdeposit.statusChanged
withdrawalwithdrawal.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.


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

BehaviorDescription
SuccessAny 2xx response is treated as successful
FailureNon-2xx responses, including 3xx redirects, are treated as failed
TimeoutWebhook delivery has a 10-second timeout
Invalid signatureYour server should return 401 Unauthorized
Retry policyCryptonly makes up to 15 delivery attempts in total
Retry delayRetries use exponential backoff starting from 20 seconds
Not retriedAn 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:

RetryApproximate delay after previous failed attempt
1st retry20 seconds
2nd retry40 seconds
3rd retry80 seconds
4th retryabout 2.7 minutes (160 seconds)
5th retryabout 5.3 minutes (320 seconds)
6th retryabout 10.7 minutes (640 seconds)
7th retryabout 21 minutes (1,280 seconds)
8th retryabout 43 minutes (2,560 seconds)
9th retryabout 85 minutes (5,120 seconds)
10th retryabout 2.8 hours (10,240 seconds)
11th retryabout 5.7 hours (20,480 seconds)
12th retryabout 11.4 hours (40,960 seconds)
13th retryabout 22.8 hours (81,920 seconds)
14th retryabout 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.

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


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.


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.


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.

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.


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.