Skip to content

Chargebee Polling Over Webhooks

ADR 002: Chargebee Event Polling Over Webhooks

Date: 2026-04
Status: Accepted
Context: Need reliable payment event delivery for credit allocation and analytics.

Problem

Chargebee can emit invoice_paid events via webhooks, but webhooks are unreliable:

  • Network failures drop events silently
  • Webhook retries may be exhausted before we retry
  • Webhook infrastructure may be misconfigured
  • How do we ensure we never miss a payment and grant credits?

Decision

Poll Chargebee events API every 15 seconds to detect paid invoices, rather than relying on webhooks alone.

Implementation

events/poller.go
type EventPoller struct {
repo PollerRepository
cacheManager *cache.Manager
paymentProcessor *payments.Processor
}
// Poll fetches events since lastSyncTime
func (p *EventPoller) Poll(ctx context.Context, opts PollOptions) (*PollResult, error) {
since := p.determinePollSince(opts)
events, err := p.fetchEvents(ctx, since)
for _, event := range events {
p.processEvent(ctx, event)
}
}
// Runs every 15 seconds in background worker

Rate Limit Handling

When Chargebee rate-limits (429):

// events/poller.go - Legacy: simple 10s sleep
if strings.Contains(err.Error(), "Please try after some time") {
time.Sleep(10 * time.Second)
return p.fetchEvents(ctx, since, offset, allInvoices, allInvoicesSince)
}
// retry/retry.go - Modern: exponential backoff
// 2s, 4s, 8s, 16s, 32s (max 5 retries, ~62s total)
result, err := retry.ExecuteWithRetry(ctx, func() (*chargebee.ResultList, error) {
return p.fetchEventsWithRetry(ctx, since, offset)
})

Rationale

Why Polling?

  1. Guaranteed delivery: If Chargebee API is up, we will eventually see the event
  2. Idempotent recovery: If a poll is interrupted, we resume from LastPurchaseAt (no gaps)
  3. Webhook-free robustness: Doesn’t depend on return-path network availability
  4. Historical sync: Full replay via AllInvoicesSince parameter

Webhook Disabled, Not Removed

Primer webhooks (primer.go) still exist for faster payment detection (real-time):

  • Primer webhook → immediate payment recording
  • Poller → background verification & credit sync (catches webhook misses)

Consequences

Positive

  • Deterministic: No timeout/retry luck involved
  • Recoverable: Can replay from any point in history
  • Cheaper: Chargebee API calls are cheaper than webhook infrastructure costs
  • Observable: Polling results logged; easy to debug

Negative

  • Latency: 15-second delay before credits appear (acceptable for async system)
  • API calls: ~6 calls/minute = ~8,600 calls/day even with no events (Chargebee pricing concern)
  • Thundering herd: Simultaneous polls could spike API usage (mitigated by staggered worker scheduling)

Testing

// Test: Paid invoice is detected
createInvoice(userID, amount=100, status="paid", paid_at="2 min ago")
result := poller.Poll(ctx, PollOptions{})
require.True(t, result.EventsProcessed > 0)
// Test: Idempotency - same invoice polled twice
poller.Poll(ctx, opts)
result2 := poller.Poll(ctx, opts)
// Credit amount should be same; not doubled
purchases := repo.GetPurchases(userID)
require.Equal(t, 1, len(purchases))
// Test: Old events skipped (LastPurchaseAt = 1 hour ago)
createInvoice(userID, status="paid", paid_at="2 days ago")
result := poller.Poll(ctx, opts)
require.False(t, result.EventsProcessed > 0)