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
type EventPoller struct { repo PollerRepository cacheManager *cache.Manager paymentProcessor *payments.Processor}
// Poll fetches events since lastSyncTimefunc (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 workerRate Limit Handling
When Chargebee rate-limits (429):
// events/poller.go - Legacy: simple 10s sleepif 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?
- Guaranteed delivery: If Chargebee API is up, we will eventually see the event
- Idempotent recovery: If a poll is interrupted, we resume from
LastPurchaseAt(no gaps) - Webhook-free robustness: Doesn’t depend on return-path network availability
- Historical sync: Full replay via
AllInvoicesSinceparameter
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 detectedcreateInvoice(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 twicepoller.Poll(ctx, opts)result2 := poller.Poll(ctx, opts)// Credit amount should be same; not doubledpurchases := 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)Related ADRs
- 001: Deferred Activation — Payment detection triggers activation
- 005: Distributed Locks for Payments — Polling results are protected by locks
- 006: Saga Pattern — Failed polls are safely rolled back