Billium
Quickstart

Handle a payment

Verify webhook signatures and react to invoice.paid.

When a payment is confirmed on-chain, Billium sends an invoice.paid webhook to your registered endpoint. This guide shows you how to receive and verify it.

1. Register your webhook endpoint

In the Billium dashboard, go to Webhooks → Add webhook and add your endpoint URL. Make sure it:

  • Uses HTTPS
  • Returns a 2xx status code within the configured timeout (default 30 s)
  • Is reachable from the internet (for local development, use ngrok)

Select at minimum the invoice.paid event. You'll receive a webhook secret — keep it safe, you'll need it to verify signatures.

2. Handle the request

import express from 'express';
import { Billium,
  BilliumWebhookSignatureError,
  BilliumWebhookTimestampError,
} from '@billium/node';

const billium = new Billium({
  webhookSecret: process.env.BILLIUM_WEBHOOK_SECRET,
});

const app = express();

// ⚠️ Raw body is required — do NOT use express.json() on this route
app.post('/webhooks/billium', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = billium.webhooks.verify(req.body, req.headers['x-signature'] as string);
  } catch (err) {
    if (err instanceof BilliumWebhookTimestampError) {
      return res.status(400).send(); // possible replay attack
    }
    if (err instanceof BilliumWebhookSignatureError) {
      return res.status(400).send(); // tampered or invalid
    }
    throw err;
  }

  if (event.event === 'invoice.paid') {
    const data = event.data as { invoiceId: string };
    // Fulfill the order, update your database, etc.
    console.log('Payment received for invoice', data.invoiceId);
  }

  res.status(200).send();
});
import Fastify from 'fastify';
import { Billium,
  BilliumWebhookSignatureError,
  BilliumWebhookTimestampError,
} from '@billium/node';

const billium = new Billium({
  webhookSecret: process.env.BILLIUM_WEBHOOK_SECRET,
});

const app = Fastify();

app.addContentTypeParser(
  'application/json',
  { parseAs: 'buffer' },
  (_req, body, done) => done(null, body),
);

app.post('/webhooks/billium', async (req, reply) => {
  let event;
  try {
    event = billium.webhooks.verify(
      req.body as Buffer,
      req.headers['x-signature'] as string,
    );
  } catch (err) {
    if (
      err instanceof BilliumWebhookTimestampError ||
      err instanceof BilliumWebhookSignatureError
    ) {
      return reply.status(400).send();
    }
    throw err;
  }

  if (event.event === 'invoice.paid') {
    console.log('Paid:', event.data);
  }

  return reply.status(200).send();
});
// app/api/webhooks/billium/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Billium,
  BilliumWebhookSignatureError,
  BilliumWebhookTimestampError,
} from '@billium/node';

const billium = new Billium({
  webhookSecret: process.env.BILLIUM_WEBHOOK_SECRET,
});

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const signature = req.headers.get('x-signature') ?? '';

  let event;
  try {
    event = billium.webhooks.verify(rawBody, signature);
  } catch (err) {
    if (
      err instanceof BilliumWebhookTimestampError ||
      err instanceof BilliumWebhookSignatureError
    ) {
      return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
    }
    throw err;
  }

  if (event.event === 'invoice.paid') {
    console.log('Paid:', event.data);
  }

  return NextResponse.json({ received: true });
}

Always read the raw request body before parsing JSON. Parsing first (e.g. with express.json()) mutates the body and will cause signature verification to fail.

3. Test with a ping

You can send a test delivery from the Billium dashboard (Webhooks → your webhook → Send ping) or via the API:

curl -X POST https://api.billium.to/api/v1/merchants/merchant/$BILLIUM_MERCHANT_ID/webhooks/$WEBHOOK_ID/ping \
  -H "x-api-key: sk_..."

The ping delivers a special ping event to your endpoint. Use it to confirm your handler is reachable and returning 200.

What happens if my endpoint is down?

Billium retries failed deliveries with exponential backoff (default: 3 attempts, starting at 60 s). If all retries are exhausted the delivery moves to a dead-letter queue and is marked PERMANENT_FAILURE. You can inspect delivery history in the dashboard.

On this page