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 retried automatically with exponential backoff (see Delivery and retries)
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
Send a test event
After saving your endpoint URL, use Send test in your dashboard webhook settings to deliver a sample event and confirm your endpoint is reachable and your signature check works. The dashboard reports back whether your endpoint responded, and with which HTTP status.
The test event is a regular subscription.charge_succeeded payload filled with placeholder data and signed with your real webhook secret, exactly like live events. The one difference is a top-level "livemode": false field, which marks it as a test.
livemode is test-only: it appears solely on events sent by Send test. Real (live)
events do not include the field at all. Treat the presence of livemode (with a false value) as
the signal that an event is a test, and don’t expect the field on live deliveries.
Your signing secret
Your webhook secret is issued once, when you first configure a webhook URL, and stays the same when you later change the URL. It is shown in full only at the moment it is generated, so store it securely.
To replace it, use Regenerate secret in your dashboard webhook settings. Rotation is zero-downtime: your previous secret keeps working for 24 hours, so deliveries never fail while you roll out the new one. During that window every event is signed with both secrets, the new one in X-Signature and the previous one in X-Signature-Previous, so an endpoint validating against either keeps accepting events. See Rotating your webhook secret for the full flow.
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). |
payment.flagged | Payer’s address matched an OFAC sanctions list. The payment is permanently halted; the deposit stays held in the deposit address and cannot be captured or refunded. Carries the payment object. |
payment.screening_pending | The screening provider was unreachable; the payment is held and automatically re-screened until the provider resolves. Carries the payment object. |
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 state wins, so treat the latest webhook as authoritative.
Subscription Events
| Event | Description |
|---|---|
subscription.created | Subscription created on-chain (fires alongside subscription_checkout.completed on first subscribe). data.object is the subscription. |
subscription.charge_succeeded | A cycle charge confirmed on-chain. data.object is the subscription plus the charge fields (amount, fee, subscription_charge_id, tx_hash, block_number, charged_at). |
subscription.charge_failed | A cycle charge reached the chain but reverted. Same shape as charge_succeeded, plus failure_reason, and without fee. |
subscription.cap_updated | The subscriber changed cap_amount on the hosted page. data.object is the updated subscription. |
subscription.migrated | The subscription moved to a new subscriber wallet. data.object is the subscription plus old_subscriber, new_subscriber, new_subscriber_balance, new_subscriber_allowance. |
subscription.cancelled | On-chain SubscriptionCancelled observed. data.object is the subscription plus cancelled_by. Subscription is terminal. |
subscription.flagged | The subscription was flagged by compliance screening. data.object is a minimal subscription reference (id, object, subscriber). |
Treat the subscription event list as open: handle unrecognized subscription event types by
ignoring them, so a new event type never breaks your handler.
Payout Events (Beta)
| Event | Description |
|---|---|
customer.activated | Customer completed KYC and can now make payouts. |
customer.action_required | KYC needs more information; redirect the customer to a fresh kyc_url. |
customer.kyc_failed | Customer’s KYC was rejected. |
payout.deposit_detected | The customer’s crypto arrived at the deposit address. |
payout.forwarded | Funds were forwarded into the off-ramp. |
payout.completed | Fiat was delivered to the beneficiary’s bank account. |
payout.failed | The payout could not be completed (see data.object for the reason). |
payout.refunded | Funds were returned to the customer. |
Payout events use the same envelope as everything below; data.object is a payout or customer.
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": "tz4a98xxat96iws9zmbrgj3a",
"object": "payment",
"status": "settled",
"method": "DIRECT",
"amount": "5000000",
"amount_paid": "5000000",
"currency": "USD",
"detected_token": "USDC",
"detected_token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"detected_chain": "eip155:1",
"token_amount": "5000000",
"token_amount_decimal": "5",
"on_chain_id": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
"deposit_address": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"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,
"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": "tz4a98xxat96iws9zmbrgj3a",
"object": "payment",
"status": "escrow_confirmed",
"method": "TWO_STEP",
"amount": "5000000",
"amount_paid": "5000000",
"currency": "USD",
"detected_token": "USDC",
"detected_token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"detected_chain": "eip155:1",
"token_amount": "5000000",
"token_amount_decimal": "5",
"on_chain_id": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
"deposit_address": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"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": "tz4a98xxat96iws9zmbrgj3a",
"object": "payment",
"status": "failed",
"method": "DIRECT",
"amount": "5000000",
"amount_paid": "0",
"currency": "USD",
"detected_token": "USDC",
"detected_token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"detected_chain": "eip155:1",
"token_amount": "5000000",
"token_amount_decimal": "5",
"on_chain_id": "0x1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b",
"deposit_address": "0x5fbdb2315678afecb367f032d93f642f64180aa3",
"deploy_tx_hash": null,
"payer_addresses": ["0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21"],
"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 is the order’s value as a canonical 6-decimal integer string (e.g. "5000000" = $5.00). amount_paid, token_amount, and each transactions[].amount are in the token’s native smallest unit — the raw on-chain transfer value (for 6-decimal USDC they coincide with amount; for an 18-decimal token they don’t), and token_amount_decimal is the human-readable form. detected_chain is the CAIP-2 chain the customer paid on (e.g. "eip155:1"); deposit_address is the payment contract holding the funds; detected_token_address is the token contract. CheckoutSigner reads on_chain_id, detected_chain, detected_token_address, and token_amount straight off this object — pass it to signer.signCapture / signer.signRefund / signer.signRescue. non_funding_transactions_count counts on-chain activity that doesn’t match the payment’s (detected_token, detected_chain): wrong token, wrong chain, duplicate, or post-settle stragglers.
payment.flagged and payment.screening_pending carry the standard payment object shown above; the event type is the signal, and no screening metadata is included.
A flagged payment means the payer’s address matched an OFAC sanctions list. The payment is permanently halted: the deposit stays held in the payment’s deposit address, cannot be captured or refunded through the API, and is not re-screened. Exodus reserves the right to process these funds in accordance with applicable regulations.
A screening_pending payment means the screening provider was temporarily unreachable. The platform automatically re-screens until the provider resolves, after which the payment continues to its normal terminal state.
Subscription Payloads
subscription_checkout.completed fires once per intent and bundles the objects you need 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",
"business_name": "Acme Inc",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"external_customer_id": "cus_42",
"token_symbol": "USDC",
"charge_amount": "9990000",
"recommended_cap": "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": "0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"asset": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"token_symbol": "USDC",
"token_decimals": 6,
"chain": "eip155:1",
"cap_amount": "120000000",
"charge_amount": "9990000",
"period_duration": 2592000,
"last_charged_at": "2026-04-19T12:02:18Z",
"next_charge_at": "2026-05-19T12:02:18Z",
"charge_nonce": 1,
"status": "active",
"paused": false,
"flagged": false,
"created_at": "2026-04-19T12:02:18Z"
},
"first_charge": {
"object": "subscription_charge",
"id": "subc_4567890abcdef123",
"subscription_id": "0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"amount": "9990000",
"fee": "0",
"tx_hash": "0xabc...",
"block_number": "12345678",
"chain": "eip155:1",
"charge_nonce": 0,
"charged_at": "2026-04-19T12:02:18Z"
}
}
}Cycle charges fire two events: subscription.charge_succeeded on success and subscription.charge_failed when an on-chain charge reverts. In both, data.object is the subscription object enriched with the charge fields (amount, subscription_charge_id, tx_hash, block_number, charged_at) — fee on success, failure_reason on failure. The standalone subscription_charge row also lands in the Subscription Charges ledger.
{
"id": "evt_3333cccc4444dddd",
"object": "event",
"type": "subscription.charge_succeeded",
"created_at": "2026-05-19T12:02:18Z",
"data": {
"object": {
"object": "subscription",
"id": "0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"asset": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"token_symbol": "USDC",
"token_decimals": 6,
"chain": "eip155:1",
"cap_amount": "120000000",
"charge_amount": "9990000",
"period_duration": 2592000,
"last_charged_at": "2026-05-19T12:02:18Z",
"next_charge_at": "2026-06-19T12:02:18Z",
"charge_nonce": 2,
"status": "active",
"paused": false,
"flagged": false,
"created_at": "2026-04-19T12:02:18Z",
"amount": "9990000",
"fee": "0",
"subscription_charge_id": "subc_6789abcdef012345",
"tx_hash": "0xdef...",
"block_number": "12346789",
"charged_at": "2026-05-19T12:02:18Z"
}
}
}{
"id": "evt_5555eeee6666ffff",
"object": "event",
"type": "subscription.charge_failed",
"created_at": "2026-06-19T12:00:00Z",
"data": {
"object": {
"object": "subscription",
"id": "0x9f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a",
"subscriber": "0x742d35Cc6634C0532925a3b844Bc9e7595f8fE21",
"asset": "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
"token_symbol": "USDC",
"token_decimals": 6,
"chain": "eip155:1",
"cap_amount": "120000000",
"charge_amount": "9990000",
"period_duration": 2592000,
"last_charged_at": "2026-05-19T12:02:18Z",
"next_charge_at": "2026-06-19T12:02:18Z",
"charge_nonce": 3,
"status": "active",
"paused": true,
"flagged": false,
"created_at": "2026-04-19T12:02:18Z",
"amount": "9990000",
"subscription_charge_id": "subc_7890abcdef123456",
"tx_hash": "0xabc...",
"block_number": "12350000",
"charged_at": "2026-06-19T12:00:00Z",
"failure_reason": "InsufficientBalance"
}
}
}Use event.id for deduplication. Webhooks may be redelivered, and the indexer’s late-backfill
path can write a subscription.charge_succeeded for the same tx_hash already persisted by the API write
path. The subscription_charge_id on the enriched subscription object is stable across both paths.
Delivery and Retries
Webhook delivery is at-least-once: every event is delivered one or more times. A delivery counts as successful only when your endpoint returns a 2xx status within 10 seconds. Any non-2xx response, timeout, or network error is treated as a failed delivery and retried, so design your handler to tolerate seeing the same event again.
Deduplicate on the event id
Every request carries an X-Webhook-Id header equal to the event’s id (the evt_... value in the body). This id is stable across retries: a redelivered event always arrives with the same X-Webhook-Id. Use it as your idempotency key. Record the ids you have already processed and skip any you have seen before, so a redelivery never applies an effect twice.
Retry schedule
Failed deliveries are retried automatically with exponential backoff. After the initial attempt, an event is retried up to 5 more times (6 attempts total), backing off on roughly a 5, 15, 30, then 60 minute schedule between attempts (subsequent retries stay at ~60 minutes). Retries stop as soon as your endpoint returns a 2xx. We give up after 6 attempts, or once the event is more than 7 days old, whichever comes first.
The request body carries the same content across retries, though JSON key order is not guaranteed to
match the first attempt, and each delivery’s X-Signature is always valid for the body it was sent
with. Verify the signature on every delivery, including redeliveries.
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 of 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.
Verifying during a secret rotation
After you regenerate your secret, events are signed with both the new and the previous secret for 24 hours (see Rotating your webhook secret). During that window each request carries an extra X-Signature-Previous header alongside X-Signature. Accept the event if either signature validates, so deliveries keep succeeding no matter which secret your endpoint is currently holding:
function verifyWithRotation(payload, req, currentSecret) {
const primary = req.headers['x-signature'];
const previous = req.headers['x-signature-previous'];
if (verifyWebhookSignature(payload, primary, currentSecret)) return true;
// Present only during a rotation window; absent the rest of the time.
if (previous && verifyWebhookSignature(payload, previous, currentSecret)) {
return true;
}
return false;
}Point currentSecret at your latest secret. Once the window closes, X-Signature-Previous stops being sent and you are back to verifying X-Signature alone.
Rotating your webhook secret
Rotation is zero-downtime: the previous secret stays valid for a fixed 24 hour grace window, during which every event is signed with both secrets. You never miss a delivery while switching over.
- Regenerate the secret from your dashboard (Regenerate secret) or by calling
POST /settings/webhook/secret. The response returns the newwebhook_secretandprevious_secret_expires_at, the moment the old secret stops working. - Until that expiry, events arrive with both
X-Signature(new secret) andX-Signature-Previous(old secret). Keep accepting either while you roll out the change (see Verifying during a secret rotation). - Deploy the new secret to your endpoint.
- When the window closes,
X-Signature-Previousis no longer sent and only the new secret is accepted.
There is a single previous-secret slot. Regenerating again within the window immediately invalidates the older secret and restarts the 24 hour window for the one you just replaced, so rotate only as fast as you can deploy.
Handling Webhooks
Best Practices
- Return quickly: Respond with a 2xx status code within 10 seconds
- Process asynchronously: Queue events for background processing if needed
- Handle duplicates: Delivery is at-least-once; use
event.id(also sent as theX-Webhook-Idheader) to 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.
Compliance (optional): payment.flagged, payment.screening_pending. Subscribe if you want to reflect halted or held payments in your own system. The platform manages the compliance lifecycle regardless of whether you subscribe.
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);
// The payment object does not echo your checkout metadata; correlate by the payment id you persisted.
await fulfillOrder(settledPayment.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('Payment expired:', expiredPayment.id);
await releaseHeldInventory(expiredPayment.id);
break;
}
case 'payment.cancelled': {
const cancelledPayment = event.data.object;
console.log('Payment cancelled:', cancelledPayment.id);
await releaseHeldInventory(cancelledPayment.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.id, 'refunded');
break;
}
case 'payment.flagged': {
// Payer is OFAC-restricted. Funds stay held in the deposit address; they cannot be captured or refunded.
const flaggedPayment = event.data.object;
console.log('Payment flagged, halted:', flaggedPayment.id);
await flagForReview(flaggedPayment.id);
break;
}
case 'payment.screening_pending': {
// Screening provider was unavailable; the payment is held and auto re-screened.
const pendingPayment = event.data.object;
console.log('Payment held for screening:', pendingPayment.id);
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.charge_succeeded': {
// data.object is the subscription enriched with charge fields (amount, fee, tx_hash, ...).
const charge = event.data.object;
await recordCharge(charge);
break;
}
case 'subscription.charge_failed': {
// `failure_reason` is a typed contract error name; `id` is the subscription id.
const charge = event.data.object;
await handleFailedCharge(charge.id, charge.failure_reason);
break;
}
case 'subscription.cap_updated': {
// Subscriber raised/lowered the cap on the hosted page.
const sub = event.data.object;
console.log('Cap updated:', sub.id, sub.cap_amount);
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 (hex) |
| X-Signature-Previous | HMAC-SHA256 signature computed with your previous secret. Sent only during a rotation grace window. |
| X-Webhook-Id | The event id, stable across retries. Use it to deduplicate. |
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)
- If you recently regenerated the secret, also check
X-Signature-Previous: during the 24 hour rotation window events validate against either your new or previous secret
Duplicate events
- Delivery is at-least-once, so the same event can arrive more than once
- Use the
event.idfield (also sent as theX-Webhook-Idheader) to deduplicate events - Implement idempotent event handlers
