Guides

Webhooks

Set up real-time webhook notifications for billing events.

PimaBill sends webhook notifications to your application when events occur: payments, subscription changes, collections, payouts and more. Each organisation has one webhook URL.

Setup

Set your webhook URL in the dashboard, or through the API:

curl -X PATCH http://localhost:4000/api/tenants/me \
  -H "Authorization: Bearer sk_live_a1b2c3d4..." \
  -H "Content-Type: application/json" \
  -d '{"webhookUrl": "https://your-app.com/webhooks/pimabill"}'

Your app (or its deployment script) can do this itself with an API key. Setting the URL needs the write or tenants:write scope. GET /api/tenants/me returns webhookSecret only to a key with one of those scopes, or to a person with the Developer role or above. Rotate the secret with POST /api/tenants/me/webhook-secret/rotate.

Live and the sandbox each have their own webhook URL and secret. Set them separately, each with that environment's key.

Event Payload

Every event is a POST with a JSON body:

{
  "event": "payment.succeeded",
  "timestamp": "2025-01-15T10:30:00.000Z",
  "data": {
    "id": "pay_abc123",
    "invoiceId": "inv_xyz789",
    "amount": "180000.0000",
    "currency": "UGX",
    "status": "SUCCEEDED"
  }
}

data is the object the event is about, in the same shape the API returns for it (a payment, an invoice, a collection, and so on).

These headers come with it:

HeaderValue
X-Webhook-Signature-V2t=<timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>">, keyed with your webhook secret. Use this one.
X-Webhook-TimestampUnix seconds when the event was signed
X-Webhook-SignatureHex HMAC-SHA256 of the raw body only (kept for older receivers; it does not protect against replays)
X-Event-TypeThe event name, e.g. payment.succeeded

Webhook URLs must point to a public internet address; private, loopback and cloud-metadata addresses are refused. Self-hosted deployments that deliver to an internal receiver can set ALLOW_PRIVATE_WEBHOOK_URLS=true.

Event Types

EventDescription
customer.created, customer.updated, customer.deletedA customer changed
subscription.createdA new subscription was created
subscription.renewedA subscription moved into a new period
subscription.plan_changed, subscription.plan_change_scheduledA plan change took effect, or was scheduled for the period end
subscription.paused, subscription.resumedA subscription was paused or resumed
subscription.trial_endedA trial ended
subscription.canceledA subscription was canceled
invoice.finalized, invoice.paid, invoice.voidedAn invoice changed state
invoice.partially_paidA deposit or instalment was paid; the invoice stays open until payments cover it (partial payments)
payment.created, payment.pendingA payment was started, or the provider has not confirmed it yet
payment.succeeded, payment.failedA payment finished
payment.held_for_reviewThe provider reported a success whose amount or currency does not match the invoice; the invoice stays unpaid until you review it
payment.duplicateMoney arrived for an invoice that was already paid or voided; refund or apply it
payment.retry.succeededA retried payment went through
payment.refundedA payment was refunded
credit_note.created, credit_note.finalized, credit_note.voidedA credit note changed
wallet.created, wallet.updated, wallet.terminatedA customer wallet changed
collection.succeeded, collection.failed, collection.canceled, collection.stuckA one-time collection finished, or the provider could not confirm it
payout.created, payout.requires_approvalA payout was created, or is waiting for approval
payout.succeeded, payout.failed, payout.canceled, payout.stuckA payout finished, or the provider could not confirm it
transfer.succeededMoney moved between two wallets

Handling Webhooks

Check the signature against the raw body before trusting an event. This handler uses the standard Request and Response (Next.js route handlers, Hono, Bun, Deno, Cloudflare Workers); Express works the same way with express.raw().

import { createHmac, timingSafeEqual } from 'node:crypto';

const secret = process.env.PIMABILL_WEBHOOK_SECRET!;

export async function POST(request: Request): Promise<Response> {
  const body = await request.text();
  // X-Webhook-Signature-V2: t=<timestamp>,v1=<signature>
  const parts = Object.fromEntries(
    (request.headers.get('x-webhook-signature-v2') ?? '').split(',').map((p) => p.split('=', 2)),
  );
  const timestamp = Number(parts.t);
  // Reject old (replayed) events.
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) {
    return new Response('Stale or missing timestamp', { status: 401 });
  }
  const expected = createHmac('sha256', secret).update(`${parts.t}.${body}`).digest();
  const given = Buffer.from(parts.v1 ?? '', 'hex');
  if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const { event, data } = JSON.parse(body) as { event: string; data: { id: string } };
  switch (event) {
    case 'payment.succeeded':
      // Mark the order paid
      break;
    case 'subscription.canceled':
      // Revoke access
      break;
    default:
      console.log(`Unhandled event: ${event} (${data.id})`);
  }

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

The same check in Python:

import hashlib
import hmac


import time


def is_valid(body: bytes, signature_v2: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_v2.split(",") if "=" in p)
    timestamp = parts.get("t", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
        return False
    signed = timestamp.encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Respond with a 2xx quickly and do slow work afterwards. Events can arrive more than once, and not always in order: use data.id and the object's status, not the arrival order.

Retries

A delivery fails when your endpoint answers with a non-2xx status or does not answer within 10 seconds. Each event is attempted 3 times, with exponential backoff starting at 2 seconds. Every attempt is recorded in the webhook log:

const logs = await client.tenants.getWebhookLogs({ direction: 'outbound', limit: 20 });

for (const log of logs.data) {
  console.log(`${log.createdAt} ${log.event}: ${log.success ? 'delivered' : `failed (${log.statusCode ?? 'no response'})`}`);
}

Inbound Webhooks (Payment Providers)

PimaBill also receives notifications from payment providers to update payment statuses. Point each provider's webhook setting at your API:

ProviderURL
StripePOST /webhooks/stripe
PaystackPOST /webhooks/paystack
FlutterwavePOST /webhooks/flutterwave
PayPalPOST /webhooks/paypal
PesapalPOST /webhooks/pesapal
PayUPOST /webhooks/payu
DPOPOST /webhooks/dpo
Any provider, per organisationPOST /webhooks/{provider}/{tenantId}

Mobile money and crypto providers used for collections and payouts are sent their notification URL with each request, so there is nothing to configure, except Airtel Money: set its callback once in the Airtel portal to POST /webhooks/airtel_money/{tenantId}/callback.

Copyright © 2026