Skip to main content

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

  1. Log in to CrediBill dashboard
  2. Go to Settings → Webhooks
  3. Enter your webhook URL (must be HTTPS in production)
  4. Save the webhook secret for signature verification
  5. 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

Webhook Format

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
}

HTTP Headers

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

Step 1: Extract Headers

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

1. Return 200 OK Immediately

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:
ngrok http 3000
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:
  1. Go to Dashboard → Webhooks → Test
  2. Select event type
  3. Select test data
  4. Click “Send Test Webhook”

Webhook Logs

View all webhook delivery history:
  1. Go to Dashboard → Webhooks → Logs
  2. See delivery attempts, timestamps, responses
  3. Manually retry failed webhooks
  4. 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:
  • Webhook URL is publicly accessible (not localhost)
  • HTTPS certificate is valid (not self-signed in production)
  • Firewall allows traffic from CrediBill
  • No WAF/IP blocking of CrediBill IPs
  • Webhook endpoint returns 200 OK
  • Endpoint responds within 10 seconds
Debug:
  1. Check webhook logs in CrediBill dashboard
  2. Look for delivery attempts and responses
  3. Enable verbose logging in your app
  4. 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:
CodeMeaningRetry?
200SuccessNo
4xxClient error (except 408)No
408Request timeoutYes
5xxServer errorYes
TimeoutNo response in 10sYes

Support

  • Webhook debugging: Check logs in dashboard
  • Signature issues: Contact support with webhook examples
  • Delivery problems: Check our status page