Documentation Index
Fetch the complete documentation index at: https://docs.credibill.tech/docs/llms.txt
Use this file to discover all available pages before exploring further.
Overview
CrediBill sends webhooks to notify your application of real-time payment and subscription events. All webhooks are signed with HMAC-SHA256 for security verification.
Webhook Configuration
Setting Up Webhooks
- Log in to CrediBill dashboard
- Go to Settings → Webhooks
- Enter your webhook URL (must be HTTPS in production)
- Save the webhook secret for signature verification
- Select which events to receive
Webhook URL Requirements
- HTTPS only in production (HTTP allowed for local testing)
- Public accessibility - must be reachable from internet
- Valid SSL certificate - self-signed certificates rejected in production
- Timeout handling - respond within 10 seconds
- Rate handling - handle rapid webhook delivery
All webhooks are HTTP POST requests with JSON payload:
{
"event": "payment.success",
"data": {
"id": "txn_abc123",
"amount": 50000,
"currency": "UGX",
"customerId": "cust_xyz789",
"subscriptionId": "sub_def456",
"invoiceId": "inv_ghi789",
"providerTransactionId": "FLW-123456",
"status": "success",
"timestamp": 1703721600000
},
"timestamp": 1703721600000
}
POST /webhooks/credibill HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-CrediBill-Signature: abc123def456...
X-CrediBill-Timestamp: 1703721600000
X-CrediBill-Event: payment.success
User-Agent: CrediBill-Webhooks/1.0
Content-Length: 512
Signature Verification
Why Verify Signatures?
- Confirms webhook originated from CrediBill
- Prevents webhook spoofing attacks
- Validates payload integrity
- Protects against man-in-the-middle attacks
Verification Steps
const signature = req.headers["x-credibill-signature"];
const timestamp = req.headers["x-credibill-timestamp"];
const rawBody = req.body.toString(); // Raw JSON string, not parsed
Important: Use the raw request body, not JSON-parsed body.
Step 2: Construct Signature Payload
const signaturePayload = `${timestamp}.${rawBody}`;
Step 3: Compute HMAC-SHA256
import crypto from "crypto";
const webhookSecret = process.env.CREDIBILL_WEBHOOK_SECRET;
const expectedSignature = crypto
.createHmac("sha256", webhookSecret)
.update(signaturePayload)
.digest("hex");
Step 4: Timing-Safe Comparison
// IMPORTANT: Use timing-safe comparison to prevent timing attacks
const isValid = crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
);
if (!isValid) {
return res.status(401).json({ error: "Invalid signature" });
}
Complete Example
import express from "express";
import crypto from "crypto";
const app = express();
// Use raw body middleware to get unparsed request body
app.post(
"/webhooks/credibill",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-credibill-signature"];
const timestamp = req.headers["x-credibill-timestamp"];
const rawBody = req.body.toString();
// Verify signature
const signaturePayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", process.env.CREDIBILL_WEBHOOK_SECRET)
.update(signaturePayload)
.digest("hex");
// Timing-safe comparison
let isValid = false;
try {
isValid = crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(signature)
);
} catch (err) {
isValid = false;
}
if (!isValid) {
console.warn("Invalid webhook signature");
return res.status(401).json({ error: "Invalid signature" });
}
// Verify timestamp (prevent replay attacks)
const webhookTime = parseInt(timestamp);
const now = Date.now();
const ageMs = now - webhookTime;
if (ageMs > 5 * 60 * 1000) {
// 5 minutes
console.warn("Webhook too old:", ageMs);
return res.status(400).json({ error: "Webhook too old" });
}
// Parse and process webhook
const webhook = JSON.parse(rawBody);
// Acknowledge receipt immediately
res.status(200).json({ success: true });
// Process webhook asynchronously
handleWebhookAsync(webhook).catch((err) => {
console.error("Webhook processing error:", err);
});
}
);
async function handleWebhookAsync(webhook) {
const { event, data } = webhook;
switch (event) {
case "payment.success":
await handlePaymentSuccess(data);
break;
case "payment.failed":
await handlePaymentFailed(data);
break;
case "subscription.past_due":
await handleSubscriptionPastDue(data);
break;
// ... handle other events
}
}
Payment Events
payment.success
Sent when a payment completes successfully.
Payload:
{
"event": "payment.success",
"data": {
"id": "txn_abc123",
"amount": 50000,
"currency": "UGX",
"status": "success",
"customerId": "cust_xyz789",
"subscriptionId": "sub_def456",
"invoiceId": "inv_ghi789",
"paymentMethod": "mobile_money_mtn",
"providerTransactionId": "FLW-123456",
"providerReference": "FLWREF123",
"timestamp": 1703721600000
}
}
What to do:
- ✅ Activate/enable customer’s subscription
- ✅ Grant access to features
- ✅ Send confirmation email with receipt
- ✅ Update customer’s billing status to “active”
- ✅ Log successful payment for analytics
Example:
case 'payment.success': {
const { customerId, subscriptionId, amount, currency } = data;
// Activate subscription in your system
await db.subscription.update(subscriptionId, {
status: 'active',
paidAt: new Date(),
});
// Grant feature access
await enableFeatures(customerId);
// Send confirmation email
await sendEmail(customerId, 'receipt', {
amount,
currency,
date: new Date(),
});
// Track metric
analytics.track('payment_success', { customerId, amount });
break;
}
payment.failed
Sent when a payment attempt fails.
Payload:
{
"event": "payment.failed",
"data": {
"id": "txn_abc123",
"amount": 50000,
"currency": "UGX",
"status": "failed",
"customerId": "cust_xyz789",
"subscriptionId": "sub_def456",
"invoiceId": "inv_ghi789",
"paymentMethod": "mobile_money_mtn",
"failureReason": "Insufficient funds",
"failureCode": "INSUFFICIENT_FUNDS",
"attemptNumber": 1,
"nextRetryAt": 1703807600000,
"timestamp": 1703721600000
}
}
Failure codes:
INSUFFICIENT_FUNDS - Customer doesn’t have enough balance
INVALID_ACCOUNT - Account number/phone invalid
DECLINED - Card or account declined
TIMEOUT - Provider timeout (may retry automatically)
INVALID_CREDENTIALS - Our credentials rejected
PROVIDER_ERROR - Provider API error (will retry)
What to do:
- ⚠️ Mark subscription as “past_due”
- ⚠️ Send payment failure email
- ⚠️ Optionally restrict access (depends on your policy)
- ⚠️ Provide payment update link
- ⚠️ Note: CrediBill will auto-retry up to 3 times
Example:
case 'payment.failed': {
const { customerId, subscriptionId, failureReason, attemptNumber } = data;
// Update subscription status
await db.subscription.update(subscriptionId, {
status: 'past_due',
failureReason,
failedAttempts: attemptNumber,
failedAt: new Date(),
});
// Send failure notification
await sendEmail(customerId, 'payment_failed', {
reason: failureReason,
nextRetryDate: new Date(data.nextRetryAt),
updateLink: `https://your-app.com/settings/payment`,
});
// Optionally restrict access after multiple failures
if (attemptNumber >= 3) {
await disableFeatures(customerId);
await sendEmail(customerId, 'subscription_disabled', {
reason: 'Payment failed after 3 retry attempts',
});
}
break;
}
Subscription Events
subscription.created
Sent when a new subscription is created.
Payload:
{
"event": "subscription.created",
"data": {
"id": "sub_abc123",
"customerId": "cust_xyz789",
"planId": "plan_pro_monthly",
"status": "trialing",
"trialEndsAt": 1704326400000,
"currentPeriodStart": 1703721600000,
"currentPeriodEnd": 1706313600000,
"amount": 50000,
"currency": "UGX",
"interval": "month",
"timestamp": 1703721600000
}
}
What to do:
- ✅ Send welcome email
- ✅ Log in analytics
- ✅ Start trial period countdown (if applicable)
subscription.activated
Sent when subscription becomes active (first payment succeeds or trial ends with successful charge).
Payload:
{
"event": "subscription.activated",
"data": {
"id": "sub_abc123",
"customerId": "cust_xyz789",
"planId": "plan_pro_monthly",
"status": "active",
"paymentTransactionId": "txn_abc123",
"currentPeriodEnd": 1706313600000,
"timestamp": 1703721600000
}
}
What to do:
- ✅ Grant full access to features
- ✅ Send “subscription active” email
- ✅ Start usage tracking/monitoring
subscription.past_due
Sent when payment fails and subscription enters past-due state.
Payload:
{
"event": "subscription.past_due",
"data": {
"id": "sub_abc123",
"customerId": "cust_xyz789",
"status": "past_due",
"failedPaymentAttempts": 1,
"lastPaymentAttempt": 1703721600000,
"nextRetryAt": 1703808000000,
"timestamp": 1703721600000
}
}
What to do:
- ⚠️ Display payment failure notice on customer dashboard
- ⚠️ Send “payment failed” email
- ⚠️ Show “update payment method” prompt
- ⚠️ Optionally reduce feature access
subscription.canceled
Sent when subscription is canceled.
Payload:
{
"event": "subscription.canceled",
"data": {
"id": "sub_abc123",
"customerId": "cust_xyz789",
"status": "canceled",
"canceledAt": 1703721600000,
"cancelReason": "customer_request",
"immediateEffect": true,
"timestamp": 1703721600000
}
}
Cancel reasons:
customer_request - Customer requested cancellation
payment_failed - Canceled due to failed payment after retries
admin_action - Admin canceled subscription
fraud_detected - Fraud prevention system
What to do:
- ✅ Revoke all feature access immediately
- ✅ Send cancellation confirmation email
- ✅ Offboard customer data (if applicable)
- ✅ Send “we’re sorry” follow-up email
subscription.expired
Sent when subscription naturally expires (end of term, no renewal).
Payload:
{
"event": "subscription.expired",
"data": {
"id": "sub_abc123",
"customerId": "cust_xyz789",
"status": "expired",
"expiredAt": 1703721600000,
"timestamp": 1703721600000
}
}
What to do:
- ✅ Disable all features
- ✅ Show “subscription expired” message
- ✅ Offer reactivation option
- ✅ Offer downgrade/upgrade options
Invoice Events
invoice.generated
Sent when an invoice is created for a billing period.
Payload:
{
"event": "invoice.generated",
"data": {
"id": "inv_abc123",
"customerId": "cust_xyz789",
"subscriptionId": "sub_def456",
"amount": 50000,
"amountDue": 50000,
"currency": "UGX",
"status": "open",
"dueDate": 1704326400000,
"invoiceDate": 1703721600000,
"lineItems": [
{
"description": "Pro Plan - January 2024",
"amount": 30000,
"quantity": 1,
"unitPrice": 30000,
"interval": "month"
},
{
"description": "API Calls - 10,000 calls @ 0.002/call",
"amount": 20000,
"quantity": 10000,
"unitPrice": 2,
"type": "usage"
}
],
"timestamp": 1703721600000
}
}
What to do:
- ✅ Store invoice in your system
- ✅ Send invoice to customer (optional - CrediBill can auto-send)
- ✅ Add to customer’s invoice history
- ✅ Track for accounting/reconciliation
invoice.paid
Sent when invoice is fully paid.
Payload:
{
"event": "invoice.paid",
"data": {
"id": "inv_abc123",
"customerId": "cust_xyz789",
"subscriptionId": "sub_def456",
"amount": 50000,
"currency": "UGX",
"status": "paid",
"paidAt": 1703721600000,
"paymentTransactionId": "txn_abc123",
"timestamp": 1703721600000
}
}
What to do:
- ✅ Update invoice status to “paid” in your system
- ✅ Send payment receipt
- ✅ Update accounting records
- ✅ Clear any past-due notices
invoice.payment_failed
Sent when payment attempt for invoice fails.
Payload:
{
"event": "invoice.payment_failed",
"data": {
"id": "inv_abc123",
"customerId": "cust_xyz789",
"subscriptionId": "sub_def456",
"amount": 50000,
"currency": "UGX",
"status": "open",
"paymentAttempts": 2,
"lastFailureReason": "Insufficient funds",
"lastFailureCode": "INSUFFICIENT_FUNDS",
"nextRetryAt": 1703808000000,
"timestamp": 1703721600000
}
}
What to do:
- ⚠️ Send payment failure notification
- ⚠️ Show dunning email to customer
- ⚠️ Provide “update payment method” link
- ⚠️ Track failed invoices for reporting
Best Practices
app.post("/webhooks/credibill", async (req, res) => {
// Verify signature and basic validation
validateWebhook(req);
// Immediately acknowledge receipt
res.status(200).json({ success: true });
// Process webhook asynchronously
processWebhook(req.body).catch((err) => {
console.error("Webhook processing failed:", err);
// Log to error tracking service
sentry.captureException(err);
});
});
Why? CrediBill retries webhooks that don’t return 200 within 10 seconds. Slow processing causes duplicate retries.
2. Handle Idempotency
async function handleWebhook(webhook) {
// Create unique event ID
const eventId = `${webhook.event}_${webhook.data.id}_${webhook.timestamp}`;
// Check if already processed
const existing = await db.processedEvents.findOne({ eventId });
if (existing) {
console.log("Webhook already processed:", eventId);
return; // Silently ignore duplicate
}
// Process webhook
await processEvent(webhook);
// Mark as processed
await db.processedEvents.create({ eventId, processedAt: new Date() });
}
Why? Network issues may cause webhooks to be delivered multiple times. Idempotency prevents double-charging.
3. Implement Retry Logic
async function processWebhook(webhook) {
let lastError;
const maxRetries = 3;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const { event, data } = webhook;
switch (event) {
case "payment.success":
await handlePaymentSuccess(data);
break;
// ... other cases
}
return; // Success
} catch (error) {
lastError = error;
console.error(
`Webhook processing failed (attempt ${attempt}/${maxRetries}):`,
error
);
// Exponential backoff for retries
if (attempt < maxRetries) {
const delayMs = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
// After max retries, log and alert
console.error("Webhook processing failed after all retries:", lastError);
await alertOps("webhook_processing_failed", { webhook, error: lastError });
}
4. Validate Timestamp
function validateWebhookTimestamp(timestamp) {
const webhookTime = parseInt(timestamp);
const now = Date.now();
const ageMs = now - webhookTime;
// Reject webhooks older than 5 minutes
const maxAgeMs = 5 * 60 * 1000;
if (ageMs > maxAgeMs) {
throw new Error(`Webhook too old: ${ageMs}ms`);
}
// Reject future webhooks (clock skew tolerance: 1 minute)
const maxSkewMs = 1 * 60 * 1000;
if (ageMs < -maxSkewMs) {
throw new Error(`Webhook timestamp in future: ${ageMs}ms`);
}
}
Why? Prevents replay attacks where attacker sends old webhooks multiple times.
5. Log All Webhooks
async function logWebhook(req, status, error) {
await db.webhookLogs.create({
provider: "credibill",
event: req.body.event,
payload: req.body.data,
signature: req.headers["x-credibill-signature"],
timestamp: req.headers["x-credibill-timestamp"],
status,
error,
receivedAt: new Date(),
ipAddress: req.ip,
userAgent: req.headers["user-agent"],
});
}
Why? Essential for debugging webhook delivery issues.
Testing Webhooks
Local Development
Use ngrok to expose your local server:
Then update your webhook URL to https://your-ngrok-url.ngrok.io/webhooks/credibill
Test Mode Webhooks
In test mode, trigger webhooks manually from dashboard:
- Go to Dashboard → Webhooks → Test
- Select event type
- Select test data
- Click “Send Test Webhook”
Webhook Logs
View all webhook delivery history:
- Go to Dashboard → Webhooks → Logs
- See delivery attempts, timestamps, responses
- Manually retry failed webhooks
- Export logs for analysis
Common Testing Scenarios
// Test payment success
POST /webhooks/credibill
{
"event": "payment.success",
"data": {
"id": "txn_test_123",
"customerId": "cust_test_456",
"amount": 50000,
"status": "success"
}
}
// Test payment failure
POST /webhooks/credibill
{
"event": "payment.failed",
"data": {
"id": "txn_test_789",
"customerId": "cust_test_456",
"failureReason": "Insufficient funds"
}
}
// Test subscription activation
POST /webhooks/credibill
{
"event": "subscription.activated",
"data": {
"id": "sub_test_999",
"customerId": "cust_test_456",
"status": "active"
}
}
Troubleshooting
Webhooks Not Received
Checklist:
Debug:
- Check webhook logs in CrediBill dashboard
- Look for delivery attempts and responses
- Enable verbose logging in your app
- Test endpoint manually with curl
Signature Verification Fails
Common causes:
- Using parsed JSON body instead of raw body
- Webhook secret copied incorrectly (extra spaces, wrong env var)
- Timestamp header missing or incorrect
- Not using timing-safe comparison
Fix:
// ❌ Wrong: Using parsed body
app.post("/webhook", express.json(), (req, res) => {
const rawBody = JSON.stringify(req.body); // WRONG!
});
// ✅ Correct: Using raw body
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString(); // Correct
});
Duplicate Webhooks
Cause: Network timeouts causing retries
Solution: Implement idempotency (see “Best Practices” above)
Rate Limits
- 100 webhooks/second per app max
- Failed webhooks retry with exponential backoff
- No limit on total webhooks received
- Delivery timeout: 10 seconds per attempt
Webhook Status Codes
CrediBill expects HTTP 200 for successful delivery. Other codes cause retries:
| Code | Meaning | Retry? |
|---|
| 200 | Success | No |
| 4xx | Client error (except 408) | No |
| 408 | Request timeout | Yes |
| 5xx | Server error | Yes |
| Timeout | No response in 10s | Yes |
Support
- Webhook debugging: Check logs in dashboard
- Signature issues: Contact support with webhook examples
- Delivery problems: Check our status page