Chargebee Integration Playbook
Overview
Chargebee is the billing/subscription source of truth for Foxy. It owns: plans, item prices, subscriptions, invoices, and the customer record on the billing side. sirloin is the only service that talks to it. Other services (brisket, strip, fennec, brain) reach billing data via sirloin’s REST/gRPC surface; they do not hold a Chargebee SDK.
Money does not flow through Chargebee in production. Primer is the
authoritative payment collector — Chargebee runs with auto_collection = OFF
for Primer customers (see ADR
Auto-Collection Off). Chargebee
generates invoices, tracks status, and computes MRR; sirloin orchestrates
payment via Primer and feeds the result back.
Owner: sirloin. SDK wrapper: apps/sirloin/internal/app/services/billing/chargebee/client.go.
Code root: apps/sirloin/internal/app/services/billing/.
Architecture
Subscription state machine
stateDiagram-v2 [*] --> future: createPendingSubscription<br/>(auto_collection=OFF, start_date=future) future --> active: Primer payment succeeds<br/>→ submitPaidInvoice() future --> cancelled: payment fails / abandoned<br/>(deferred activation never fires) active --> non_renewing: user schedules cancel non_renewing --> cancelled: term ends active --> active: renewal invoice generated<br/>→ Primer collects → submitPaidInvoice active --> cancelled: renewal dunning exhausts retries cancelled --> [*] note right of future ADR: Deferred Subscription Activation. Subscription exists in Chargebee but grants no access until paid. end note note right of active auto_collection=OFF. Chargebee never charges directly; Primer is the sole collector. end noteInbound integration paths
flowchart LR CB[Chargebee] -->|"Basic auth POST<br/>/webhooks/chargebee/events"| WH[chargebeewebhook.go] POLL[poller<br/>15s + 30m] -->|chargebee.ListEvents| CB SYNC[chargebeesync<br/>daily export] -->|export API| CB WH --> EP[EventPoller.ProcessChargebeeEvent] POLL --> EP EP --> CRED[(credits applied)] EP --> ACT[subscription activation]Cross-links:
- ADR: Polling Over Webhooks
- ADR: Deferred Subscription Activation
- ADR: Auto-Collection Off
- Billing flow
- Billing Pricing standard
Item prices, plans, addons
Active plans use the foxy-{type}-new family with item-price IDs of the form
foxy-{type}-new-USD-{Monthly|Yearly}. Older plans (foxy-premium,
foxy-ultimate, foxy-ultimate-unlimited, starter) remain in the catalog
for legacy subscribers but are not sold on the active checkout. Frontend
plan constants in apps/brisket/src/features/products-provider/ and
apps/sirloin/internal/app/services/billing/domain/plans.go carry display
metadata; Chargebee owns price authority (see
Billing Pricing).
| Item Price ID | External Name | Price | Currency | Period |
|---|---|---|---|---|
foxy-starter-new-USD-Monthly | Foxy Starter | 29.00 | USD | month |
foxy-starter-new-USD-Yearly | Foxy Starter | 179.00 | USD | year |
foxy-plus-new-USD-Monthly | Foxy Plus | 49.00 | USD | month |
foxy-plus-new-USD-Yearly | Foxy Plus | 299.00 | USD | year |
foxy-creator-new-USD-Monthly | Foxy Creator | 99.00 | USD | month |
foxy-creator-new-USD-Yearly | Foxy Creator | 599.00 | USD | year |
foxy-pro-new-USD-Monthly | Foxy Pro | 99.00 | USD | month |
foxy-pro-new-USD-Yearly | Foxy Pro | 599.00 | USD | year |
foxy-ultimate-new-USD-Monthly | Foxy Ultimate | 249.00 | USD | month |
foxy-ultimate-new-USD-Yearly | Foxy Ultimate | 1499.00 | USD | year |
foxy-ultinext1-new-USD-Monthly | Foxy Ultimate 5K | 399.00 | USD | month |
foxy-ultinext1-new-USD-Yearly | Foxy Ultimate 5K | 2399.00 | USD | year |
foxy-ultinext2-new-USD-Monthly | Foxy Ultimate 7.5K | 599.00 | USD | month |
foxy-ultinext2-new-USD-Yearly | Foxy Ultimate 7.5K | 3599.00 | USD | year |
foxy-ultinext3-new-USD-Monthly | Foxy Ultimate 12.5K | 999.00 | USD | month |
foxy-ultinext3-new-USD-Yearly | Foxy Ultimate 12.5K | 5999.00 | USD | year |
foxy-nsfwpl-new-USD-Monthly | 18+ Plus | 79.00 | USD | month |
foxy-nsfwpl-new-USD-Yearly | 18+ Plus | 479.00 | USD | year |
foxy-nsfwcreat-new-USD-Monthly | Foxy 18+ Creator | 149.00 | USD | month |
foxy-nsfwcreat-new-USD-Yearly | Foxy 18+ Creator | 899.00 | USD | year |
foxy-nsfwult-new-USD-Monthly | Foxy 18+ Ultimate | 299.00 | USD | month |
foxy-nsfwult-new-USD-Yearly | Foxy 18+ Ultimate | 1799.00 | USD | year |
Source: Chargebee MCP search_plans (26 plans found, 16 active “-new” prices
listed above) and list_item_prices per item (queried 2026-05-05).
Addons: zero. search_addons returns empty. All current pricing is flat-fee
plan items; credit grants are derived from the active plan in
apps/sirloin/internal/app/services/billing/events/credits.go, not from
Chargebee addons.
Plan-ID format reference: apps/sirloin/internal/app/services/billing/domain/plans.go:277.
Webhooks received
Endpoint: POST /webhooks/chargebee/events
Source: apps/sirloin/internal/app/services/billing/webhooks/chargebeewebhook.go.
Auth: HTTP Basic. Credentials in SIRLOIN_CHARGEBEE_WEBHOOK_USERNAME /
SIRLOIN_CHARGEBEE_WEBHOOK_PASSWORD. Comparison uses
crypto/subtle.ConstantTimeCompare. Empty creds → 500 (fail-closed). See
Security Model § Webhook signature verification.
Body cap: 1 MiB (maxChargebeeEventBodyBytes).
Event types accepted:
| Event type | Action |
|---|---|
invoice_generated | route to EventPoller.ProcessChargebeeEvent |
invoice_updated | route to EventPoller.ProcessChargebeeEvent |
All other event types return 200 OK and are logged & dropped
(isSupportedChargebeeWebhookEvent). This keeps the listener narrow — the
poller is the durable replay path.
Idempotency: the event handler defers to EventPoller.processEvent, which
keys credit application by transactionID via
PollerRepository.PurchaseExists before writing. Re-delivery and re-poll
converge on the same purchase row.
Status today: the webhook is wired but Chargebee delivery is disabled at the source per ADR-002. The endpoint is kept warm for emergency re-enable.
Polling job
The poller is the primary, durable ingestion path. Two scheduled tasks plus a
nightly export, registered in apps/sirloin/internal/app/worker/worker.go.
| Task | Schedule | Scope | Source |
|---|---|---|---|
TaskPollChargebeeEvents | every 15s | events since last watermark | pollchargebeeevents.go:18 |
TaskPollAllChargebeeEvents | every 30m | replay last 7 days | pollchargebeeevents.go:46 |
TaskChargebeeSyncSubsAll | daily 00:00 UTC | full subscriptions CSV export | chargebeesync.go:39 |
All three use gocron.WithSingletonMode(LimitModeReschedule) — only one
instance runs across replicas. Both event tasks gate on stage.IsProtected()
so they fire in staging/prod, not local dev.
Watermark: EventPoller.determinePollSince reads
PollerRepository.LastPurchaseAt(ctx) and adds 1s to avoid re-fetching the
same event. If no purchases exist, it falls back to now - DefaultEventLookback
(7 days, events/poller.go). The 30-minute replay job passes
AllInvoicesSince = now - 7d to widen the window and catch any missed
invoices.
Idempotency: purchases are keyed by transactionID; PurchaseExists
short-circuits before writes. Failed activations / cancellations are persisted
to the failed_operations table and drained by TaskRetryFailedOperations
every 5 minutes.
Rate limiting: RateLimitSleepDuration = 10s, retried up to a fixed
ceiling on Chargebee 429s (events/poller.go). The handler reads
Retry-After when present.
Failure modes
| Failure | Detection | Mitigation |
|---|---|---|
| Webhook Basic auth fails | 401 from chargebeewebhook.go; Chargebee dashboard webhook log; sirloin error logs "chargebee webhook basic auth credentials are not configured" | Verify SIRLOIN_CHARGEBEE_WEBHOOK_USERNAME/PASSWORD match Chargebee site config. Webhook is currently disabled in Chargebee — poller covers gap. |
| Polling lag (queue backed up, Chargebee 5xx) | billing_credits_applied_latency_seconds (TODO histogram per billing-slos); poller error logs | 30m all-invoices job catches gaps for up to 7 days. Beyond 7 days → manual replay via TaskPollAllChargebeeEvents with extended AllInvoicesSince. |
| Item-price drift between Chargebee and code constants | Checkout returns ErrUnknownItemPrice from domain/plans.go; providecancellationbenefit_test.go fails | Chargebee is authoritative for price; code constants must follow. Add price → update domain/plans.go and brisket products-provider/ together. See Billing Pricing. |
| Customer creation race (two concurrent checkouts for same user) | Duplicate chargebee_customer_id writes; lock contention errors | Distributed lock around customer creation in checkout path (see billing-pitfalls § Race Conditions). |
| Deferred activation never fires (payment never succeeds) | Subscription remains in future past start_date; failed_operations accumulates | Expected. Subscription auto-cancels on Chargebee side. ADR-001 § Consequences. |
auto_collection accidentally ON for a Primer customer | Chargebee attempts double-charge; ADR-004 sandbox tests catch | Run cmd/scripts/chargebee-auto-collection-off/ to flip back. |
Customer model
Chargebee Customer ≠ Clerk User. See Glossary § Chargebee Customer and Glossary § Customer (Chargebee).
A Foxy account is a Clerk User. Billing identity is a separate
Chargebee Customer linked from the user row via chargebee_customer_id.
Creation happens lazily on first checkout. The link is one-to-one but can lag
— a user can exist without a Chargebee customer (free tier, never paid).
Never assume chargebee_customer_id != "" outside billing code paths.
PII classification: Chargebee holds billing PII (address, last-4, customer id); no full PAN. See Security Model § Data classification.
Secrets
| Variable | Purpose | Rotation |
|---|---|---|
SIRLOIN_CHARGEBEE_API_KEY | sirloin → Chargebee API auth (full-access site key) | Rotate via Chargebee dashboard → Settings → API Keys; redeploy sirloin. |
SIRLOIN_CHARGEBEE_SITE | Site identifier (e.g. foxyai-test, prod TBD) — not a secret per se but co-located | Static per env. |
SIRLOIN_CHARGEBEE_WEBHOOK_USERNAME | Basic-auth username Chargebee posts with | Rotate in pair with password; update Chargebee webhook config. |
SIRLOIN_CHARGEBEE_WEBHOOK_PASSWORD | Basic-auth password | As above. |
Storage: env vars, loaded via the deployment platform (TODO: confirm prod
secrets backend per Security Model § Secrets management).
Local dev uses .env; sandbox tests read from SIRLOIN_CHARGEBEE_SITE /
SIRLOIN_CHARGEBEE_API_KEY directly. Validation in
apps/sirloin/internal/app/config/config.go and validator.go.
Logging hygiene: API key and Basic-auth values must never be logged — checked in webhook handler (only event id / type are logged).
Sandbox vs prod
| Env | Site | Base URL pattern | Auto-collection |
|---|---|---|---|
| Sandbox / dev | foxyai-test (default in .env.example) | https://foxyai-test.chargebee.com/api/v2 | OFF |
| Production | foxyai (set via SIRLOIN_CHARGEBEE_SITE) | https://foxyai.chargebee.com/api/v2 | OFF |
Site is interpolated in code: see validatecoupon.go:493 —
fmt.Sprintf("https://%s.chargebee.com/api/v2/coupons/%s", site, couponID).
Test mode is implicit in the sandbox site; there is no parallel “test” key in
the prod site.
Sandbox-only tests are gated by SIRLOIN_CHARGEBEE_SITE + SIRLOIN_CHARGEBEE_API_KEY
being set (e.g. subscriptions/query_sandbox_test.go). They t.Skip() when
absent so unit-test runs stay hermetic.
Dashboards
- Chargebee admin UI:
https://{SIRLOIN_CHARGEBEE_SITE}.chargebee.com/d/(subscriptions, invoices, MRR, retention, dunning). - Internal billing dashboards: TODO —
internal/dashboards/billing-credit-latency.jsonis planned per billing-slos but not yet populated. - Foxy360 / PostHog: subscription event analytics emitted by
events/analytics.go(billing_credit_applied_total,billing_payment_total).
Cost model
TODO — Chargebee billing fees not encoded in this repo. Pricing is per their contract (typically % of MRR + per-invoice). Capture in finance docs separately.
Runbook hooks
- Billing runbook — incident playbook.
- Billing pitfalls — known races and TOCTOU traps in the Chargebee + Primer pairing.
- Billing SLOs — credit application latency, payment success rate, subscription state consistency.
When a Chargebee outage or webhook-auth regression is suspected, start with
billing.md’s incident checklist; the poller will hold the line for ~7 days
before manual replay is needed.