InstaWebhook
August 23, 2026By InstaWebhook TeamWebhook Security

Preventing Revenue Leakage: Securing Chargebee and Paddle Billing Webhooks

Preventing Revenue Leakage: Securing Chargebee and Paddle Billing Webhooks Stripe dominates most developer conversations about online payments, but thousands of scaling SaaS...

Preventing Revenue Leakage Securing Chargebee And Paddle Billing Webhooks

Preventing Revenue Leakage: Securing Chargebee and Paddle Billing Webhooks

Stripe dominates most developer conversations about online payments, but thousands of scaling SaaS companies run on merchant-of-record (MoR) platforms and dedicated subscription engines — Chargebee, Paddle, and Recurly among them — specifically because these platforms absorb global tax compliance, complex B2B invoicing, and localized billing.

The tradeoff is that a fragile webhook pipeline on top of any of these platforms becomes a source of silent revenue loss. When a Chargebee webhook fails, or a Paddle notification never gets acknowledged in time, the synchronized state between your billing engine and your application database breaks. Payments succeed on the provider's side, but users stay locked out. Or worse — a subscription cancels or a card declines, and your application never revokes access, so a churned account keeps consuming compute, storage, and API resources indefinitely for free.

This guide walks through how revenue leakage actually happens in non-Stripe billing pipelines, what Chargebee's and Paddle's webhook systems really guarantee (verified against each provider's current documentation), the architecture that prevents leakage, and where a managed webhook gateway fits in.

The Hidden Cost of Webhook Failures in SaaS

In a typical subscription architecture, your billing platform is the source of truth for financial transactions, while your application database is the source of truth for user entitlements and feature access. Webhooks are the asynchronous bridge between the two.

Code example
┌─────────────────────────┐               ┌───────────────────────────┐
│  Billing Platform       │  HTTP POST    │  Your Application Server  │
│  (Chargebee / Paddle)   ├──────────────►│  /api/webhooks/billing    │
│                         │   Webhooks    │                           │
└─────────────────────────┘               └─────────────┬─────────────┘
                                                          │
                                                          ▼
                                            ┌───────────────────────────┐
                                            │  App Database             │
                                            │  (Entitlements & Access)  │
                                            └───────────────────────────┘

When that bridge fails silently, financial data and application authorization diverge in three distinct ways:

1. Unpaid usage (free-rider entitlement leakage). A card fails, or a customer cancels. The billing platform fires payment_failed, subscription_cancelled, or subscription.past_due. If your endpoint returns a 5xx or times out and the retry is never caught, your database never updates the user's status. The customer keeps using paid features or burning third-party API credits without paying.

2. Silent churn and support escalations. A customer upgrades from a $50/month plan to a $500/month enterprise tier. The billing platform charges the card successfully, but the subscription.updated event hits a serverless cold start and times out. The customer gets a "Payment Successful" receipt but your UI still shows "Upgrade Your Plan." That mismatch reliably turns into an urgent support ticket, a refund request, or churn.

3. Usage-based metering discrepancies. For hybrid usage-based billing (per-seat, per-GB, per-active-contact), mid-cycle metering webhooks synchronize consumption against quota. If those webhooks drop, the billing engine under-counts usage, and you either under-bill at renewal or blow past your own margin assumptions without noticing.

Chargebee vs. Paddle: What Actually Differs

Both platforms provide webhook systems, but their timeout windows, retry schedules, and — importantly — their authentication mechanisms differ in ways that matter for how you build your handler. The table below reflects each provider's current published documentation, not vendor marketing copy.

FeatureChargebee WebhooksPaddle Billing Webhooks
Response deadline20s connection / 20s read / 60s total execution on live sites (10s/10s/20s on test sites)5 seconds, strict
Success acknowledgmentHTTP 2XXHTTP 200
Retry policy (live)Up to 7 retries over roughly 3 days 7 hours, exponential backoffUp to 60 retries over 3 days (20 in the first hour, 47 within the first day)
Retry policy (sandbox/test)Same 7-retry schedule on shorter timeouts3 retries within 15 minutes
Request authenticityBasic Authentication only — Chargebee does not currently support HMAC signing for webhooksPaddle-Signature header, HMAC-SHA256 over timestamp:raw_body
Ordering guaranteeNone — use resource_version to detect stale eventsNone — use occurred_at alongside idempotency checks
Failure behaviorRetries exhausted → webhook marked failed, email alert to site admins; manual resend from console or Events APIRetries exhausted → notification status set to failed; replay via the Notifications API

The most consequential correction to make here, if you've read older blog posts or built your integration a while ago: Chargebee does not support HMAC signature verification for webhooks. Its documentation is explicit that only Basic Authentication (a username/password pair, or a key embedded in the webhook URL) is available today, and that HMAC support is a requested feature still under evaluation. If you've been told your Chargebee integration verifies an HMAC signature, that logic is either verifying something else or not actually protecting you the way you think. Paddle, by contrast, has supported HMAC-SHA256 signing since Paddle Billing launched.

Chargebee Webhooks in Detail

Chargebee dispatches an event payload whenever entity state changes (subscription_created, payment_succeeded, payment_failed, subscription_cancelled, and so on). A delivery only counts as successful on a 2XX response.

Timeouts. Chargebee enforces three separate timeout values — connection timeout, read timeout, and total webhook execution timeout — and the values differ between test and live sites:

TimeoutTest siteLive site
Connection timeout10,000 ms20,000 ms
Read timeout10,000 ms20,000 ms
Webhook execution timeout20,000 ms60,000 ms

Retry schedule. If a webhook call fails or times out, Chargebee retries up to 7 times on an exponential schedule:

Code example
Attempt 1 ── Initial failure
Attempt 2 ── +2 minutes
Attempt 3 ── +6 minutes (after attempt 2)
Attempt 4 ── +30 minutes (after attempt 3)
Attempt 5 ── +1 hour (after attempt 4)
Attempt 6 ── +5 hours (after attempt 5)
Attempt 7 ── +1 day, then a final retry +2 days later

That full window runs to roughly 3 days and 7 hours, which is also why Chargebee's own guidance recommends keeping a matching idempotency window — purge stored event IDs older than that, not sooner. Once the 7th retry fails, Chargebee stops automatically and marks the endpoint as failing; recovery requires a manual resend from the Events log or the Events API.

Authentication and ordering. As noted above, protect your endpoint with Basic Auth or HTTPS plus IP allowlisting (Chargebee publishes the IP ranges it sends from) — there is no signature header to verify. For ordering, don't rely on wall-clock timestamps; Chargebee recommends comparing the resource_version attribute, which increments on every change to a resource, since webhooks can and do arrive out of order.

A newer alternative worth knowing about. For AWS-based teams or anyone processing high webhook volume, Chargebee now recommends Amazon EventBridge as the primary integration path over raw webhooks, specifically because it removes the retry/timeout/dead-letter burden from your own infrastructure and integrates natively with Lambda, SQS, and Step Functions.

Paddle Billing Webhooks: The 5-Second Race

Paddle's webhook architecture is unforgiving on timing but very persistent on retries. Your handler must return HTTP 200 within 5 seconds — no exceptions for cold starts, database locks, or slow third-party calls. Anything else, including a non-200 status code, is treated as a failure and queued for retry.

Retry schedule (confirmed against Paddle's current developer documentation):

  • Live accounts: up to 60 retries over 3 days, with 20 attempts in the first hour and 47 within the first day, using exponential backoff.
  • Sandbox accounts: up to 3 retries within 15 minutes.

Once all attempts are exhausted, the notification's status is set to failed, and it can be manually replayed through the Notifications API.

That aggressive first-hour retry cadence creates a real operational risk: duplicate processing storms. If your server actually finishes processing a request in 6 seconds, Paddle already counted that as a timeout by the time your response lands, and will resend the same transaction.completed event shortly after. Without strict idempotency handling, that becomes double-provisioned credits, duplicate onboarding emails, or a corrupted customer record.

Signature verification. Every Paddle Billing webhook includes a Paddle-Signature header in the format ts=<timestamp>;h1=<hash>. The hash is an HMAC-SHA256 digest computed over the string timestamp:raw_body, using a secret key that is unique to each notification destination — if you run multiple endpoints, each needs its own secret. Paddle's own SDKs enforce a five-second timestamp tolerance by default to guard against replay attacks; a manual implementation should apply the same check.

One legacy wrinkle: if you're maintaining an older integration, be aware that Paddle Classic (the pre-2023 product, now being actively migrated to Paddle Billing) used a different, public-key-based verification scheme rather than the shared-secret HMAC method described here. Code samples that reference Paddle Classic verification will not work against Paddle Billing endpoints, and vice versa.

Technical Failure Modes That Cause Webhook Drops

Four failure modes account for most production webhook incidents:

Code example
                  ┌─────────────────────────────────────────────────┐
                  │          Incoming Billing Webhook                │
                  └────────────────────────┬────────────────────────┘
                                            │
         ┌──────────────────────────────────┼──────────────────────────────────┐
         │                                  │                                  │
         ▼                                  ▼                                  ▼
┌──────────────────┐               ┌──────────────────┐               ┌──────────────────┐
│ 1. Serverless    │               │ 2. Signature /   │               │ 3. Database      │
│ cold starts      │               │ auth middleware  │               │ locks & queues   │
└──────────────────┘               └──────────────────┘               └──────────────────┘

1. Serverless cold starts. Deploying on Lambda, Vercel, or Cloud Run introduces cold starts. Initializing a connection pool and importing dependencies can eat 2–4 seconds before your business logic even runs — leaving almost no margin against Paddle's 5-second deadline if you also do synchronous database writes:

Code example
// ❌ ANTI-PATTERN: synchronous processing inside a serverless handler
export async function POST(req: Request) {
  const body = await req.json();

  // 1. Verify signature (~20ms)
  // 2. Cold-start DB connection (~2500ms)
  // 3. Call an external CRM API (~1800ms)
  // 4. Update the internal user record (~900ms)

  // Total: ~5220ms → exceeds Paddle's 5-second window
  return new Response("OK", { status: 200 });
}

2. Signature or auth middleware mutating the body. For Paddle, common frameworks (Express, Next.js, Fastify) parse incoming JSON into an object via body-parsing middleware by default. Re-serializing req.body with JSON.stringify() reorders keys or changes whitespace, which breaks HMAC verification silently and produces false 401s on genuinely valid webhooks. For Chargebee, the equivalent failure mode is misconfigured Basic Auth credentials after a secret rotation.

3. Out-of-order delivery. Neither provider guarantees ordered delivery. A subscription.updated event can arrive before the subscription.created event it logically follows, especially during rapid state changes like a signup immediately followed by an upgrade. A handler that assumes sequential arrival will throw on a missing customer record.

4. Third-party rate limits and outages. If your handler synchronously calls Salesforce, QuickBooks, or an internal provisioning API, any slowdown or outage in that dependency takes your webhook handler down with it.

Architectural Principles for Reliable Billing Webhooks

Principle 1 — Acknowledge immediately, process asynchronously. Your HTTP handler should do exactly two things synchronously: verify the request (signature for Paddle, Basic Auth for Chargebee), and enqueue the raw payload into a durable broker (Redis Streams, SQS, RabbitMQ, Kafka). Return 200 the moment the message is safely queued; let a background worker do the actual business logic.

Principle 2 — Idempotency is not optional. Retries are guaranteed in both systems. Store each provider's unique event ID (Chargebee's id, Paddle's event_id) in a dedupe table before processing, and discard anything already seen.

Principle 3 — Guard against out-of-order delivery correctly per provider. For Chargebee, compare the incoming event's resource_version against the value you last applied — it's a monotonically incrementing counter, not a wall-clock value, which makes it more reliable than a timestamp comparison. For Paddle, compare occurred_at against your stored last_billing_event_at, and skip the write if the incoming event is older than the currently applied state.

Code: Verifying a Paddle Billing Webhook Signature (Node.js / Express)

Code example
import express, { Request, Response } from 'express';
import crypto from 'crypto';

const app = express();

// CRITICAL: raw body middleware preserves the unparsed bytes signature verification needs
app.post(
  '/api/webhooks/paddle',
  express.raw({ type: 'application/json' }),
  async (req: Request, res: Response) => {
    const signatureHeader = req.headers['paddle-signature'] as string;
    const webhookSecret = process.env.PADDLE_WEBHOOK_SECRET_KEY!;

    if (!signatureHeader) {
      return res.status(400).send('Missing Paddle-Signature header');
    }

    try {
      // 1. Parse "ts=...;h1=..." into its parts
      const parts = signatureHeader.split(';');
      let timestamp = '';
      let expectedHash = '';
      for (const part of parts) {
        const [key, value] = part.split('=');
        if (key === 'ts') timestamp = value;
        if (key === 'h1') expectedHash = value;
      }
      if (!timestamp || !expectedHash) {
        return res.status(400).send('Malformed Paddle-Signature header');
      }

      // 2. Reject stale timestamps (mirrors Paddle SDK's 5-minute tolerance)
      const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
      if (ageSeconds > 300) {
        return res.status(401).send('Signature timestamp outside tolerance');
      }

      // 3. Recompute the HMAC over "timestamp:raw_body"
      const rawBody = req.body.toString('utf8');
      const signedPayload = `${timestamp}:${rawBody}`;
      const computedHash = crypto
        .createHmac('sha256', webhookSecret)
        .update(signedPayload)
        .digest('hex');

      // 4. Constant-time comparison
      const isValid = crypto.timingSafeEqual(
        Buffer.from(computedHash, 'utf8'),
        Buffer.from(expectedHash, 'utf8')
      );
      if (!isValid) {
        return res.status(401).send('Invalid signature');
      }

      // 5. Valid — enqueue and return 200 well inside the 5-second window
      const payload = JSON.parse(rawBody);
      await messageQueue.push({
        provider: 'paddle',
        eventId: payload.event_id,
        eventType: payload.event_type,
        data: payload.data,
        occurredAt: payload.occurred_at,
      });

      return res.status(200).json({ status: 'queued' });
    } catch (error) {
      console.error('Webhook processing failed:', error);
      return res.status(500).send('Internal Server Error');
    }
  }
);

For a Chargebee endpoint, the equivalent handler drops the HMAC block entirely and instead checks req.headers.authorization against your configured Basic Auth credentials (or an allowlist of Chargebee's published source IPs) before enqueuing.

Code: Idempotent Worker (Node.js / Redis / PostgreSQL)

Code example
import { db } from './db';
import { redis } from './redis';

interface BillingEvent {
  provider: 'chargebee' | 'paddle';
  eventId: string;
  eventType: string;
  customerId: string;
  occurredAt: string;
  resourceVersion?: number; // Chargebee only
  status: string;
}

export async function processBillingWebhookWorker(event: BillingEvent) {
  const idempotencyKey = `processed_evt:${event.provider}:${event.eventId}`;

  // Atomic set-if-not-exists guards against retry-driven duplicate processing
  const isNewEvent = await redis.set(idempotencyKey, '1', 'NX', 'EX', 86400 * 7);
  if (!isNewEvent) {
    console.log(`[Idempotency] Skipping duplicate event: ${event.eventId}`);
    return;
  }

  const user = await db.user.findUnique({ where: { billingCustomerId: event.customerId } });
  if (!user) {
    await db.unlinkedBillingEvents.create({ data: { payload: event } });
    return;
  }

  // Out-of-order guard: resource_version for Chargebee, occurred_at for Paddle
  const isStale =
    event.provider === 'chargebee'
      ? (event.resourceVersion ?? 0) < (user.lastResourceVersion ?? 0)
      : new Date(event.occurredAt).getTime() < (user.lastBillingEventAt?.getTime() ?? 0);

  if (isStale) {
    console.log(`[Out-of-Order] Ignoring stale event: ${event.eventId}`);
    return;
  }

  await db.$transaction([
    db.user.update({
      where: { id: user.id },
      data: {
        subscriptionStatus: mapEventToStatus(event.eventType, event.status),
        lastBillingEventAt: new Date(event.occurredAt),
        ...(event.resourceVersion ? { lastResourceVersion: event.resourceVersion } : {}),
      },
    }),
    db.billingAuditLog.create({
      data: { userId: user.id, eventId: event.eventId, provider: event.provider, eventType: event.eventType },
    }),
  ]);
}

function mapEventToStatus(eventType: string, status: string): string {
  if (['subscription_cancelled', 'subscription.canceled'].includes(eventType)) return 'CANCELED';
  if (['payment_failed', 'transaction.payment_failed'].includes(eventType)) return 'PAST_DUE';
  return 'ACTIVE';
}

Auditing Checklist for Your Billing Webhook Pipeline

  • Sub-second acknowledgment — your endpoint returns 2XX/200 well under each provider's deadline (60s ceiling for Chargebee live, 5s hard limit for Paddle), not just under it on average.
  • Async decoupling — heavy work (emails, third-party API calls, PDF generation) happens in a background worker, not the request handler.
  • Correct authentication per provider — Basic Auth (or IP allowlisting) for Chargebee; raw-body HMAC-SHA256 verification of the Paddle-Signature header for Paddle. Don't assume both use signatures.
  • Idempotency table keyed on the provider's event ID, with a retention window that covers the full retry cycle (~3 days 7 hours for Chargebee, 3 days for Paddle).
  • Out-of-order protection using resource_version (Chargebee) or occurred_at compared against your own last-applied timestamp (Paddle) — not arrival order.
  • Durable queue buffering so a deployment or outage doesn't burn through the provider's retry budget before your server comes back.
  • Alerting wired to Slack or PagerDuty when failure counts cross a threshold, and to each provider's own failure-notification email as a backstop.
  • Manual replay path via the Events API (Chargebee) or Notifications API (Paddle) for events that exhausted retries before a fix shipped.

Where a Webhook Gateway Fits

Building all of the above in-house — an ingestion proxy, a durable queue, retry logic, a dead-letter queue, and an observability dashboard — is a legitimate multi-week engineering project, and for teams already stretched thin it's effort spent on infrastructure instead of product.

A few paths are worth knowing about, and it's worth evaluating more than one:

  • Amazon EventBridge, which Chargebee itself now recommends as an alternative to raw webhooks for AWS-native teams, since it hands off retry and delivery guarantees to a managed AWS service and plugs directly into Lambda, SQS, and Step Functions.
  • General-purpose webhook gateways such as Hookdeck or Svix, which sit between any provider and your server, acknowledge instantly, and give you a longer processing window plus replay tooling.
  • Purpose-built billing-webhook tools such as InstaWebhook, which is built specifically around this reliability problem: it accepts events quickly, stores them durably, and tracks each one through received, queued, attempted, retried, delivered, and dead-lettered states so you can see exactly where a payment event is at any point, then replay it with full delivery history and idempotency context once your endpoint is healthy again.

Whichever route you choose, the underlying job is the same: turn "did the webhook arrive in time" into a problem your billing logic never has to think about.

The Bottom Line

Revenue leakage rarely shows up as a dramatic outage. It's a continuous, low-visibility drain on MRR — one missed Chargebee retry here, one Paddle timeout during a cold start there, and over a quarter that adds up to churned users retaining free access while paying customers hit provisioning delays.

Treating billing webhooks as financial transactions rather than routine HTTP callbacks — with async processing, provider-appropriate authentication, strict idempotency, and correct out-of-order handling — is what actually closes the gap. Get the architecture right once, and it stops being something your team has to think about during the next serverless outage or Black Friday traffic spike.


Sources: Chargebee's Webhook Settings and Events & Webhooks documentation; Paddle's Handle webhook delivery and Verify webhook signatures documentation. Figures reflect each provider's published documentation as of August 2026 and may change — always confirm against the current docs before relying on them in production.