Webhooks
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"}'
import { PimaBillClient } from 'pimabill';
const client = new PimaBillClient({ token: 'sk_live_a1b2c3d4...' });
await client.tenants.updateMe({
webhookUrl: 'https://your-app.com/webhooks/pimabill',
});
// The secret your endpoint uses to check signatures:
const { webhookSecret } = await client.tenants.getMe();
from pimabill import PimaBillClient
client = PimaBillClient(token="sk_live_a1b2c3d4...")
client.tenants.update_me(webhook_url="https://your-app.com/webhooks/pimabill")
# The secret your endpoint uses to check signatures:
webhook_secret = client.tenants.get_me().webhook_secret
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:
| Header | Value |
|---|---|
X-Webhook-Signature-V2 | t=<timestamp>,v1=<hex HMAC-SHA256 of "<timestamp>.<raw body>">, keyed with your webhook secret. Use this one. |
X-Webhook-Timestamp | Unix seconds when the event was signed |
X-Webhook-Signature | Hex HMAC-SHA256 of the raw body only (kept for older receivers; it does not protect against replays) |
X-Event-Type | The 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
| Event | Description |
|---|---|
customer.created, customer.updated, customer.deleted | A customer changed |
subscription.created | A new subscription was created |
subscription.renewed | A subscription moved into a new period |
subscription.plan_changed, subscription.plan_change_scheduled | A plan change took effect, or was scheduled for the period end |
subscription.paused, subscription.resumed | A subscription was paused or resumed |
subscription.trial_ended | A trial ended |
subscription.canceled | A subscription was canceled |
invoice.finalized, invoice.paid, invoice.voided | An invoice changed state |
invoice.partially_paid | A deposit or instalment was paid; the invoice stays open until payments cover it (partial payments) |
payment.created, payment.pending | A payment was started, or the provider has not confirmed it yet |
payment.succeeded, payment.failed | A payment finished |
payment.held_for_review | The provider reported a success whose amount or currency does not match the invoice; the invoice stays unpaid until you review it |
payment.duplicate | Money arrived for an invoice that was already paid or voided; refund or apply it |
payment.retry.succeeded | A retried payment went through |
payment.refunded | A payment was refunded |
credit_note.created, credit_note.finalized, credit_note.voided | A credit note changed |
wallet.created, wallet.updated, wallet.terminated | A customer wallet changed |
collection.succeeded, collection.failed, collection.canceled, collection.stuck | A one-time collection finished, or the provider could not confirm it |
payout.created, payout.requires_approval | A payout was created, or is waiting for approval |
payout.succeeded, payout.failed, payout.canceled, payout.stuck | A payout finished, or the provider could not confirm it |
transfer.succeeded | Money 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'})`}`);
}
logs = client.tenants.get_webhook_logs(direction="outbound", limit=20)
for log in logs.data:
print(f"{log.created_at} {log.event}: {'delivered' if log.success else 'failed'}")
Inbound Webhooks (Payment Providers)
PimaBill also receives notifications from payment providers to update payment statuses. Point each provider's webhook setting at your API:
| Provider | URL |
|---|---|
| Stripe | POST /webhooks/stripe |
| Paystack | POST /webhooks/paystack |
| Flutterwave | POST /webhooks/flutterwave |
| PayPal | POST /webhooks/paypal |
| Pesapal | POST /webhooks/pesapal |
| PayU | POST /webhooks/payu |
| DPO | POST /webhooks/dpo |
| Any provider, per organisation | POST /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.