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.created | Payment Created | A new payment has been created. |
payment.processing | Payment Processing | Transaction hash submitted, awaiting on-chain confirmation. |
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. |
payment.refunded | Payment Refunded | A refund for this payment has been completed. |
refund.created | Refund Created | A new refund has been initiated. |
refund.completed | Refund Completed | Refund transaction confirmed on-chain. |
refund.failed | Refund Failed | Refund transaction failed. |
settlement.requested | Settlement Requested | A settlement has been requested. |
settlement.approved | Settlement Approved | Settlement has been approved. |
settlement.completed | Settlement Completed | Settlement transaction confirmed. |
settlement.failed | Settlement Failed | Settlement transaction failed. |
customer.created | Customer Created | A new customer record was created. |
customer.updated | Customer Updated | A customer record was updated. |
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: v1=a4b3c2d1e0f9...
X-Crypax-Event: payment.confirmed
X-Crypax-Timestamp: 1704067200
{
"id": "pay_01HZ...",
"status": "confirmed",
"txHash": "0xabcdef1234...",
"blockNumber": 12345,
"amount": "10.00",
"currency": "native",
"chainId": 41956
}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 | v1={hex} — HMAC-SHA256 of {timestamp}.{rawBody} signed with your webhook secret |
X-Crypax-Event | Event type, e.g. payment.confirmed |
X-Crypax-Timestamp | Unix timestamp (seconds) of when the event was sent. Use this to reject stale requests. |
Verification Algorithm
The signed payload is timestamp + '.' + rawBody. Compute HMAC-SHA256 of this string using your webhook secret, encode as hex, and compare with the value after v1= in the signature header. Use crypto.timingSafeEqual to prevent timing attacks. Reject events with a timestamp older than 5 minutes.
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signatureHeader, timestampHeader, secret) {
// signatureHeader format: "v1=<hex>"
const signedPayload = timestampHeader + '.' + rawBody;
const expected = 'v1=' + crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Reject stale requests (older than 5 minutes)
const age = Math.floor(Date.now() / 1000) - Number(timestampHeader);
if (age > 300) {
throw new Error('Webhook timestamp too old');
}
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();
app.post('/webhooks/crypax', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-crypax-signature'];
const timestamp = req.headers['x-crypax-timestamp'];
const eventType = req.headers['x-crypax-event'];
const secret = process.env.CRYPAX_WEBHOOK_SECRET;
// Build signed payload: timestamp.rawBody
const signedPayload = timestamp + '.' + req.body.toString();
const expected = 'v1=' + crypto
.createHmac('sha256', secret)
.update(signedPayload)
.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 (eventType) {
case 'payment.confirmed':
console.log('Payment confirmed:', payload.id, payload.txHash);
break;
case 'payment.failed':
console.log('Payment failed:', payload.id);
break;
case 'payment.expired':
console.log('Payment expired:', payload.id);
break;
case 'refund.completed':
console.log('Refund completed:', 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.
import { Crypax } from '@crypax/node';
import express from 'express';
const app = express();
const crypax = new Crypax('sk_live_...');
app.post('/webhooks/crypax', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-crypax-signature'] as string;
try {
const event = crypax.webhooks.constructEvent(
req.body, // raw body string
signature,
process.env.CRYPAX_WEBHOOK_SECRET!,
);
switch (event.type) {
case 'payment.confirmed':
console.log('Confirmed:', event.data.id, event.data.txHash);
break;
case 'payment.expired':
console.log('Expired:', event.data.id);
break;
case 'refund.completed':
console.log('Refund completed:', event.data.id);
break;
case 'customer.created':
console.log('New customer:', event.data.id);
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://.