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

Five tiers, monthly or annual. Annual = monthly × 10 (two months free). The primary v2.2 quota is tracked pixel events per month — Starter 15,000, Growth 100,000, Pro 750,000, Scale 3,000,000, Enterprise 15,000,000. Team seats, ad-account connections, and Maxxer chat messages are unlimited on every paid plan (Maxxer chat uses your own AI provider key, billed by the provider). The events quota is pooled per user: if you own multiple shops, the sum of their tracked events shares one pool, and a single upgrade lifts the cap for every shop you own.

Admaxxer plans — pricing and quotas (canonical: shared/pricing.ts)
Plan Monthly Annual Tracked events / mo Retention Team seats
Starter (AD_STARTER) $9 / mo $90 / yr (2 months free) 15,000 12 months 1
Growth (AD_GROWTH) $29 / mo $290 / yr (2 months free) 100,000 18 months 3
Pro (AD_PRO) $79 / mo $790 / yr (2 months free) 750,000 24 months 10
Scale (AD_AGENCY) $199 / mo $1990 / yr (2 months free) Unlimited 36 months 25
Enterprise (AD_ENTERPRISE) $499 / mo $4990 / yr (2 months free) Unlimited 60 months Unlimited
Platform (AD_PLATFORM) $999 / mo $9990 / yr (2 months free) Unlimited 60 months Unlimited

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 our job queue 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.

What if I'm stuck on the billing page?

On rare occasions you might find every page of Admaxxer redirecting you back to /billing. This usually means your free trial wasn't started yet. Look for the green "Start 7-day free trial — no credit card required" button at the top of the billing page and click it. Your trial activates instantly and you'll be sent to onboarding.

If you don't see the green button, refresh the page once. The card renders for any user who hasn't activated their trial yet — it doesn't matter how you arrived on /billing, and it does not depend on the ?welcome=1 query string surviving the bounce.

Why this can happen: in October 2026 we shipped an "explicit trial" flow where new users actively click to start their trial (so we never charge a card without consent). A small number of users completed signup before clicking the button — we now auto-bootstrap a 7-day Starter trial for anyone who finishes onboarding without one, so the loop is closed for new users. If you're an older user who landed in this state, the green CTA on /billing will always start your trial.

Trials last 7 days, no credit card, Starter quotas unlocked (15,000 tracked events / month pooled across every shop you own, unlimited ad connections, the Maxxer AI agent, MMM/incrementality/ad-level LTV are paid-only). The trial is Starter-only and once per user — if you need higher event quotas or paid-only features before your trial ends, subscribe directly to that plan; your remaining trial days are not lost, you simply move to paid earlier. When the trial ends, the dashboard pauses ingestion until you add a payment method via Stripe.

Quick FAQ

Can I switch my trial to a higher plan?
No — the trial is Starter-only. To get more events, retention, or paid-only features (MMM, incrementality, ad-level LTV) during your trial, subscribe to the higher plan via Stripe Checkout. Your remaining trial days aren't lost; you simply move to paid earlier.
I added a 2nd shop. Why does its event quota look different from my main shop?
It shouldn't. The 15,000-event Starter trial quota is pooled across every shop you own, and every newly-added shop inherits your current plan/status automatically. If you're seeing different quotas per shop, refresh /billing — older shops created before May 2026 may need a one-time backfill (R26_06 migration).
The button says "Start 7-day free trial — no credit card required". Where is it?
Top of /billing, before the plan grid. If it's missing, refresh once. The card renders whenever your users.licenseActivatedAt is null.
Why was I bounced back to billing?
Every /api/v1/* route checks for an active license. Without one, the dashboard redirects to /billing. Clicking the green CTA activates your trial and unlocks the dashboard immediately.

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 — hard cap, no overage

Pricing v2.2 enforces a single primary metered quota: tracked pixel events per month, pooled across every shop a user owns. The pixel ingestion endpoint reads users.monthly_events_used_pooled on every event; if it has reached PLANS[plan].eventsPerMonth, the request returns HTTP 429 Too Many Requests with { error: 'quota_exceeded' }. Ingestion stops on every shop the user owns until either (1) the 30-day window resets and the counter rolls back to zero, or (2) the user upgrades to a higher tier.

We never bill overage. A user who hits the cap mid-month cannot accidentally run up a $500 bill — they get a hard "no" until they consciously choose to upgrade. This is the same shape for trial and paid users.

The 80% warning + 100% exceeded emails

Two emails fire on the way to the cap, once per 30-day window:

Both *_sent_at flags are cleared on rollover by monthlyEventsResetCron (top-of-hour cron). When the 30-day window expires, the reset cron zeros the counters AND nulls the suppression columns, so the next cycle's emails fire as expected.

Pooled per user, not per workspace

The quota attaches to the human, not the workspace. If you own three shops, the SUM of their monthly_events_used shares one pool, and a single upgrade lifts the cap for every shop you own. The hourly monthlyEventsCounterCron recomputes per-shop usage from our analytics warehouse truth and writes the per-user sum to users.monthly_events_used_pooled; the ingestion path reads from that single column on every event so cross-shop traffic is accounted for in real time.

Why 429 not 403?

HTTP 429 Too Many Requests is the semantically correct status for "rate limited / quota exceeded by volume" — it tells the client (an SDK, a Shopify pixel app, a server-side CAPI relay) that a retry will succeed once a counter resets. 403 would imply "you are not authorized to do this", which is not the situation; we are saying "you've used your allotment, try again next window or upgrade."

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 our job queue 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?
Pricing v2.2 hard-caps tracked events per month — the primary metered quota. When the per-user pooled counter (sum of monthly_events_used across every workspace you own) reaches plan.eventsPerMonth, the pixel ingestion endpoint returns 429 with code='quota_exceeded' on every shop you own. We do not bill overage. To resume tracking, either upgrade to a higher plan (in-app, instant) or wait for the 30-day window to reset. Connection counts and team seats are unlimited on every paid plan — they're informational, not gated.
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.
What if I'm stuck on the billing page and every dashboard route bounces me back?
This usually means your free trial wasn't started yet. Look for the green 'Start 7-day free trial — no credit card required' button at the top of the billing page and click it. Your trial activates instantly and you'll be sent to onboarding. If you don't see the green button, refresh the page once — the card renders for any user who hasn't activated their trial yet, regardless of how they arrived on /billing.
Why does the billing page show no trial CTA when I land on it?
If the green 'Start 7-day free trial — no credit card required' card is missing, refresh the page once. The TrialActivationCard renders whenever your user record has license_activated_at IS NULL — it does not depend on the ?welcome=1 query string, so it survives any redirect, deep link, or bounce. If the refresh still shows no CTA, your trial is already active and you should be able to reach /dashboard directly.
Can I switch my trial to Growth, Pro, or another plan?
No. The 7-day free trial is Starter-only. If you need more events, retention, or advanced features (MMM, incrementality, ad-level LTV) before your trial ends, subscribe to the higher plan via Stripe Checkout — your remaining trial days are not lost, you simply move to paid earlier. There is no second free trial: every subsequent plan change happens via the Stripe Customer Portal or /api/v1/billing/change-plan, fully metered.
I have multiple shops. Do I get a separate trial for each?
No. The 7-day trial is per user, not per workspace. Every shop you add (via the 'Add shop' button in the workspace switcher) inherits your current trial: same Starter quotas, same end date, same status. The 15,000 monthly event quota is pooled across every shop you own — if Shop A consumes 12,000 events, Shop B has 3,000 remaining for the same window. When you upgrade to a paid plan, every shop you own moves to the same plan automatically.
What happens when I hit my monthly tracked-event cap?
You'll receive a single 80% warning email when your pooled usage crosses 12,000 events on Starter (80,000 on Growth, 600,000 on Pro, 2,400,000 on Scale, 12,000,000 on Enterprise). At 100% you'll receive a second 'cap reached' email and the pixel will hard-cap — no further events are ingested on ANY of your shops until either (1) the 30-day window resets and the counter rolls back to zero, or (2) you upgrade to a higher tier and resume ingesting immediately. Hard-capping is the same for trial and paid users; we never bill overage. Both emails are suppressed against re-firing within the same window via quota_warning_80_sent_at and quota_exceeded_sent_at flags on the user record, which are cleared on rollover by the monthlyEventsResetCron.
Why did I receive an 'event cap reached' email?
Because the SUM of tracked events across every workspace you own crossed your plan's pooled cap. The 80% warning fires once per 30-day window from server/queues/quotaAlertCron.ts (top-of-hour cron); the 100% exceeded email fires once when the same scan detects pooledUsed >= eventsPerMonth. Both emails reference the same per-user pool — if Shop A burned 14,000 events and Shop B burned 1,200 events on Starter (15k cap), the pool is at 15,200 and the cap is hit even if no individual shop maxed out. The reset cron rolls the counter back to zero after the 30-day window expires; the suppression flags are cleared at the same time, so the next cycle's emails will fire again as expected.

Next steps

Reference: /docs/ADMAXXER-BILLING.md.