> ## 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.

# Webhook Reference

> Complete guide to CrediBill webhook events and signatures

## 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:

```json theme={null}
{
  "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

```typescript theme={null}
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

```typescript theme={null}
const signaturePayload = `${timestamp}.${rawBody}`;
```

#### Step 3: Compute HMAC-SHA256

```typescript theme={null}
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

```typescript theme={null}
// 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

```typescript theme={null}
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:**

```json theme={null}
{
  "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:**

```typescript theme={null}
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:**

```json theme={null}
{
  "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:**

```typescript theme={null}
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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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:**

```json theme={null}
{
  "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

```typescript theme={null}
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

```typescript theme={null}
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

```typescript theme={null}
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

```typescript theme={null}
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

```typescript theme={null}
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:

```bash theme={null}
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

```typescript theme={null}
// 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:**

```typescript theme={null}
// ❌ 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](https://status.credibill.tech)
