Skip to content

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 note

Inbound 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:

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 IDExternal NamePriceCurrencyPeriod
foxy-starter-new-USD-MonthlyFoxy Starter29.00USDmonth
foxy-starter-new-USD-YearlyFoxy Starter179.00USDyear
foxy-plus-new-USD-MonthlyFoxy Plus49.00USDmonth
foxy-plus-new-USD-YearlyFoxy Plus299.00USDyear
foxy-creator-new-USD-MonthlyFoxy Creator99.00USDmonth
foxy-creator-new-USD-YearlyFoxy Creator599.00USDyear
foxy-pro-new-USD-MonthlyFoxy Pro99.00USDmonth
foxy-pro-new-USD-YearlyFoxy Pro599.00USDyear
foxy-ultimate-new-USD-MonthlyFoxy Ultimate249.00USDmonth
foxy-ultimate-new-USD-YearlyFoxy Ultimate1499.00USDyear
foxy-ultinext1-new-USD-MonthlyFoxy Ultimate 5K399.00USDmonth
foxy-ultinext1-new-USD-YearlyFoxy Ultimate 5K2399.00USDyear
foxy-ultinext2-new-USD-MonthlyFoxy Ultimate 7.5K599.00USDmonth
foxy-ultinext2-new-USD-YearlyFoxy Ultimate 7.5K3599.00USDyear
foxy-ultinext3-new-USD-MonthlyFoxy Ultimate 12.5K999.00USDmonth
foxy-ultinext3-new-USD-YearlyFoxy Ultimate 12.5K5999.00USDyear
foxy-nsfwpl-new-USD-Monthly18+ Plus79.00USDmonth
foxy-nsfwpl-new-USD-Yearly18+ Plus479.00USDyear
foxy-nsfwcreat-new-USD-MonthlyFoxy 18+ Creator149.00USDmonth
foxy-nsfwcreat-new-USD-YearlyFoxy 18+ Creator899.00USDyear
foxy-nsfwult-new-USD-MonthlyFoxy 18+ Ultimate299.00USDmonth
foxy-nsfwult-new-USD-YearlyFoxy 18+ Ultimate1799.00USDyear

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 typeAction
invoice_generatedroute to EventPoller.ProcessChargebeeEvent
invoice_updatedroute 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.

TaskScheduleScopeSource
TaskPollChargebeeEventsevery 15sevents since last watermarkpollchargebeeevents.go:18
TaskPollAllChargebeeEventsevery 30mreplay last 7 dayspollchargebeeevents.go:46
TaskChargebeeSyncSubsAlldaily 00:00 UTCfull subscriptions CSV exportchargebeesync.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

FailureDetectionMitigation
Webhook Basic auth fails401 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 logs30m 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 constantsCheckout returns ErrUnknownItemPrice from domain/plans.go; providecancellationbenefit_test.go failsChargebee 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 errorsDistributed 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 accumulatesExpected. Subscription auto-cancels on Chargebee side. ADR-001 § Consequences.
auto_collection accidentally ON for a Primer customerChargebee attempts double-charge; ADR-004 sandbox tests catchRun 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

VariablePurposeRotation
SIRLOIN_CHARGEBEE_API_KEYsirloin → Chargebee API auth (full-access site key)Rotate via Chargebee dashboard → Settings → API Keys; redeploy sirloin.
SIRLOIN_CHARGEBEE_SITESite identifier (e.g. foxyai-test, prod TBD) — not a secret per se but co-locatedStatic per env.
SIRLOIN_CHARGEBEE_WEBHOOK_USERNAMEBasic-auth username Chargebee posts withRotate in pair with password; update Chargebee webhook config.
SIRLOIN_CHARGEBEE_WEBHOOK_PASSWORDBasic-auth passwordAs 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

EnvSiteBase URL patternAuto-collection
Sandbox / devfoxyai-test (default in .env.example)https://foxyai-test.chargebee.com/api/v2OFF
Productionfoxyai (set via SIRLOIN_CHARGEBEE_SITE)https://foxyai.chargebee.com/api/v2OFF

Site is interpolated in code: see validatecoupon.go:493fmt.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.json is 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.