Skip to content

Chargebee Integration Playbook

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/.

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

Active plans are the pricing-v3 catalog (FOXY-280): two SFW tiers (Starter, Creator) plus the five-tier NSFW Pro band (faxppr1..faxppr5), all using the foxy-{token}-new-USD-{Monthly|Yearly} item-price ID form. The previous catalog (Starter $29 / Plus / Creator $99 / Pro / Ultimate / ultinext + 18+ Plus/Creator/ Ultimate) stays in the catalog for legacy/grandfathered subscribers but is not sold on the active checkout. Frontend plan constants live in apps/brisket/src/features/checkout/components/constants.ts and apps/sirloin/internal/app/services/billing/domain/plans.go; Chargebee owns price authority (see Billing Pricing).

Since FOXY-1165 the catalog carries two Starter/Creator pairs: the SFW originals strtr/crtr (Stripe) and their full-access twins faxpst/faxpcr (high-risk lane, credit_type=full_access), same prices and credits. Which pair an account is shown is decided in Brisket’s listProducts by the per-user PostHog flag full-access-plans; see Full-Access Plans for New Signups. The twin tokens are deliberately no substring of any other token: several code sites match plan ids with Contains/LIKE, and a shared faxp prefix would also capture faxppr1..5.

Annual ≈ 33% off. faxppr* are the full-access (18+) tiers — purchasable and visible to everyone; KYC gates 18+ content generation, not plan purchase or visibility. faxppr3/4/5 annual ItemPrices exist but are purchase-disabled (over the processor per-charge limit — see billing pitfalls); monthly is purchasable.

Item Price ID Plan Credits/mo Price Period
foxy-strtr-new-USD-Monthly Starter (SFW) 80 9.00 month
foxy-strtr-new-USD-Yearly Starter (SFW) 80 72.00 year
foxy-crtr-new-USD-Monthly Creator (SFW) 350 39.00 month
foxy-crtr-new-USD-Yearly Creator (SFW) 350 312.00 year
foxy-faxpst-new-USD-Monthly Starter (full access, twin of strtr) 80 9.00 month
foxy-faxpst-new-USD-Yearly Starter (full access, twin of strtr) 80 72.00 year
foxy-faxpcr-new-USD-Monthly Creator (full access, twin of crtr) 350 39.00 month
foxy-faxpcr-new-USD-Yearly Creator (full access, twin of crtr) 350 312.00 year
foxy-faxppr1-new-USD-Monthly Pro 1 (NSFW) 750 79.00 month
foxy-faxppr1-new-USD-Yearly Pro 1 (NSFW) 750 636.00 year
foxy-faxppr2-new-USD-Monthly Pro 2 (NSFW) 1,500 149.00 month
foxy-faxppr2-new-USD-Yearly Pro 2 (NSFW) 1,500 1188.00 year
foxy-faxppr3-new-USD-Monthly Pro 3 (NSFW) 2,500 249.00 month
foxy-faxppr3-new-USD-Yearly Pro 3 (NSFW) — annual disabled 2,500 2004.00 year
foxy-faxppr4-new-USD-Monthly Pro 4 (NSFW) 5,000 499.00 month
foxy-faxppr4-new-USD-Yearly Pro 4 (NSFW) — annual disabled 5,000 3996.00 year
foxy-faxppr5-new-USD-Monthly Pro 5 (NSFW) 10,000 999.00 month
foxy-faxppr5-new-USD-Yearly Pro 5 (NSFW) — annual disabled 10,000 8028.00 year

Six one-time packs, server-driven via ListProducts(ONE_TIME) (item-price IDs owned by Chargebee; safe to edit in place since no recurring subscriber is bound):

Credits Price
80 20.00
250 50.00
600 100.00
1,750 250.00
4,000 500.00
8,500 1,000.00

Legacy subscriptions (grandfathered, not sold)

Section titled “Legacy subscriptions (grandfathered, not sold)”

Retained for existing subscribers; entitlements and renewal cadence unchanged. Renewal credit grants are being adjusted (via Chargebee item-price metadata) for the three lowest legacy tiers — foxy-starter-new, foxy-plus-new, and foxy-nsfwpl-new; all other legacy tiers keep their original credit grants. SFW: foxy-starter-new ($29), foxy-plus-new ($49), foxy-creator-new ($99), foxy-pro-new ($99), foxy-ultimate-new ($249), foxy-ultinext1/2/3-new ($399/$599/$999). NSFW: foxy-nsfwpl-new (18+ Plus $79), foxy-nsfwcreat-new (18+ Creator $149), foxy-nsfwult-new (18+ Ultimate $299).

Source: pricing-v3 spec (FOXY-280) + brisket plan constants (apps/brisket/src/features/checkout/components/constants.ts). Re-verify against the production Chargebee catalog after the FOXY-341 prod cutover.

Addons: zero. search_addons returns empty. All pricing is flat-fee plan items; credit grants derive 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.

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.

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 run as River periodic jobs (FOXY-1038): unique-by-period inserts plus a per-kind advisory-lock guard mean 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 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.

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.

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).

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.

  • 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).

TODO — Chargebee billing fees not encoded in this repo. Pricing is per their contract (typically % of MRR + per-invoice). Capture in finance docs separately.

  • 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.