Webhooks
Cryptonly signs each webhook with HMAC-SHA256 over the raw JSON body.
Webhooks are sent for invoice, deposit, and withdrawal status updates.
How webhooks are sent
Section titled “How webhooks are sent”How webhook signing works
Section titled “How webhook signing works”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.
Webhook HTTP contract
Section titled “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. |
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.
Webhook payload shape
Section titled “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:
depositTransactionHashactuallyReceivedAmountactuallyReceivedAmountUsd
For full field lists and status values, see:
Supported webhook events
Section titled “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
Section titled “Default webhook URLs”You can configure default webhook URLs in the Cryptonly merchant dashboard.
Go to:
Settings → IntegrationYou 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
webhookUrlin 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.
Signing keys
Section titled “Signing keys”Getting your webhook signing key
Section titled “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 → SecurityThe page shows a fingerprint of the current key. Only organization admins can view the fingerprint or rotate the key.
How to obtain the key
- Open the Cryptonly merchant/admin dashboard.
- Go to Accounts and select the account.
- Open the Settings tab, then Security.
- Find the Webhook signing key section.
- Click Rotate key.
- Confirm the rotation.
- Copy the new signing key immediately.
- Store it in your server environment variables.
Example:
CRYPTONLY_WEBHOOK_SECRET=your_signing_key_hereImportant key rotation behavior
Section titled “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
Section titled “Verifying webhooks”Signature format
Section titled “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 |
Replay protection
Section titled “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
timestampvalues; - deduplicate deliveries by the
x-webhook-idheader; - 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
Section titled “Recommended verification flow”Your webhook endpoint should follow this order:
- Receive the webhook request.
- Read the raw request body.
- Read the
x-webhook-signatureheader. - Calculate the expected signature using your webhook signing key.
- Compare signatures using a timing-safe comparison.
- Reject invalid signatures with
401 Unauthorized. - Parse the JSON body only after signature verification succeeds.
- Optionally validate timestamp and deduplicate the event.
- Process the webhook.
- Return a
2xxresponse.
Verify a webhook
Section titled “Verify a webhook”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'); },);If you use the Cryptonly SDK, you can verify invoice, deposit, and withdrawal webhooks with helper functions.
import { verifyInvoiceWebhook, verifyWithdrawalWebhook, verifyDepositWebhook,} from '@cryptonly/sdk';
import type { Request, Response } from 'express';
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', });}Framework notes
Section titled “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
Section titled “Testing webhook verification”You can send a test webhook from the Cryptonly dashboard.
Go to:
Settings → Integration → Test webhooksThree 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
2xxresponse; - 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.
Delivery
Section titled “Delivery”Delivery behavior and retries
Section titled “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 attemptsThe 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.
Webhook delivery log and manual resend
Section titled “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), orfailed; - 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-idand the samedata; - 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
Section titled “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-idTwo 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 statusExamples:
invoice.statusChanged + invoice.id + invoice.statusdeposit.statusChanged + deposit.id + deposit.statuswithdrawal.statusChanged + withdrawal.id + withdrawal.statusIf your system already processed the same status transition, return 200 OK and do not repeat the action.
Going live
Section titled “Going live”Security checklist
Section titled “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
Section titled “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
Section titled “Summary”To verify webhooks correctly:
- Get your webhook signing key from the account’s Accounts → Settings → Security page.
- Configure your webhook URL in Settings → Integration.
- Use a raw body parser for your webhook route.
- Verify
x-webhook-signaturewith HMAC-SHA256. - Reject invalid signatures.
- Add timestamp checks or deduplication.
- Process the webhook idempotently.