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 is a production-grade billing infrastructure for SaaS applications using African payment providers. It handles subscription billing, payment orchestration, and real-time webhook delivery while maintaining multi-tenant isolation and security.
Key Capabilities
- Multi-Tenant Architecture: Each SaaS app maintains isolated payment provider credentials
- 4 African Payment Providers: Flutterwave, PawaPay, Pesapal, DPO
- Automated Billing Cycles: Cron-based trial expirations, recurring payments, and retries
- Production-Grade Security: HMAC signature verification, replay attack prevention, encrypted credentials
- Real-Time Webhooks: Bi-directional webhook delivery with retry logic
- Comprehensive Logging: Full audit trail of all payment events
Architecture
Multi-Tenant Design
┌─────────────────────────────────────────────────┐
│ SaaS App 1 SaaS App 2 │
│ (Flutterwave + DPO) (PawaPay + Pesapal) │
└────────┬──────────────────────────┬──────────────┘
│ │
└──────────┬───────────────┘
│
┌──────────▼──────────┐
│ CrediBill │
│ Orchestration │
└──────────┬──────────┘
│
┌──────────┴────────────────┬──────────────┬─────────────┐
│ │ │ │
┌────▼─────┐ ┌────────────┐ ┌─▼────────┐ ┌─▼──────┐ ┌──▼──────┐
│Flutterwave │PawaPay │ │Pesapal │ │DPO │ │Others │
└──────────┘ └────────────┘ └──────────┘ └───────┘ └─────────┘
Each SaaS app:
- Configures their own payment provider credentials
- Maintains encrypted credential storage
- Receives isolated webhook events
- Has payment transactions flow directly to their provider account
CrediBill acts as orchestrator, not custodian - funds never touch CrediBill accounts.
Payment Lifecycle
1. Trial to Paid Conversion
Trigger: Customer’s trial period expires
Flow:
Daily 2 AM UTC
│
▼
Check for expired trials
│
├─ Find subscriptions with trialEndsAt <= now
│
▼
For each expired trial:
│
├─ Create invoice from subscription + usage
│
├─ Get app's primary payment provider
│
├─ Call provider adapter (initiatePayment)
│
├─ Store payment transaction record
│
├─ Set transaction status: "initiated"
│
▼
Update subscription status: "active"
Implementation Functions:
convex/cronHandlers.ts: processTrialExpirations()
convex/payments.ts: initiateSubscriptionPayment()
convex/paymentsNode.ts: Provider-specific adapters
2. Recurring Payments
Trigger: Subscription renewal date reached
Flow:
Daily 3 AM UTC
│
▼
Check for due subscriptions
│
├─ Find where currentPeriodEnd <= now
│
▼
For each due subscription:
│
├─ Aggregate usage (if usage-based)
│
├─ Create invoice for next period
│
├─ Initiate payment via provider
│
├─ Update billing dates
│ ├─ currentPeriodStart = old.currentPeriodEnd
│ └─ currentPeriodEnd += interval
│
▼
Log payment transaction
Implementation Functions:
convex/cronHandlers.ts: processRecurringPayments()
convex/payments.ts: processRecurringPayment()
3. Payment Confirmation (Webhook)
Trigger: Payment provider sends webhook confirmation
Flow:
Provider sends POST /webhooks/{provider}
│
▼
Verify signature
│
├─ Get webhook secret from config
│
├─ Compute HMAC-SHA256(timestamp.body)
│
├─ Compare with header (timing-safe)
│
├─ Reject if mismatch
│
▼
Check replay attack
│
├─ Validate timestamp within 5 minutes
│
├─ Reject if too old or future
│
▼
Check idempotency
│
├─ Search webhookLogs for same provider event ID
│
├─ Reject if already processed
│
▼
Find matching transaction
│
├─ Look up by providerTransactionId
│
├─ Verify transaction exists
│
▼
Update transaction status
│
├─ Transaction: pending → success/failed
│
├─ Guard against terminal states
│ └─ Never overwrite: success/canceled/refunded
│
├─ Mark invoice as paid (if success)
│
▼
Send outgoing webhook to SaaS app
│
├─ Event: "payment.success" or "payment.failed"
│
├─ Include transaction details
│
├─ Retry if SaaS app endpoint fails
│
▼
Log webhook event
Implementation Functions:
convex/http.ts: Webhook route handlers
convex/webhookActions.ts: Event handlers
convex/webhookQueries.ts: Transaction lookups
convex/webhookMutations.ts: Status updates
convex/outgoingWebhooks.ts: SaaS app notifications
4. Failed Payment Handling
Trigger: Payment fails or webhook indicates failure
Flow:
Payment marked as FAILED
│
▼
Subscription status: ACTIVE → PAST_DUE
│
├─ Increment failedAttempts counter
│
├─ Record failure timestamp
│
├─ Set nextRetryAt = now + exponentialBackoff
│
▼
Send outgoing webhook
│
├─ Event: "payment.failed"
│
├─ Include failure reason from provider
│
├─ SaaS app decides action (restrict access, etc)
│
▼
Schedule retry (Cron: Daily 4 AM UTC)
│
├─ Check subscriptions in PAST_DUE status
│
├─ Where nextRetryAt <= now AND failedAttempts < 3
│
├─ Re-initiate payment via provider
│
├─ Update retry count
│
▼
After 3 failures
│
├─ Leave subscription in PAST_DUE
│
├─ Send "final notice" webhook
│
├─ SaaS app must disable access
│
▼
If payment succeeds on retry:
│
├─ Status: PAST_DUE → ACTIVE
│
├─ Send "payment.success" webhook
│
├─ Reset retry counter
│
▼
Optional: Proration
│
└─ If retry delayed, calculate proration
└─ Credit customer for failed period
Implementation Functions:
convex/cronHandlers.ts: retryFailedPayments()
convex/webhookMutations.ts: updateTransactionFromWebhook()
Database Schema
Payment Providers
// Table: paymentProviders
{
_id: Id<"paymentProviders">,
_creationTime: number,
// Multi-tenant isolation
organizationId: Id<"organizations">,
appId: Id<"apps">,
// Provider identification
provider: "flutterwave" | "pawapay" | "pesapal" | "dpo",
// Encrypted credentials
credentials: {
// Common fields
secretKeyEncrypted: string, // AES-256-GCM encrypted
// Provider-specific
publicKey?: string, // Flutterwave, DPO
merchantId?: string, // Some providers
apiUrl?: string, // Custom or provider-specific
apiToken?: string, // PawaPay
consumerKey?: string, // Pesapal
consumerSecretEncrypted?: string, // Pesapal
},
// Configuration
environment: "test" | "live",
isPrimary: boolean,
isActive: boolean,
// Webhooks
webhookSecret?: string, // Provider's webhook signing secret
webhookUrl: string,
// Metadata
lastTestedAt?: number,
lastSuccessfulPaymentAt?: number,
connectionStatus: "active" | "error" | "unconfigured",
errorMessage?: string,
}
Payment Transactions
// Table: paymentTransactions
{
_id: Id<"paymentTransactions">,
_creationTime: number,
// Multi-tenant isolation
organizationId: Id<"organizations">,
appId: Id<"apps">,
// Relationships
customerId: Id<"customers">,
subscriptionId?: Id<"subscriptions">,
invoiceId?: Id<"invoices">,
paymentProviderId: Id<"paymentProviders">,
// Payment details
amount: number, // In smallest currency unit (e.g., cents)
currency: string, // ISO 4217 code
paymentMethod: "mobile_money_mtn" | "mobile_money_airtel"
| "card_visa" | "card_mastercard" | "bank_transfer",
// Provider reference
providerTransactionId?: string, // From provider's response
providerReference?: string, // Transaction reference for customer
merchantReference: string, // Internal reference
// Status flow: pending → initiated → processing → success/failed
status: "pending" | "initiated" | "processing" | "success" | "failed" | "canceled" | "refunded",
// Retry tracking
attemptNumber: number,
isRetry: boolean,
failureReason?: string,
failureCode?: string,
nextRetryAt?: number,
// Provider response
providerResponse?: Record<string, any>,
// Timestamps
initiatedAt: number,
completedAt?: number,
expiresAt?: number, // When transaction expires if not completed
// Idempotency
idempotencyKey?: string,
}
Webhook Logs (Incoming)
// Table: webhookLogs
{
_id: Id<"webhookLogs">,
_creationTime: number,
// Multi-tenant isolation
organizationId: Id<"organizations">,
appId: Id<"apps">,
// Source
provider: "flutterwave" | "pawapay" | "pesapal" | "dpo",
// Event details
eventId: string, // Provider's unique event ID
event: string, // e.g., "charge.completed"
payload: Record<string, any>,
// Processing status
status: "received" | "processing" | "processed" | "failed" | "ignored",
// Validation
signatureValid: boolean,
signatureError?: string,
replayCheckPassed: boolean,
idempotencyCheckPassed: boolean,
// Matching
paymentTransactionId?: Id<"paymentTransactions">,
subscriptionId?: Id<"subscriptions">,
// Timing
receivedAt: number,
processedAt?: number,
processingDurationMs?: number,
// Error tracking
error?: string,
}
Outgoing Webhooks
// Table: outgoingWebhookDeliveries
{
_id: Id<"outgoingWebhookDeliveries">,
_creationTime: number,
// Multi-tenant isolation
organizationId: Id<"organizations">,
appId: Id<"apps">,
// Event details
event: "payment.success" | "payment.failed" | "subscription.past_due" | ...,
// Related entities
customerId: Id<"customers">,
paymentTransactionId?: Id<"paymentTransactions">,
subscriptionId?: Id<"subscriptions">,
// Payload
payload: Record<string, any>,
// Delivery tracking
webhookUrl: string,
status: "pending" | "delivered" | "failed" | "retrying",
attemptCount: number,
maxAttempts: number,
// Response
lastHttpStatus?: number,
lastErrorMessage?: string,
// Retry scheduling
nextRetryAt?: number,
// Timing
firstAttemptAt?: number,
lastAttemptAt?: number,
deliveredAt?: number,
}
Security Features
Webhook Signature Verification
All incoming webhooks must be verified using HMAC-SHA256:
// Verification pseudocode
function verifyWebhookSignature(
providedSignature: string,
timestamp: string,
body: string,
webhookSecret: string
): boolean {
const payload = `${timestamp}.${body}`;
const expectedSignature = HMAC_SHA256(payload, webhookSecret);
// Timing-safe comparison prevents timing attacks
return timingSafeEqual(expectedSignature, providedSignature);
}
Security considerations:
- Timing-safe comparison: Prevents attackers from determining signature validity through response timing
- Timestamp validation: Reject webhooks older than 5 minutes (prevent replay attacks)
- Signature verification: Ensures webhook originated from provider, not attacker
Credential Encryption
All stored provider credentials use AES-256-GCM encryption:
function encryptCredential(plaintext: string, appId: Id<"apps">): string {
// Derive key from appId using SHA-256
const key = SHA256(`${appId}:${process.env.MASTER_KEY}`);
// Encrypt with AES-256-GCM
const iv = randomBytes(16);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const encrypted =
cipher.update(plaintext, "utf-8", "hex") + cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
// Return: iv:encrypted:authTag (format for storage)
return `${iv}:${encrypted}:${authTag}`;
}
Security implications:
- Per-app encryption: Each app has isolated encryption keys
- AES-256-GCM: Authenticated encryption prevents tampering
- No central key storage: Master key in environment variables
Race Condition Protection
Critical updates use atomic transactions:
// Transaction status update (simplified)
async function updateTransactionStatus(
transactionId: Id<"paymentTransactions">,
newStatus: "success" | "failed"
) {
const transaction = await db.paymentTransactions.get(transactionId);
// Guard against overwriting terminal states
const terminalStates = ["success", "canceled", "refunded"];
if (terminalStates.includes(transaction.status)) {
throw new Error("Cannot update transaction in terminal state");
}
// Atomic update
await db.paymentTransactions.patch(transactionId, {
status: newStatus,
completedAt: Date.now(),
});
}
Cron Jobs Schedule
| Job | Frequency | Time (UTC) | Purpose |
|---|
| Trial Expirations | Daily | 2:00 AM | Convert expired trials to paid |
| Recurring Payments | Daily | 3:00 AM | Process subscription renewals |
| Failed Payment Retries | Daily | 4:00 AM | Retry failed transactions |
| Cleanup Expired | Daily | 5:00 AM | Mark old pending as failed |
| Webhook Retries | Every 5 min | - | Retry failed outgoing webhooks |
Next Steps