Documentation · Billing & Plans.

Stripe Checkout, plan changes, and quotas — explained.

Three plans, snake_case metadata, hourly trial sweeps, and a Customer Portal that hands users the keys. Every dollar from Stripe to your dashboard, end to end.

See pricing Back to docs

Overview

Admaxxer billing runs on Stripe — Stripe Checkout for purchase and the Stripe Customer Portal for self-serve management (card updates, invoice viewing, plan swaps, cancellations). Plans are defined in shared/pricing.ts, which is the canonical source of truth for every plan's displayName, monthlyPriceUSD, annualTotalUSD, maxConnections, maxChatMessagesPerMonth, and maxTeamMembers. Every backend map — webhook tier resolver, quota gate, name lookup, price lookup, VALID_PLAN_KEYS — reads from this file. Drifting copies are a known risk; see GL#26 in our key-lessons archive.

The customer flow is short: pick a plan on /pricing, sign up (no card, 7-day trial), upgrade via Stripe Checkout, return to your workspace already activated. Plan changes happen via the Customer Portal or via POST /api/billing/change-plan. Quota enforcement happens at the API middleware layer — no surprise overage bills, no surprise downgrades, no waiting for a sales call to upgrade.

The architecture follows a strict separation: Stripe is the source of truth for billing state (subscription status, billing cycle, invoice history), while Admaxxer's database is the source of truth for application state (active plan, quotas, feature flags). The two sync via webhooks. If they ever drift, Stripe wins — we re-sync from the latest customer.subscription object on every webhook delivery, even for events we don't strictly need.

Plans

Three tiers, monthly or annual. Annual saves ~17% across the board. Quotas reset on the 1st of every billing period.

Admaxxer plans — pricing and quotas (canonical: shared/pricing.ts)
Plan Monthly Annual Connections Chat msgs / mo Team seats
Starter (AD_STARTER) $29 / mo $288 / yr (~17% off) 2 100 1
Pro (AD_PRO) $79 / mo $768 / yr 10 1,000 3
Agency (AD_AGENCY) $199 / mo $1,908 / yr Unlimited (999 internal cap) 10,000 10

For full plan descriptors, feature bullets, and helper functions, see shared/pricing.ts and the customer-facing /pricing page.

Plan key naming

Plan keys are stable, snake_case-uppercase strings: AD_STARTER, AD_PRO, AD_AGENCY, plus _ANNUAL suffixed variants. They appear in three places: (1) Stripe Price metadata plan_key, (2) the workspaces.subscriptionPlan column, (3) the ?plan= query parameter on the OAuth signup flow. Renaming a plan key is a structural migration — never do it casually. Adding a new plan key requires updating shared/pricing.ts, the webhook tier resolver, the quota map, and VALID_PLAN_KEYS in lockstep.

Why we don't bill on usage

Many ad-tech tools bill on revenue managed or ad spend processed. We don't. Flat plans give two things: predictable cost for the customer (no surprise invoice when the customer scales) and predictable revenue for us (no churn from a customer who doesn't need the high tier this month). The trade-off is that we leave money on the table from heavy users — but we get a much cleaner relationship in return.

Free trial — 7 days, no card required

Every signup gets 7 days, no credit card. users.trialEndsAt is set to NOW() + 7d on signup, and subscription_status='trialing'. The trial is app-managed rather than Stripe-managed — quotas come from a trial preset stored in shared/pricing.ts rather than from a Stripe trialing subscription. The UI shows a banner with days remaining; an hourly BullMQ job (trialExpirySweep) flips the status to expired once the window closes. Users in the expired state see an upgrade modal until they pick a plan.

Why app-managed instead of Stripe-managed trialing subscriptions? Two reasons. (1) We don't want to require a card for the trial — Stripe trialing subscriptions still need card collection to convert. (2) We want to enforce quotas for trial users that mirror the Starter plan's limits, so the trial gives you the real Starter experience, not a watered-down preview.

Refund policy — 14 days, monthly

14-day money-back guarantee on monthly plans, once per workspace. If you're not satisfied within the first 14 days of an upgrade, email support and we'll refund your last month directly to the original payment method via Stripe. The "once per workspace" qualifier prevents abuse (cycling between paid and refunded), not legitimate refund requests — we honor honest customer concerns without quibbling.

Annual plans receive a prorated refund mid-term — you only pay for the months you used. Cancellations apply at the end of the current billing period unless you specifically request immediate cancellation with a prorated refund.

Checkout flow

POST /api/billing/create-checkout-session with body { planKey: "AD_PRO" } returns a Stripe Checkout session URL. The session is created with metadata.plan_key set to the planKey (snake_case — see the metadata contract below). The success URL returns the user back to their workspace; the cancel URL returns them to /pricing unchanged.

POST /api/billing/create-checkout-session
Content-Type: application/json

{ "planKey": "AD_PRO" }

→ 200
{ "url": "https://checkout.stripe.com/c/pay/cs_test_..." }

Once the user completes Checkout, Stripe redirects them to the success URL and emits the checkout.session.completed webhook on the server side. The activation flow (next section) handles all server-side state changes.

Activation — checkout.session.completed

On checkout.session.completed, the webhook resolves the plan in priority order, writes the subscription record, and sets workspace quotas. The plan resolver is defense-in-depth — three sources, ranked by trust:

  1. Price metadata plan_key on the Stripe Price object — most trusted, set once at price creation.
  2. Subscription metadata plan_key — copied from Checkout session metadata at activation.
  3. Env-var price-ID map (ADMAXXER_PRICE_*) — fallback for legacy or misconfigured prices.

Once resolved, the webhook writes users.stripeSubscriptionId, sets users.subscription_status='active', and updates workspace quotas atomically (one transaction). If any of these steps fails, the whole transaction rolls back and the webhook returns a non-2xx so Stripe retries. Idempotency is handled via Stripe's event ID — replays are safe.

Plan change — proration handled

POST /api/billing/change-plan with body { newPlanKey: "AD_AGENCY" } calls stripe.subscriptions.update(subId, { items: [...], proration_behavior: 'create_prorations' }). Stripe issues a prorated invoice for the upgrade. The customer.subscription.updated webhook fires, the resolver rebuilds the plan, and the user's plan + quotas flip atomically. No interruption to service — the user keeps using the dashboard while the upgrade processes in the background.

For downgrades, the change applies at the end of the current billing period, not immediately. This avoids a surprise quota tightening mid-month while users are actively working.

Quota enforcement

API middleware checks plan quotas before reaching route handlers. The two enforced quotas are:

On exceed, the route returns HTTP 402 Payment Required with a JSON body containing an upsell URL pointing at /pricing. The UI catches the 402 status and shows an upgrade modal with the next-tier price and benefits. There is no soft overage; you can't accidentally rack up a $500 bill from heavy chat usage.

Why 402 not 403?

HTTP 402 Payment Required is the semantically correct status for a quota-exceeded response. 403 means "you cannot do this thing"; 402 means "you cannot do this thing yet, but paying will fix it." The distinction matters for clients (third-party integrations, SDKs) that want to retry intelligently. Our SDK reads the 402, surfaces an upgrade prompt, and waits for the user to take action rather than retrying blindly.

Per-workspace, not per-user

Quotas attach to the workspace, not the individual user. An Agency-tier workspace with 10 seats shares its 10,000 chat messages across all members. This avoids the "one teammate spams chat and locks out the rest" failure mode while still letting power users scale their workflow. The Agency tier's larger envelope is sized to support a real team, not a single power user.

Customer portal — self-serve

POST /api/billing/portal returns a Stripe Customer Portal session URL. From the portal, users can update their card, view past invoices, swap plan, or cancel — without writing to support. Every change fires a webhook (customer.subscription.updated, invoice.paid, etc.) which reconciles the user's subscription_status and quotas.

Customer Portal is configured in the Stripe Dashboard with the products and prices visible to customers. Admaxxer enables plan swaps, card updates, invoice viewing, and cancellation; tax ID collection is optional.

Payment failure handling

On invoice.payment_failed, Admaxxer flags the account and triggers a retry email via Resend. Stripe Smart Retries handle the retry cadence automatically — typically 4 attempts over 2-3 weeks. After retries are exhausted, customer.subscription.deleted fires and the account loses paid access. Data is preserved for 90 days so users can reactivate without data loss; after 90 days, the account enters a deletion queue.

The data-preservation window is a deliberate UX choice. Many SaaS tools delete data on cancellation, which means a user who comes back two weeks later starts from scratch. Our 90-day buffer gives users a real chance to recover from a bounced card without losing their attribution history, agent chat threads, or saved dashboards. The trade-off is storage cost, which we're happy to absorb in exchange for higher reactivation rates.

The retry email is sent once on the first failure and not repeated until the next retry actually fails. We deliberately don't email-bomb users whose card is declining — Stripe's retry cadence is already aggressive, and a failed payment is a stressful enough signal without our help.

Metadata contract — snake_case (CRITICAL)

Hard-learned consistency rule (GL#41). Every Stripe metadata key is snake_case. Every webhook handler reads snake_case. Mixing conventions — e.g. userId on the create side, user_id on the read side — caused silent failures in webhook activation. The grep test metadata.[A-Za-z]*[A-Z] in server/ must return zero hits. This is enforced as a CI check.

Canonical keys:

Both sides — Checkout creation and webhook reading — use the same keys. New keys go through this section first.

Required environment variables

The Stripe price ID env vars come in pairs (monthly + annual) for each plan tier:

Plus the global Stripe credentials and the canonical app URL:

Run a sanity check after deployment: hit the Stripe Webhooks dashboard and confirm the configured endpoint URL matches $APP_URL/api/webhooks/stripe. A drift between these two breaks all of billing in a way that doesn't show up until the next checkout.

Coupons, promo codes, and discounts

Stripe coupons and promo codes are honored at Checkout. Coupons are configured in the Stripe Dashboard with either a percentage discount, a fixed-amount discount, or a duration restriction (forever, once, repeating). Admaxxer never creates coupons programmatically; new promo campaigns go through the Dashboard so the audit trail lives where finance and growth can see it. The Checkout session is created with allow_promotion_codes: true, which surfaces a "Add promotion code" link on the Stripe-hosted Checkout page.

Discounts apply automatically and the proration math is handled by Stripe. On customer.subscription.updated, the webhook reads the discount block from the event payload and persists the redemption for analytics. We do not let coupons unlock features that aren't part of the underlying plan — a Starter user with a 100% off coupon still has Starter-tier quotas. This avoids the "free Pro forever" failure mode where coupon abuse silently lifts a user into a higher tier.

Tax handling and invoicing

Stripe Tax computes sales tax, VAT, and GST automatically based on the customer's billing address. We enable Stripe Tax at the account level and let it flow through Checkout — customers see the tax line item before they pay. Invoices generated by Stripe include the tax breakdown, the merchant's tax ID, and (where required) the customer's tax ID. EU customers can supply a VAT number in the Customer Portal; we forward it to Stripe Tax for proper B2B treatment.

For US customers, sales tax is computed per state based on Stripe Tax's economic-nexus tracking. We register in states where we cross the threshold; below threshold, no tax applies. Customers can request a tax-exempt status via support; once configured in Stripe, the exemption applies on the next renewal.

Webhook handling — idempotency, retries, and signature verification

Every Stripe webhook hits /api/webhooks/stripe, where the first thing we do is verify the Stripe-Signature header against STRIPE_WEBHOOK_SECRET. Any signature mismatch returns 400 immediately — we never trust an unverified payload, even from a familiar IP. The Stripe SDK does the verification; we just call stripe.webhooks.constructEvent and let it throw on bad signatures.

Idempotency: every Stripe event has a globally unique event.id. We persist a row in stripeEvents on first receipt; subsequent deliveries of the same event ID short-circuit before doing any state mutation. This means Stripe can retry as aggressively as it wants — replays are safe.

The events we handle:

For events we don't strictly need (e.g. customer.created), we still log the receipt and 2xx the response. This keeps Stripe's webhook delivery health metrics green and makes future debugging easier.

Frequently asked

Do I need a credit card to start the trial?
No. The 7-day trial requires no credit card. trialEndsAt is set on signup (NOW() + 7d) and the subscription_status is initialized to 'trialing'. App-managed quotas govern usage during the trial. An hourly BullMQ job (trialExpirySweep) flips the status to 'expired' once the window closes.
How are plan prices and quotas kept in sync across the codebase?
Single source of truth: shared/pricing.ts exports the PLANS map with displayName, monthlyPriceUSD, annualTotalUSD, maxConnections, maxChatMessagesPerMonth, and maxTeamMembers. Every backend map (webhook tier resolver, quota gate, name lookup, price lookup, VALID_PLAN_KEYS) reads from this file. Drifting copies are a known risk — see GL#26.
What happens when I exceed a plan quota?
API middleware checks plan quotas (maxConnections for /api/integrations create; maxChatMessagesPerMonth for /api/chat). On exceed, the route returns HTTP 402 Payment Required with an upsell URL pointing at /pricing. The UI catches the 402 and shows an upgrade modal.
How does plan change work mid-cycle?
POST /api/billing/change-plan {newPlanKey} calls stripe.subscriptions.update(subId, { items: [...], proration_behavior: 'create_prorations' }). Stripe issues a prorated invoice for the upgrade and the customer.subscription.updated webhook flips the user's plan + quotas atomically. No interruption to service.
Why do all Stripe metadata keys use snake_case?
Hard-learned consistency rule (GL#41). Every Stripe Checkout metadata key is snake_case (user_id, plan_key, ga4_client_id, utm_source, utm_medium, utm_campaign), and every webhook handler reads snake_case. Mixing conventions caused silent failures in webhook activation. The grep test metadata.[A-Za-z]*[A-Z] in server/ must return zero hits.
What's the refund policy?
14-day money-back guarantee on monthly plans, once per workspace. Annual plans receive a prorated refund mid-term — you only pay for the months you used. Refunds are issued via Stripe directly to the original payment method.
Can I downgrade my plan?
Yes. Use the Customer Portal (POST /api/billing/portal returns the portal session URL). You can downgrade at any time; the change applies at the end of the current billing period. Quotas tighten on the next renewal.
What happens if my card fails?
Stripe Smart Retries kick in automatically. The invoice.payment_failed webhook flags the account and triggers a retry email. After Stripe exhausts retries, customer.subscription.deleted fires and the account loses paid access — but data is preserved for 90 days.

Next steps

Reference: /docs/ADMAXXER-BILLING.md.