| Feature | Free | Team · $20/seat/mo | Business · $40/seat/mo | Enterprise · Custom |
|---|---|---|---|---|
| Annual price | — | $192/seat/yr (20% off) | $384/seat/yr (20% off) | Negotiated |
| Stripe required | No | Yes | Yes | No — off Stripe |
| Fixes per 30 days | 5 | Unlimited | Unlimited | Unlimited |
| Doc runs per 30 days | 1 | Unlimited | Unlimited | Unlimited |
| Linear cycles per 30 days | 5 | Unlimited | Unlimited | Unlimited |
| Connected repos | Unlimited | Up to 5 | Unlimited | Unlimited |
| All 7 workflows | Limited | Full access | Full access | Full access |
| Jira integration | No | No | Yes | Yes |
| Audit logs | No | No | Yes | Yes |
| Usage reporting | No | No | Yes | Yes |
| Priority fix queue | No | No | Yes | Yes |
| SSO / SAML | No | No | No | Yes |
| Full RBAC | No | No | No | Yes |
| VPC / compliance | No | No | No | Yes |
| Overage behavior | Hard stop | No limits | No limits | No limits |
| Free tier reset | Rolling 30d per tenant | N/A | N/A | N/A |
Free tier counters are enforced entirely in Hydra's DB. No Stripe record exists for a free tenant. Enterprise is handled outside Stripe entirely.
| Price ID env var | Amount | Interval | Per |
|---|---|---|---|
STRIPE_TEAM_MONTHLY_PRICE_ID | $20.00 | month | seat |
STRIPE_TEAM_ANNUAL_PRICE_ID | $192.00 | year | seat |
STRIPE_BUSINESS_MONTHLY_PRICE_ID | $40.00 | month | seat |
STRIPE_BUSINESS_ANNUAL_PRICE_ID | $384.00 | year | seat |
Stripe creates an invoice each month. Customer is charged monthly. Proration on mid-cycle changes is bundled into the next monthly invoice.
Customer is billed the full annual amount on day 1. Stripe creates one invoice per year. Seat additions mid-year are prorated and billed immediately as a separate invoice line item.
Switching interval (month → year or year → month) mid-period is not supported natively. Requires Stripe Subscription Schedules — a future edge case. In v1: customers on monthly must cancel and re-subscribe to switch to annual. Annual customers cannot switch to monthly mid-year.
The monthly/annual toggle on the billing page changes the price_id sent to POST /api/billing/checkout. Nothing else changes. Stripe handles the rest.
invoice.payment_failed.invoice.paidplan_tier, stripe_subscription_status=active, stripe_current_period_end, stripe_seat_count in DB.invoice.payment_failedstripe_subscription_status=past_due. Start 7-day grace period. Do not immediately revoke access.invoice.payment_action_requiredincomplete until authenticated.Before applying any seat change or plan upgrade, call create_preview to show the customer exactly what they'll be charged. The preview returns a hypothetical invoice with line items.
Critical: capture proration_date = int(time.time()) once. Pass identical value to both the preview call and the apply call. Proration is calculated to the second — any gap changes the amount.
Configure in Dashboard: Settings → Business settings → Customer emails. Enable "Successful payments" and "Failed payments." Stripe handles the email — you do not build invoice emails.
Customers can view and download all past invoices from the Customer Portal without contacting you. Enable under Portal configuration → Invoice history.
Annual customers receive one large invoice per year (e.g. $192 × 10 seats = $1,920). Mid-year seat additions generate a separate prorated invoice for the remainder of the year.
| Object | Created when | Key fields |
|---|---|---|
Customer cus_ | First upgrade attempt | email, name, metadata: hydra_tenant_id |
Subscription sub_ | Checkout completes | items[0].price_id, items[0].quantity |
SubscriptionItem si_ | With subscription | id required for seat/plan updates |
| Invoice | Every billing cycle | status, amount_due, period_start/end |
| PaymentIntent | Inside invoice | status, last_payment_error |
| Column | Set by |
|---|---|
plan_tier | invoice.paid handler |
stripe_subscription_status | All subscription events |
stripe_seat_count | invoice.paid, sub.updated |
stripe_current_period_end | invoice.paid |
stripe_customer_id | Checkout session creation |
| Event | Action |
|---|---|
invoice.paid | Provision access — canonical signal |
invoice.payment_failed | Set past_due, start grace period |
invoice.payment_action_required | 3DS — send auth email to customer |
checkout.session.completed | Store subscription_id, await invoice |
customer.subscription.created | If active: provision; if incomplete: hold |
customer.subscription.updated | Handle seat, status, price changes |
customer.subscription.deleted | Schedule revocation at period_end |
entitlements.active_entitlement_summary.updated | Sync feature flags from Stripe |
Idempotency is not optional. A handler that runs twice must produce the same result. A missed event = customer paid, no access. A double-processed event = double charge. Every handler checks current state before writing.
| Ticket | Title | Est | Priority |
|---|---|---|---|
| BILL-01 | Stripe account + Products + Prices + Portal config | 0.5d | Urgent |
| BILL-02 | DB migration — billing columns on tenants | 2h | Urgent |
| BILL-03 | Free tier enforcement middleware | 0.5d | Urgent |
| BILL-04 | Free tier reset job (rolling 30-day per tenant) | 2h | High |
| BILL-07 | Webhook endpoint + signature verification | 2h | Urgent |
| BILL-05 | Checkout session endpoint | 0.5d | Urgent |
| BILL-06 | Post-checkout success page + session retrieval | 2h | High |
| BILL-08 | invoice.paid handler — provision paid access | 2h | Urgent |
| BILL-09 | invoice.payment_failed + grace period | 2h | High |
| BILL-12 | checkout.session.completed + subscription.created | 2h | High |
| BILL-10 | customer.subscription.updated handler | 0.5d | Urgent |
| BILL-11 | customer.subscription.deleted handler | 2h | High |
| BILL-13 | Seat update — proration preview + apply | 0.5d | High |
| BILL-14 | Plan upgrade endpoint (Team → Business) | 2h | High |
| Ticket | Title | Est | Priority |
|---|---|---|---|
| BILL-15 | Customer Portal session endpoint | 1h | High |
| BILL-16 | Access gating middleware | 0.5d | Urgent |
| BILL-17 | Billing settings API | 0.5d | High |
| BILL-22 | Free tier enforcement unit tests | 2h | High |
| BILL-18 | Frontend — upgrade prompt components | 0.5d | High |
| BILL-19 | Frontend — billing/upgrade page | 0.75d | High |
| BILL-20 | Frontend — billing settings page | 0.5d | High |
| BILL-21 | Frontend — post-checkout success page | 2h | Medium |
| BILL-23 | Stripe test mode — integration tests | 1d | High |
| BILL-24 | Webhook handler tests via Stripe CLI | 1d | High |
| Decision | Default |
|---|---|
| Seat definition | Developer with active connected repo |
| Billing cycle anchor | Per-customer signup date |
| Proration in portal | create_prorations |
| billing_mode | classic (Stripe default) |
| Entitlements API | Enable it |
| Grace period on failed payment | 7 days |
Defaults are safe to ship with. If Tyler doesn't decide, use the defaults — don't block coding on decisions that can be changed later.
https://api.hydra.iru.dev/api/webhooks/stripe — all 8 events. Copy signing secret to env.brew install stripe/stripe-cli/stripe && stripe loginfree_tier_fixes_used, free_tier_doc_runs_used, free_tier_cycles_used, free_tier_reset_at, stripe_customer_id, stripe_subscription_id, stripe_subscription_status, stripe_price_id, stripe_current_period_end, stripe_seat_count, plan_tier (default 'free'). Add 3 indexes. Test both forward and backward migration before merging — rollback must work.FreeTierLimitReached on hard stop — triggers upgrade prompt. Increment counter only after successful execution. Paid tenants (plan_tier != 'free') skip the check entirely. Initialize free_tier_reset_at on first run.free_tier_reset_at < NOW(). Zeros all three counters. Advances free_tier_reset_at by 30 days from previous value — not from NOW(). This preserves the rolling window. Must be idempotent: safe to run multiple times per hour without double-zeroing.POST /api/webhooks/stripe. Verify Stripe-Signature header on every request — never skip. Return 400 only on invalid signature. Return 200 for all handled events, even if the handler throws an exception (log, don't crash). Log every event with event.id for replay tracing. Route by event.type to handler map.invoice.paid fires. Access is provisioned. End-to-end in test mode.POST /api/billing/checkout — creates Stripe Customer on first call (stores cus_ ID immediately). Creates Checkout Session with mode=subscription, adjustable_quantity (min 1, max 500), returns session URL. Frontend redirects — never construct Checkout URL on the frontend. Disable submit button on click to prevent double-submit creating two Customers.payment_status=paid. Store subscription_id. Return "status": "provisioning" — do not provision here. Access is granted by invoice.paid webhook, not by this endpoint. Show "provisioning" state to user until webhook fires and DB updates.plan_tier, stripe_subscription_status=active, stripe_current_period_end, stripe_seat_count in DB. Idempotency check required: if tenant is already active at this plan, return early without writing. This fires multiple times — duplicate writes would corrupt the period_end on each renewal.stripe_subscription_status = 'past_due'. Do not revoke access. Start 7-day grace period. Send notification to billing contact. Note: Stripe Smart Retries run before this event fires — payment_failed fires after Stripe has exhausted automatic retries, not on first decline. Also implement handler for invoice.payment_action_required: send customer an email with Stripe-hosted 3DS authentication link.checkout.session.completed: store subscription ID, do not provision yet — wait for invoice.paid. subscription.created: if status=incomplete, hold at free; if status=active, provision. Out-of-order risk: invoice.paid can arrive before subscription.created. Handle missing tenant lookup gracefully — log and return 200, do not crash.data.previous_attributes for what changed. Handle 4 distinct cases: (1) quantity changed → update stripe_seat_count; (2) status: past_due → active → re-provision access; (3) status → canceled/unpaid → schedule revocation at current_period_end, not now; (4) items[0][price] changed → update stripe_price_id and plan_tier via price_id_to_tier() helper. All cases must be idempotent.stripe_subscription_status = 'canceled'. Do not revoke access immediately. Paid access continues until current_period_end — this is Stripe's contract with the customer. Schedule a job to flip plan_tier = 'free' at current_period_end. Access gating middleware reads stripe_current_period_end to allow access until that timestamp.POST /api/billing/seats. Step 1 (preview): call POST /v1/invoices/create_preview, return proration amount to frontend before user confirms. Step 2 (apply): user confirms, call Subscription.modify with new quantity. Critical: capture proration_date = int(time.time()) once in step 1. Pass same timestamp to step 2. Never recalculate — Stripe calculates to the second, any gap changes the amount and causes customer disputes.POST /api/billing/upgrade. Swap items[0][price] to Business price ID. Always explicitly pass current quantity in the modify call — Stripe resets quantity to 1 if not included when changing price. Same proration preview + apply pattern as BILL-13. Same proration_date timestamp rule applies.SubscriptionRequired error. A past_due tenant within grace period is allowed through. Every backend endpoint exists.POST /api/billing/portal. Call stripe.billing_portal.Session.create with customer ID, portal config ID, and return URL. Return portal URL to frontend for redirect. Portal URL expires when the session ends — never cache. Generate a new session on every click of "Manage subscription."plan_tier + stripe_subscription_status + stripe_current_period_end. Rules: active → allow. past_due, within 7d grace → allow. past_due, past 7d → block. canceled, before period_end → allow. canceled, after period_end → block. Return structured SubscriptionRequired error with upgrade URL.GET /api/billing/status. Return: current plan_tier, stripe_seat_count, stripe_current_period_end, stripe_subscription_status, and portal URL. For free users: return current usage vs limits for all 3 counter types. Used by billing settings page and post-checkout success polling.FreeTierLimitReachedfree_tier_reset_at is NULL on first run → initializes to now() + 30dreset_at by 30d from previous value, not from now()Non-punitive copy from hydra-pricing-plg.md Section 4. CTA links to billing/upgrade page. Do not block the entire UI — show prompt alongside the locked feature.
price_id sent to backend. Upgrade button calls POST /api/billing/checkout, then redirects to the returned Stripe Checkout URL.Required: Disable button after first click (prevent double Customer creation). Show proration preview for existing subscribers changing plan or seats. Display specific error message on backend failure — not a generic toast. Checkout URL from backend only — never construct it on the frontend.
POST /api/billing/portal → redirects to Customer Portal. Generate new portal URL on every click — never cache.
Free user: Current usage vs limits (3 progress bars: fixes, doc runs, cycles). Days until 30-day reset. "Upgrade" CTA linking to billing/upgrade page.
GET /api/billing/status every 2s, up to 30s. Show "provisioning" spinner until plan_tier is updated in DB by the invoice.paid webhook. Provisioning is async — the success page cannot show access confirmed until the webhook has fired.| Scenario | Card / Method | Expected state after |
|---|---|---|
| Successful upgrade (monthly) | 4242 4242 4242 4242 | plan_tier=team, status=active |
| Successful upgrade (annual) | 4242 4242 4242 4242 | plan_tier=team, annual price_id |
| Payment failure at checkout | 4000 0000 0000 0002 | User stays free, no sub created |
| Post-upgrade payment failure | 4000 0000 0000 0341 | status=past_due, access within grace |
| Grace period expiry | manual advance | plan_tier=free, access revoked |
| Seat increase with proration | seat update API | stripe_seat_count updated, invoice line created |
| Team → Business upgrade | upgrade API | plan_tier=business, quantity preserved |
| 3DS / SCA (EU card) | 4000 0025 0000 3155 | payment_action_required handled, provisioned after auth |
stripe listen --forward-to localhost:8000/api/webhooks/stripe to replay test events for all 8 event types. Each event must be fired twice to verify idempotency — the second firing must be a no-op. Cover: failed payment → grace period → access revocation. Cancellation → access until period_end. subscription.updated with seat change. subscription.updated with plan change.grep -r "sk_test_" . → nothing. git log -p | grep sk_test → nothing.Webhook.construct_event().alembic downgrade -1 must succeed if needed.STRIPE_WEBHOOK_SECRET.BILLING_ENABLED=false. All tenants treated as free. Fix, re-run checklist, try again.invoice.paid deliver? Check app logs for webhook receipt. Check DB stripe_subscription_status. If webhook received but DB not updated: replay event from Dashboard. Emergency: manually set plan_tier and stripe_subscription_status while investigating root cause.proration_date in preview call vs apply call. If different: this is the drift bug — issue partial refund, fix BILL-13 to pass preview's proration_date to the apply call. If same: cent-level variance is expected, explain to customer.BILLING_ENABLED=false env var. All tenants treated as free. Safe for PLG — users prefer free access over an error. No deploy required.alembic downgrade -1 removes billing columns.Billing is the right first internal target. Bounded scope. Clear inputs and outputs. Real production risk if wrong. If Hydra can audit and improve its own payment code, that is a proof point worth showing.
hydra-stripe-billing-research.md — Stripe API research, primary citations, 24 Kanban cardshydra-stripe-billing-spec.md — Full Python implementation spec, every endpoint + handlerhydra-stripe-billing-roadmap.md — Extended roadmap with security checklist, runbooks, rollbackhydra-stripe-billing-linear.csv — Import: Linear → Settings → Import → CSVhydra-billing-deck.html — This deck