Skip to main content
Webhooks push events to your server in real time. Register an HTTPS endpoint and billing.io sends a signed POST request when something happens — a checkout confirms, a subscription renews, a payout settles.
Webhooks are available on Starter plans and above and require a publicly accessible HTTPS endpoint.

Setting Up a Webhook Endpoint

Register your endpoint by specifying the URL and which events you want to receive.
curl -X POST https://api.billing.io/v1/webhooks \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/billing",
    "events": [
      "checkout.created",
      "checkout.completed",
      "checkout.expired",
      "checkout.failed"
    ],
    "description": "Production checkout events"
  }'
Response:
{
  "webhook_id": "we_f6a7b8c9d0e1f2a3",
  "url": "https://example.com/webhooks/billing",
  "events": [
    "checkout.created",
    "checkout.completed",
    "checkout.expired",
    "checkout.failed"
  ],
  "secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
  "description": "Production checkout events",
  "status": "active",
  "created_at": "2025-06-15T10:00:00Z"
}
The secret field is only returned once when the webhook endpoint is created. Store it securely in your environment variables. You will need it to verify webhook signatures.

Supported Event Types

Checkout events

EventDescription
checkout.createdA new checkout was created
checkout.payment_detectedAn on-chain transaction was detected (unconfirmed)
checkout.confirmingBlock confirmations are in progress
checkout.completedPayment is fully confirmed (terminal)
checkout.expiredCheckout expired without payment (terminal)
checkout.failedUnrecoverable error (terminal)

Subscription events

EventDescription
subscription.renewedSubscription period was successfully renewed
subscription.past_duePayment failed after retries, subscription is past due
subscription.pausedSubscription was paused
subscription.canceledSubscription was canceled

Payout events

EventDescription
payout.settledPayout was verified and settled on-chain
payout.failedPayout verification failed

Webhook Payload Format

Every webhook delivery is an HTTP POST with a JSON body:
{
  "event_id": "evt_7f8a9b0c1d2e3f4a",
  "type": "checkout.completed",
  "checkout_id": "co_9a8b7c6d5e4f",
  "data": {
    "checkout_id": "co_9a8b7c6d5e4f",
    "deposit_address": "TXrkA1b2C3d4E5f6G7h8I9j0K1l2M3n4",
    "chain": "tron",
    "token": "USDT",
    "amount_usd": 49.99,
    "amount_atomic": "49990000",
    "status": "confirmed",
    "tx_hash": "abc123def456...",
    "confirmations": 19,
    "required_confirmations": 19,
    "expires_at": "2025-06-15T11:00:00Z",
    "detected_at": "2025-06-15T10:35:00Z",
    "confirmed_at": "2025-06-15T10:42:00Z",
    "created_at": "2025-06-15T10:30:00Z",
    "metadata": {
      "order_id": "ord_12345"
    }
  },
  "created_at": "2025-06-15T10:42:00Z"
}

Signature Verification

Every webhook delivery includes an X-Billing-Signature header. You must verify this signature to confirm the request came from billing.io and was not tampered with.

Signature format

The X-Billing-Signature header has this format:
t={unix_timestamp},v1={hex_hmac_sha256}
Example:
t=1718451600,v1=5a2f3c4d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b

Signed payload

The HMAC-SHA256 is computed over the following string:
{timestamp}.{raw_request_body}
Where:
  • {timestamp} is the t value from the header
  • {raw_request_body} is the exact raw body of the HTTP request (not parsed JSON)

Verification code

const crypto = require("crypto");

function verifyWebhookSignature(rawBody, signatureHeader, secret) {
  // 1. Parse the signature header
  const parts = {};
  for (const part of signatureHeader.split(",")) {
    const [key, value] = part.split("=", 2);
    parts[key] = value;
  }

  const timestamp = parts["t"];
  const receivedSignature = parts["v1"];

  if (!timestamp || !receivedSignature) {
    throw new Error("Invalid signature header format");
  }

  // 2. Reject old timestamps (prevent replay attacks)
  const tolerance = 5 * 60; // 5 minutes
  const currentTime = Math.floor(Date.now() / 1000);
  if (Math.abs(currentTime - parseInt(timestamp)) > tolerance) {
    throw new Error("Timestamp outside tolerance window");
  }

  // 3. Compute the expected signature
  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedSignature = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("hex");

  // 4. Compare using constant-time comparison
  const isValid = crypto.timingSafeEqual(
    Buffer.from(receivedSignature, "hex"),
    Buffer.from(expectedSignature, "hex")
  );

  if (!isValid) {
    throw new Error("Invalid webhook signature");
  }

  return true;
}

// Express middleware example
app.post("/webhooks/billing", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-billing-signature"];
  const rawBody = req.body.toString();

  try {
    verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET);
  } catch (err) {
    console.error("Webhook verification failed:", err.message);
    return res.status(400).json({ error: "Invalid signature" });
  }

  const event = JSON.parse(rawBody);
  // Process the event...

  res.status(200).json({ received: true });
});
Always verify signatures. Without verification, an attacker could send fake events to your webhook endpoint. Use constant-time comparison functions to prevent timing attacks.

Retry Policy

If your endpoint does not respond with a 2xx status code, billing.io retries the delivery with exponential backoff:
AttemptTimingDelay after previous
1Immediate
25 minutes later5 min
330 minutes later25 min
After 3 failed attempts, the delivery is marked as failed. You can find failed deliveries in the event log at Developers > Event Log in the dashboard, or by querying the GET /events endpoint.
Each retry uses the same event payload and event_id. Your endpoint should handle duplicate deliveries idempotently. See the best practices below.

Best Practices

1. Respond with 2xx quickly

Return a 200 or 202 status code as fast as possible. Do your heavy processing (database writes, email sends, fulfillment) asynchronously after responding.
app.post("/webhooks/billing", express.raw({ type: "application/json" }), (req, res) => {
  // Verify signature first
  verifyWebhookSignature(req.body.toString(), req.headers["x-billing-signature"], secret);

  // Respond immediately
  res.status(200).json({ received: true });

  // Process asynchronously
  const event = JSON.parse(req.body.toString());
  processEventAsync(event).catch(console.error);
});

2. Handle duplicates idempotently

Due to retries and at-least-once delivery, your endpoint may receive the same event more than once. Use the event_id to deduplicate:
const processedEvents = new Set(); // Use a database in production

app.post("/webhooks/billing", handler, async (req, res) => {
  const event = JSON.parse(req.body.toString());

  // Check if already processed
  if (processedEvents.has(event.event_id)) {
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Process the event
  await processEvent(event);

  // Mark as processed
  processedEvents.add(event.event_id);

  res.status(200).json({ received: true });
});
In production, store processed event IDs in a database or Redis rather than in-memory. Use a TTL of at least 24 hours for the deduplication cache.

3. Verify the signature before processing

Always verify the X-Billing-Signature header before trusting the payload. Reject requests with invalid or missing signatures.

4. Protect against replay attacks

Check the timestamp in the signature header. Reject events older than 5 minutes to prevent replay attacks. The verification code examples above include this check.

5. Use HTTPS endpoints only

Webhook URLs must use HTTPS. billing.io does not send events to HTTP endpoints in production (sandbox allows HTTP for local development).

6. Log failed deliveries

Monitor your webhook endpoint’s success rate. Check the Developers > Event Log page in the dashboard to see delivery attempts and failures.

Testing Webhooks in Sandbox

When using sandbox API keys (sk_test_), you can test webhook deliveries without real on-chain transactions.

1. Use a tunnel for local development

Use a tunneling service to expose your local server:
# Using ngrok
ngrok http 3000

# Register the ngrok URL as a webhook endpoint
curl -X POST https://api.billing.io/v1/webhooks \
  -H "Authorization: Bearer sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.io/webhooks/billing",
    "events": ["checkout.completed"],
    "description": "Local development"
  }'

2. Simulate a payment

Use the sandbox simulation endpoint to trigger a checkout.completed event:
# First create a checkout
curl -X POST https://api.billing.io/v1/checkouts \
  -H "Authorization: Bearer sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_usd": 10.00,
    "chain": "tron",
    "token": "USDT"
  }'

# Then simulate the payment confirmation
curl -X POST https://api.billing.io/v1/sandbox/simulate-payment \
  -H "Authorization: Bearer sk_test_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "checkout_id": "co_abc123"
  }'
This triggers the full webhook delivery flow to your registered endpoint. See the Sandbox Testing guide for more details on testing.

Managing Webhook Endpoints

List endpoints

curl https://api.billing.io/v1/webhooks \
  -H "Authorization: Bearer sk_live_xxx"

Get endpoint details

curl https://api.billing.io/v1/webhooks/we_f6a7b8c9d0e1f2a3 \
  -H "Authorization: Bearer sk_live_xxx"

Delete an endpoint

curl -X DELETE https://api.billing.io/v1/webhooks/we_f6a7b8c9d0e1f2a3 \
  -H "Authorization: Bearer sk_live_xxx"

Query event history

Use the events endpoint to review past events and debug delivery issues:
# List all events
curl "https://api.billing.io/v1/events?limit=10" \
  -H "Authorization: Bearer sk_live_xxx"

# Filter by event type
curl "https://api.billing.io/v1/events?type=checkout.completed&limit=10" \
  -H "Authorization: Bearer sk_live_xxx"

# Filter by checkout
curl "https://api.billing.io/v1/events?checkout_id=co_9a8b7c6d5e4f" \
  -H "Authorization: Bearer sk_live_xxx"

Next Steps