Skip to Content

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

  1. Configure your endpoint - Set up a publicly accessible URL to receive webhook events
  2. Receive events - We POST event data to your endpoint when events occur
  3. Process and respond - Your server processes the event and returns a 2xx status code
  4. 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

EventDescription
payment.deposit_confirmedCustomer’s on-chain deposit reached required confirmations; contract deploy queued.
payment.settledDIRECT mode: payment contract deployed, funds moved to settlement address.
payment.escrow_confirmedTWO_STEP mode: payment contract deployed, funds in escrow. Ready to capture or refund.
payment.expiredCheckout expires_at passed with no valid deposit.
payment.cancelledMerchant called POST /checkouts/:id/cancel on a pending checkout.
payment.failedCustomer’s confirmed deposit was lost to a chain reorg beyond recovery.
payment.refundedPayment refunded to customer (two-step mode).

Subscription Checkout Events

EventDescription
subscription_checkout.createdPOST /subscription-checkouts succeeded. Hosted page is now live.
subscription_checkout.completedSubscriber signed and the on-chain subscribeAndCharge confirmed. Payload contains the Subscription + first_charge.
subscription_checkout.cancelledIntent was cancelled via PATCH /subscription-checkouts/:id/cancel or the customer-facing cancel page.
subscription_checkout.expiredexpires_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

EventDescription
subscription.createdSubscription created on-chain (fires alongside subscription_checkout.completed on first subscribe).
subscription.chargedCycle charge attempt completed (either succeeded or failed — inspect data.object.status and failure_reason).
subscription.charged_ad_hocAd-hoc charge attempt completed (same status + failure_reason semantics).
subscription.cancelledOn-chain SubscriptionCancelled event observed. Subscription is terminal.

Event Payload Structure

All webhook events share the same envelope; only data.object differs by event type.

payment.settled
{
  "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"
    }
  }
}
payment.escrow_confirmed
{
  "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"
    }
  }
}
payment.failed
{
  "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.

subscription_checkout.completed
{
  "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:

subscription.charged (succeeded)
{
  "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
    }
  }
}
subscription.charged (failed)
{
  "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

  1. Return quickly: Respond with a 2xx status code within 30 seconds
  2. Process asynchronously: Queue events for background processing if needed
  3. Handle duplicates: Events may be delivered more than once; use event.id to deduplicate
  4. 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 3000

Then configure the ngrok URL as your webhook endpoint in the dashboard.

Webhook Headers

Each webhook request includes the following headers:

HeaderDescription
Content-Typeapplication/json
X-SignatureHMAC-SHA256 signature of the request body
X-Event-IdUnique identifier for the event
X-Event-TypeThe type of event (e.g., payment.settled)
X-TimestampUnix 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.id field to deduplicate events
  • Implement idempotent event handlers

Start building

XO

Request Demo

Schedule a call with our team

Select a product
Arrow right

Start building
Grateful

Contact Us

We're here to help