Webhooks
Receive real-time payment events via signed POST requests. Crypax delivers webhooks with HMAC-SHA256 signatures so you can verify authenticity before processing.
Overview
When a payment's status changes, Crypax sends a POST request to your configured webhook URL. You must respond with a 2xx status code within 10 seconds. If delivery fails, Crypax retries up to 4 times. Configure your webhook URL in the Webhooks section of the dashboard.
Event Types
Crypax fires the following events. Subscribe to individual events or leave the selection empty to receive all.
| Event | Name | When |
|---|---|---|
payment.confirmed | Payment Confirmed | Transaction verified on-chain. Safe to fulfill the order. |
payment.failed | Payment Failed | Transaction submitted but on-chain verification failed. |
payment.expired | Payment Expired | Payment was not confirmed before expiresAt. |
Payload Structure
Crypax sends a JSON body along with two headers: X-Crypax-Signature for authentication and X-Crypax-Event for the event type.
POST /your-webhook-endpoint HTTP/1.1
Content-Type: application/json
X-Crypax-Signature: sha256=a4b3c2d1e0f9...
X-Crypax-Event: payment.confirmed
{
"id": "pay_01HZ...",
"status": "confirmed",
"txHash": "0xabcdef1234...",
"blockNumber": 12345,
"amount": "1000000000000000000",
"currency": "native",
"orderId": "order_123"
}Payload Fields
| Field | Type | Description |
|---|---|---|
id | string | Unique payment ID |
status | string | Payment status at the time of the event |
txHash | string | null | On-chain transaction hash (null if not submitted) |
blockNumber | number | null | Block number where tx was included (null if not confirmed) |
amount | string | Amount in smallest unit (wei for PLM) |
currency | string | 'native' for PLM, or ERC20 contract address |
orderId | string | null | Your internal order ID passed when creating the payment |
Signature Verification
Every webhook request includes an X-Crypax-Signature header. Always verify this before processing the event to ensure the request came from Crypax.
| Header | Description |
|---|---|
X-Crypax-Signature | sha256={HMAC-SHA256 hex of raw body, signed with your webhook secret} |
X-Crypax-Event | Event type, e.g. payment.confirmed |
Verification Algorithm
Compute HMAC-SHA256 of the raw request body (bytes before JSON parsing) using your webhook secret, then compare with the value after the sha256= prefix. Use crypto.timingSafeEqual to prevent timing attacks.
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
// signatureHeader format: "sha256=<hex>"
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signatureHeader),
Buffer.from(expected)
);
}Handler Examples
A webhook handler must parse the raw body, verify the signature, and respond with 200 immediately — defer any heavy work to a background queue.
Express.js (manual verification)
const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: Use raw body parser — JSON.parse changes byte representation
app.post('/webhooks/crypax', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-crypax-signature'];
const event = req.headers['x-crypax-event'];
const secret = process.env.CRYPAX_WEBHOOK_SECRET;
// Verify signature
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(400).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(req.body.toString());
switch (event) {
case 'payment.confirmed':
// Fulfill the order
console.log('Payment confirmed:', payload.id, payload.txHash);
fulfillOrder(payload.orderId);
break;
case 'payment.failed':
console.log('Payment failed:', payload.id);
break;
case 'payment.expired':
console.log('Payment expired:', payload.id);
break;
}
res.json({ received: true });
});Express.js (@crypax/node SDK)
The Node.js SDK provides a <code>constructEvent</code> helper that verifies the signature and returns a typed event object.
const { Crypax } = require('@crypax/node');
const express = require('express');
const app = express();
const crypax = new Crypax('sk_live_your_secret_key');
app.post('/webhooks/crypax', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-crypax-signature'];
try {
const event = crypax.webhooks.constructEvent(
req.body,
signature,
process.env.CRYPAX_WEBHOOK_SECRET
);
switch (event.type) {
case 'payment.confirmed':
fulfillOrder(event.data.orderId);
break;
case 'payment.expired':
cancelOrder(event.data.orderId);
break;
}
res.json({ received: true });
} catch (err) {
res.status(400).json({ error: 'Invalid signature' });
}
});Retry Policy
If your endpoint does not respond with a 2xx status within 10 seconds, Crypax retries delivery with the following schedule:
| Attempt | Delay | Notes |
|---|---|---|
| 1 | 0s | Initial delivery |
| 2 | 1s | First retry |
| 3 | 5s | Second retry |
| 4 | 30s | Final retry — no further attempts after this |
Once a 2xx response is received, retries stop. Each attempt is logged in the Delivery Logs section of the webhook detail page in your dashboard.
Best Practices
- Use the raw body for signature verification. Pass
express.raw()or equivalent. Parsing JSON first changes the byte representation and invalidates the signature. - Use timing-safe comparison. Use
crypto.timingSafeEqualinstead of===to prevent timing side-channel attacks. - Make your handler idempotent. The same event may be delivered more than once. Use the
idfield to deduplicate. - Respond 200 immediately. Do not wait for database writes or downstream calls. Acknowledge first, process asynchronously.
- HTTPS endpoints only. Crypax rejects webhook URLs that do not use
https://.