Webhooks
Webhooks allow you to receive real-time notifications about payment events in your account. When an event occurs (like an on-chain payment confirmation or subscription renewal), we send an HTTP POST request to your configured endpoint.
Webhooks are essential for keeping your system in sync with on-chain payment events, especially since blockchain transactions require confirmation time.
How It Works
- Configure your endpoint - Set up a publicly accessible URL to receive webhook events
- Receive events - We POST event data to your endpoint when events occur
- Process and respond - Your server processes the event and returns a 2xx status code
- Retry on failure - Failed deliveries are automatically retried
Setting Up Webhooks
Configure your webhook endpoint in your dashboard settings. You’ll need to provide:
- Endpoint URL: A publicly accessible HTTPS URL
- Events to subscribe: Select which events you want to receive
- Secret key: Used to verify webhook signatures
Webhook Events
Payment Events
| Event | Description |
|---|---|
payment.deposit_confirmed | Customer’s on-chain deposit reached required confirmations; contract deploy queued. |
payment.settled | DIRECT mode: payment contract deployed, funds moved to settlement address. |
payment.escrow_confirmed | TWO_STEP mode: payment contract deployed, funds in escrow. Ready to capture or refund. |
payment.expired | Checkout expires_at passed with no valid deposit. |
payment.cancelled | Merchant called POST /checkouts/:id/cancel on a pending checkout. |
payment.failed | Customer’s confirmed deposit was lost to a chain reorg beyond recovery. |
payment.refunded | Payment refunded to customer (two-step mode). |
Subscription Checkout Events
| Event | Description |
|---|---|
subscription_checkout.created | POST /subscription-checkouts succeeded. Hosted page is now live. |
subscription_checkout.completed | Subscriber signed and the on-chain subscribeAndCharge confirmed. Payload contains the Subscription + first_charge. |
subscription_checkout.cancelled | Intent was cancelled via PATCH /subscription-checkouts/:id/cancel or the customer-facing cancel page. |
subscription_checkout.expired | expires_at passed without a successful subscribe. |
A late subscription_checkout.completed can arrive after a subscription_checkout.cancelled
or subscription_checkout.expired if the subscriber’s on-chain transaction was in mempool when
the off-chain state was flipped. On-chain wins — treat the latest webhook as authoritative.
Subscription Events
| Event | Description |
|---|---|
subscription.created | Subscription created on-chain (fires alongside subscription_checkout.completed on first subscribe). |
subscription.charged | Cycle charge attempt completed (either succeeded or failed — inspect data.object.status and failure_reason). |
subscription.charged_ad_hoc | Ad-hoc charge attempt completed (same status + failure_reason semantics). |
subscription.cancelled | On-chain SubscriptionCancelled event observed. Subscription is terminal. |
Event Payload Structure
All webhook events share the same envelope; only data.object differs by event type.
{
"id": "evt_1234567890abcdef",
"object": "event",
"type": "payment.settled",
"created_at": "2024-01-15T12:30:00Z",
"data": {
"object": {
"id": "pay_0987654321fedcba",
"object": "payment",
"status": "settled",
"method": "DIRECT",
"amount": "5000000",
"amount_paid": "5000000",
"currency": "USD",
"detected_token": "USDC",
"detected_network": "ethereum",
"on_chain_id": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
"deploy_tx_hash": "0x8a9c67b2d1e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9",
"payer_addresses": ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21"],
"transactions": [
{
"tx_hash": "0xabc...",
"from": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"amount": "5000000",
"token": "USDC",
"network": "ethereum",
"block_number": 12345678,
"is_funding": true
}
],
"non_funding_transactions_count": 0,
"metadata": { "order_id": "12345", "product_name": "Premium Plan" },
"created_at": "2024-01-15T12:00:00Z",
"updated_at": "2024-01-15T12:30:00Z"
}
}
}{
"id": "evt_abcdef1234567890",
"object": "event",
"type": "payment.escrow_confirmed",
"created_at": "2024-01-15T14:00:00Z",
"data": {
"object": {
"id": "pay_0987654321fedcba",
"object": "payment",
"status": "escrow_confirmed",
"method": "TWO_STEP",
"amount": "5000000",
"amount_paid": "5000000",
"currency": "USD",
"detected_token": "USDC",
"detected_network": "ethereum",
"on_chain_id": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
"deploy_tx_hash": "0x8a9c67b2d1e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9",
"payer_addresses": ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21"],
"transactions": [
{
"tx_hash": "0xdef...",
"from": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"amount": "5000000",
"token": "USDC",
"network": "ethereum",
"block_number": 12346789,
"is_funding": true
}
],
"non_funding_transactions_count": 0,
"created_at": "2024-01-15T12:30:00Z",
"updated_at": "2024-01-15T14:00:00Z"
}
}
}{
"id": "evt_fedcba0987654321",
"object": "event",
"type": "payment.failed",
"created_at": "2024-01-20T15:30:00Z",
"data": {
"object": {
"id": "pay_0987654321fedcba",
"object": "payment",
"status": "failed",
"method": "DIRECT",
"amount": "5000000",
"currency": "USD",
"on_chain_id": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
"failure_reason": "deposit_lost_to_reorg",
"failure_message": "The customer's deposit was confirmed but later lost to a chain reorganization.",
"transactions": [],
"non_funding_transactions_count": 0,
"created_at": "2024-01-20T15:00:00Z",
"updated_at": "2024-01-20T15:30:00Z"
}
}
}amount and amount_paid are canonical 6-decimal integer strings (e.g., "5000000" = $5.00 USDC). non_funding_transactions_count counts on-chain activity that doesn’t match the payment’s (detected_token, detected_network) — wrong token, wrong chain, duplicate, or post-settle stragglers.
Subscription Payloads
subscription_checkout.completed fires once per intent and bundles every object the merchant needs to update local state — the matched intent, the materialized subscription, and the first charge — so handlers don’t need follow-up GETs.
{
"id": "evt_1111aaaa2222bbbb",
"object": "event",
"type": "subscription_checkout.completed",
"created_at": "2026-04-19T12:02:18Z",
"data": {
"object": {
"object": "subscription_checkout",
"id": "schk_1234567890abcdef",
"status": "completed",
"onchain_id": "0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"external_customer_id": "cus_42",
"token_symbol": "USDC",
"charge_amount": "9990000",
"cap_amount": "120000000",
"period_duration": 2592000,
"supported_chains": ["eip155:1", "eip155:137"],
"completed_at": "2026-04-19T12:02:18Z",
"created_at": "2026-04-19T12:00:00Z",
"metadata": { "external_plan_ref": "pro_monthly" }
},
"subscription": {
"object": "subscription",
"id": "sub_abc123def456",
"status": "active",
"onchain_id": "0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"chain": "eip155:1",
"subscription_manager_address": "0xA1B2C3D4E5F6789012345678901234567890ABCD",
"token_symbol": "USDC",
"charge_amount": "9990000",
"cap_amount": "120000000",
"period_duration": 2592000,
"charge_nonce": 1,
"charge_amount_update_nonce": 0,
"cancel_at_period_end": false,
"last_charged_at": "2026-04-19T12:02:18Z",
"next_charge_at": "2026-05-19T12:02:18Z",
"created_at": "2026-04-19T12:02:18Z"
},
"first_charge": {
"object": "subscription_charge",
"id": "subc_4567890abcdef123",
"subscription_id": "sub_abc123def456",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"amount": "9990000",
"fee": "0",
"tx_hash": "0xabc...",
"chain": "eip155:1",
"charge_nonce": 0,
"charged_at": "2026-04-19T12:02:18Z",
"status": "succeeded",
"kind": "cycle",
"failure_reason": null
}
}
}subscription.charged (and subscription.charged_ad_hoc) fires on every charge attempt — both succeeded and failed. Inspect data.object.status to branch:
{
"id": "evt_3333cccc4444dddd",
"object": "event",
"type": "subscription.charged",
"created_at": "2026-05-19T12:02:18Z",
"data": {
"object": {
"object": "subscription_charge",
"id": "subc_6789abcdef012345",
"subscription_id": "sub_abc123def456",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"amount": "9990000",
"fee": "0",
"tx_hash": "0xdef...",
"chain": "eip155:1",
"charge_nonce": 1,
"charged_at": "2026-05-19T12:02:18Z",
"status": "succeeded",
"kind": "cycle",
"failure_reason": null
}
}
}{
"id": "evt_5555eeee6666ffff",
"object": "event",
"type": "subscription.charged",
"created_at": "2026-06-19T12:00:00Z",
"data": {
"object": {
"object": "subscription_charge",
"id": "subc_7890abcdef123456",
"subscription_id": "sub_abc123def456",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"amount": "9990000",
"fee": null,
"tx_hash": "0xabc...",
"chain": "eip155:1",
"charge_nonce": 2,
"charged_at": "2026-06-19T12:00:00Z",
"status": "failed",
"kind": "cycle",
"failure_reason": "InsufficientBalance"
}
}
}Use event.id for deduplication — webhooks may be redelivered, and the indexer’s late-backfill
path can write a subscription.charged for the same tx_hash already persisted by the API write
path. The id on the inner subscription_charge is stable across both paths.
Verifying Webhook Signatures
Every webhook request includes a signature in the X-Signature header. Verify this signature to ensure the request came from our servers.
Signature Verification
The signature is computed using HMAC-SHA256 with your webhook secret:
import crypto from 'crypto';
function verifyWebhookSignature(payload, signature, secret) {
if (!signature) return false;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
const sigBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (sigBuffer.length !== expectedBuffer.length) return false;
return crypto.timingSafeEqual(sigBuffer, expectedBuffer);
}
// In your webhook handler
// Use express.raw() for webhook routes to access the raw request body
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const payload = req.body; // Buffer — raw bytes before JSON parsing
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Process the event
const event = JSON.parse(req.body);
console.log('Received event:', event.type);
res.status(200).send('OK');
});Always verify webhook signatures in production to prevent malicious requests.
Handling Webhooks
Best Practices
- Return quickly: Respond with a 2xx status code within 30 seconds
- Process asynchronously: Queue events for background processing if needed
- Handle duplicates: Events may be delivered more than once; use
event.idto deduplicate - Verify signatures: Always verify the webhook signature before processing
Which Events Should I Subscribe To?
Minimal (terminal signals only): payment.settled, payment.escrow_confirmed, payment.expired, payment.cancelled, payment.failed. Covers fulfilment and reconciliation.
Full (adds customer-done signal): All of the above plus payment.deposit_confirmed. Useful for showing an optimistic “customer paid, waiting for contract deploy” UI state.
payment.deposit_confirmed always fires before payment.settled / payment.escrow_confirmed. Merchants who only care about terminal states can skip it.
Example Handler
app.post('/webhooks/payments', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-signature'];
// Verify signature
if (!verifyWebhookSignature(req.body, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Handle the event
switch (event.type) {
case 'payment.deposit_confirmed': {
const depositPayment = event.data.object;
console.log('Deposit confirmed, deploy queued:', depositPayment.id);
// Optional: show "payment received, processing" UI to customer
break;
}
case 'payment.settled': {
const settledPayment = event.data.object;
console.log('Payment settled:', settledPayment.id);
await fulfillOrder(settledPayment.metadata?.order_id);
break;
}
case 'payment.escrow_confirmed': {
const escrowPayment = event.data.object;
console.log('Funds in escrow:', escrowPayment.id);
console.log('on_chain_id for capture/refund:', escrowPayment.on_chain_id);
await captureOrRefund(escrowPayment.id, escrowPayment.on_chain_id);
break;
}
case 'payment.expired': {
const expiredPayment = event.data.object;
console.log('Checkout expired:', expiredPayment.id);
await releaseHeldInventory(expiredPayment.metadata?.order_id);
break;
}
case 'payment.cancelled': {
const cancelledPayment = event.data.object;
console.log('Checkout cancelled:', cancelledPayment.id);
await releaseHeldInventory(cancelledPayment.metadata?.order_id);
break;
}
case 'payment.failed': {
const failedPayment = event.data.object;
console.log('Payment failed:', failedPayment.id, failedPayment.failure_reason);
await notifyCustomer(failedPayment.id, failedPayment.failure_message);
break;
}
case 'payment.refunded': {
const refundedPayment = event.data.object;
console.log('Payment refunded:', refundedPayment.id);
await updateOrderStatus(refundedPayment.metadata?.order_id, 'refunded');
break;
}
case 'subscription_checkout.completed': {
// First successful subscribe — atomic with the first charge.
// Payload bundles the intent, the subscription, and the first charge.
const { object: intent, subscription, first_charge } = event.data;
console.log('Subscription started:', subscription.id);
await grantAccess(intent.external_customer_id);
await recordCharge(first_charge);
break;
}
case 'subscription_checkout.cancelled':
case 'subscription_checkout.expired': {
const intent = event.data.object;
console.log(`${event.type}:`, intent.id);
await releaseHeldInventory(intent.metadata?.order_id);
break;
}
case 'subscription.created': {
// Fires alongside subscription_checkout.completed for the same on-chain event.
// Most merchants act on the checkout webhook instead — handle this if you
// care about subscriptions created via the indexer-only path.
const subscription = event.data.object;
console.log('Subscription created:', subscription.id);
break;
}
case 'subscription.charged':
case 'subscription.charged_ad_hoc': {
const charge = event.data.object;
if (charge.status === 'succeeded') {
await recordCharge(charge);
} else {
// Failed attempt — `failure_reason` is a typed contract error name.
await handleFailedCharge(charge.subscription_id, charge.failure_reason);
}
break;
}
case 'subscription.cancelled': {
const cancelledSub = event.data.object;
await revokeAccess(cancelledSub.id);
break;
}
default:
console.log(`Unhandled event type: ${event.type}`);
}
res.status(200).send('OK');
});Testing Webhooks
Test Mode Events
Events from test mode transactions are sent to your configured webhook endpoint just like live events. Use test mode to verify your integration.
Local Development
For local development, use a tool like ngrok to expose your local server:
ngrok http 3000Then configure the ngrok URL as your webhook endpoint in the dashboard.
Webhook Headers
Each webhook request includes the following headers:
| Header | Description |
|---|---|
| Content-Type | application/json |
| X-Signature | HMAC-SHA256 signature of the request body |
| X-Event-Id | Unique identifier for the event |
| X-Event-Type | The type of event (e.g., payment.settled) |
| X-Timestamp | Unix timestamp when the event was created |
Troubleshooting
Events not being received
- Verify your endpoint URL is correct and publicly accessible
- Check that your endpoint accepts POST requests
- Ensure your firewall allows requests from our IP addresses
Signature verification failing
- Confirm you’re using the correct webhook secret
- Ensure you’re verifying against the raw request body (before JSON parsing)
- Check that you’re using the correct hashing algorithm (SHA256)
Duplicate events
- Use the
event.idfield to deduplicate events - Implement idempotent event handlers
