Organization billing

APort sells Team through Stripe-hosted Checkout at USD 499/month. Enterprise
remains sales-led at the published USD 4,990/month offer, with rollout terms by
agreement. The launch pricing decision and evidence are recorded in
_plans/stripe-billing-decision.md.

web/src/lib/pricing.config.ts owns plan ids, prices, entitlements, supported
checkout intervals, Stripe price environment names, CTAs, and pricing FAQs.
Annual price names are reserved but annual Checkout is disabled: no annual
price or discount has been agreed. There is no usage meter or client Stripe SDK.

Start with a pilot or paid Team

Goal Minimum setup beyond the existing APort deployment
Invite a no-charge Team or Enterprise pilot Apply both billing migrations in every regional D1 database; configure the app origin, existing authentication, and invitation email delivery. No Stripe variables are required.
Accept Team payments Apply the same migrations; configure STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and STRIPE_PRICE_TEAM_MONTHLY; create the monthly Team Price, enable the Portal, and register the nine webhook events below.

For a pilot, sign in as a platform registry admin, open
/admin?tab=organizations, and select Launch Pilot. Choose Team or
Enterprise, enter the contact email, expiry, and any additional members. The
contact is invited as org_admin by default. The API equivalent is
POST /api/admin/organizations/pilot; it accepts the existing admin
authentication, including a server-held ADMIN_TOKEN. An ordinary organization
admin cannot grant a pilot through this endpoint.

The pilot is a no-charge, time-limited grant of Team or Enterprise access.
It defaults to 14 days; invitation links last seven days. No Customer,
subscription, or payment is created. Invited users become active members only
after redeeming their emailed link while the pilot is current. Organization
admins can then invite additional members through the normal Members tab.
An expired or cancelled pilot has Free entitlements; pending invitations stay
unredeemed, and existing membership and passport records remain. A stored
Stripe subscription takes precedence over pilot metadata, including when that
subscription is unpaid or cancelled. Adding a pilot does not override a lapsed
Stripe subscription.

For both modes, keep the deployment's existing JWT_SECRET and authentication
configuration. Email invitations require a verified sender in EMAIL_FROM
and the configured mail provider's credential. With the default Resend
provider, use RESEND_API_KEY (or EMAIL_API_KEY). Set the Functions
APP_BASE_URL explicitly per environment: https://sandbox.aport.io for
preview, https://aport.io for production, or http://localhost:3000 locally.
Email callbacks use that deployment origin; local callbacks use port 8787.
For a separate Pages branch deployment, configure its own app origin before
sending invitations. These are server settings, not browser billing secrets.

The current Wrangler file lists preview/production app URLs directly under
[env.preview] and [env.production], outside a [vars] table. Those entries
are not evidence that the Functions received environment variables. Set
APP_BASE_URL in the matching Pages environment and redeploy; confirm an
invitation's callback and return link both point to that environment.

For paid Team, the three Stripe values above are the minimum billing variables.
STRIPE_PORTAL_CONFIGURATION_ID is optional when the account's default Portal
has the settings below. Enterprise Price ids are needed only for agreed
Enterprise subscriptions collected through Stripe. Legacy Price lists are
needed when rotating prices, and annual variables stay unset. No Stripe
publishable key is required. Start in preview with sandbox credentials, complete
a Checkout and webhook round trip, then configure production with its separate
live credentials and endpoint signing secret.

Provision Stripe

Use a Stripe sandbox for local/preview testing and a separate live configuration
for production. Never copy live keys into preview or commit keys to the repo.

  1. Create an APort Team product with one recurring USD 499.00, monthly

Price, quantity one. Copy the Price id into STRIPE_PRICE_TEAM_MONTHLY.
Checkout verifies the Price is active, in USD, monthly, and matches the public
amount before creating a session. A mismatched configuration returns 503.

  1. Enterprise is manual in pricing config. Create an APort Enterprise

recurring product/Price only when collecting agreed Enterprise subscriptions
through Stripe; configure its id for webhook recognition. Do not enable
Enterprise or annual purchases in the Portal for this release.

  1. Enable Customer Portal payment-method updates, invoice history, and

cancellation at the end of the current period. Disable plan changes,
quantity changes, and immediate cancellation for this initial single-price
self-service offer. Put a dedicated Portal configuration id in
STRIPE_PORTAL_CONFIGURATION_ID, or configure the account's default Portal.

  1. Set server credentials and price ids as described below. No publishable key

is needed. Give a restricted server key access to Customers (read/write),
Prices (read), Checkout Sessions (read/write), Subscriptions (read), and
Customer Portal Sessions (write). Reconciliation also reads the latest
invoice id from the subscription; it does not download card information.

  1. Add an event destination in Stripe Workbench with API version

2026-08-26.dahlia, matching the pinned server SDK. The endpoint is
https://aport.io/api/billing/webhook in production and
https://sandbox.aport.io/api/billing/webhook in preview. Copy each endpoint's
signing secret into that environment's STRIPE_WEBHOOK_SECRET.

  1. Subscribe to exactly these implemented events:
    • checkout.session.completed
    • checkout.session.expired
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • customer.subscription.paused
    • customer.subscription.resumed
    • invoice.payment_succeeded
    • invoice.payment_failed

Checkout creates an org-owned Customer and writes org_id, user_id, and plan
on the session and subscription. The Customer carries org_id. For a manual
Enterprise Stripe subscription, use the organization's existing Customer,
matching customer/subscription org_id metadata, and a configured Enterprise
Price. Do not create a second nonterminal subscription for an organization.

References: Checkout,
Portal,
subscription webhooks,
and one-subscription guidance.

Rotate a Price without removing existing access

New Checkout sessions use only the current STRIPE_PRICE_TEAM_MONTHLY value.
Existing subscriptions can also match the comma-separated historical ids in
STRIPE_PRICE_TEAM_LEGACY or STRIPE_PRICE_ENTERPRISE_LEGACY. Both environment
names belong to the shared pricing config. Unknown ids and ids mapped to more
than one plan fail closed; a current Team price that also maps to Enterprise
disables Checkout before any Stripe call. Subscription metadata cannot grant
recognition to an unknown price.

  1. Add the outgoing Price id to that plan's legacy list, preserving all earlier

ids, and deploy this configuration in the affected environment first.

  1. Replace the current Price id and redeploy. The new Team Price must still

match the shared public offer. Never copy sandbox ids into production.

  1. Replay a subscription update and a successful invoice event for an existing

subscription, then confirm its stored price and paid access remain intact.
Confirm a new Checkout uses the new current id. Whitespace and repeated ids
in the same plan's list are accepted; cross-plan duplicates are denied.

  1. Keep historical ids while subscriptions or pending sessions can still use

them, including through rollback. Stripe documents that archiving a price
leaves existing subscriptions active.
Archiving alone is not a reason to remove its mapping. A rollback must retain
legacy-aware recognition and the mappings; otherwise suspend reconciliation
and new purchases until compatible code is restored.

This is an operator-maintained allowlist, not automatic trust in a Stripe
product name or caller metadata. It requires no data migration. If an earlier
configuration already persisted a Free snapshot for a valid old price, add its
mapping and replay an event to reconcile the authoritative subscription.

Cloudflare setup and persistence

Apply migrations/0009_org_billing.sql and
migrations/0010_billing_customer_attempt.sql through the existing migration workflow
in every regional D1 database before deploying the Pages Functions:

pnpm exec wrangler d1 migrations apply agent-passport-us-preview --remote
pnpm exec wrangler d1 migrations apply agent-passport-eu-preview --remote
pnpm exec wrangler d1 migrations apply agent-passport-ca-preview --remote
pnpm exec wrangler d1 migrations apply agent-passport-us --remote --env production
pnpm exec wrangler d1 migrations apply agent-passport-eu --remote --env production
pnpm exec wrangler d1 migrations apply agent-passport-ca --remote --env production

Configure these Pages secrets independently in Preview and Production:

Name Required for
STRIPE_SECRET_KEY Checkout, Portal, and webhook reconciliation
STRIPE_WEBHOOK_SECRET Webhook signature verification and enabling Checkout and Portal sessions
STRIPE_PRICE_TEAM_MONTHLY Self-service Team Checkout and Team recognition
STRIPE_PRICE_TEAM_ANNUAL Reserved; leave unset until annual pricing is approved
STRIPE_PRICE_ENTERPRISE_MONTHLY Recognition of manually agreed Enterprise subscriptions
STRIPE_PRICE_ENTERPRISE_ANNUAL Reserved; leave unset until annual pricing is approved
STRIPE_PRICE_TEAM_LEGACY Optional comma-separated historical Team Price ids; recognition only
STRIPE_PRICE_ENTERPRISE_LEGACY Optional comma-separated historical Enterprise Price ids; recognition only
STRIPE_PORTAL_CONFIGURATION_ID Optional dedicated Portal configuration

Use Cloudflare Pages project Settings → Variables and Secrets, with the
Preview or Production environment selected. The Pages CLI can provision
production secrets, for example:

pnpm exec wrangler pages secret put STRIPE_SECRET_KEY --project-name agent-passport
pnpm exec wrangler pages secret put STRIPE_WEBHOOK_SECRET --project-name agent-passport
pnpm exec wrangler pages secret put STRIPE_PRICE_TEAM_MONTHLY --project-name agent-passport

A secret name in documentation is not a deployed value. Verify settings for
both environments and redeploy so the Functions receive them. Secrets are not
placed in [vars]; named Wrangler environments do not inherit top-level vars.
Local development uses untracked .dev.vars. Do not use NEXT_PUBLIC_ keys.

Billing uses the existing D1_US, D1_EU, and D1_CA bindings resolved from the
organization's region. No new Durable Object protocol, binding, or deployment
is required. org_billing stores only the org/customer/subscription/price ids,
plan, status, period dates, cancellation flag, latest invoice id, last event id,
and update time, plus the internal checkout attempt. billing_events stores
processed event ids and an outcome. Normal KV organization writers cannot
replace this separate billing record.

D1 reads start on the primary. Checkout and webhook writes share a 120-second
organization lease. The write checks ownership and expiry in SQL, and a D1
batch commits the state and event receipt together. Stripe network calls time
out after 15 seconds, with at most one SDK retry. An expired holder cannot
commit state or clear another holder's lease.

Customer creation first saves its exact parameters, idempotency key, and start
time in customer_attempt_json. A retry reuses the stored name even if the
organization was renamed. The returned Customer id and cleared attempt commit
together; failed writes and lost responses leave the intent available to retry.
At 23 hours an unresolved customer attempt returns HTTP 409
customer_review_required with a support contact, without another creation call.
The status API never exposes these internal parameters.

Operators must inspect Stripe Customers and request logs for the exact org_id
and stored idempotency key before resolving an old attempt. Verify the existing
Customer's org metadata and subscriptions, then bind that Customer id and clear
customer_attempt_json together while billing writes are paused. Do not clear an
ambiguous attempt or invent a new key merely to retry. Verified subscription
webhooks can also resolve the binding. For attempts made before this migration,
inspect any known unresolved customer creations before re-enabling Checkout;
the migration cannot reconstruct a lost response that was never recorded.
The nullable column preserves existing customer/subscription rows. Keep both
migrations on rollback; do not run the previous customer-creation code against
unresolved attempts.

Checkout saves its exact request and idempotency key before the Stripe call.
A retry reuses those parameters; an existing open session is reused. Any
nonterminal Stripe subscription blocks another purchase, including unpaid and
incomplete subscriptions. An unresolved attempt older than 23 hours requires
operator review before Stripe's 24-hour minimum idempotency retention can lapse.
Do not clear such an attempt blindly: inspect the Customer's sessions and
subscriptions in Stripe, reconcile any completed subscription, expire any open
session, then clear checkout_json for that org only after confirming no
pending purchase remains. A 409 billing_busy asks the caller to retry.
See Stripe's idempotency retention and parameter rules.

API and authorization

Route Authorization and response
GET /api/billing/status?org_id=... Active org member; allowlisted billing snapshot, effective access, and management availability
POST /api/billing/checkout Persisted org_admin or org_billing; JSON {org_id, plan: "team"}; returns {url}
POST /api/billing/portal Persisted org_admin or org_billing; JSON {org_id}; returns only {url}
POST /api/billing/webhook Raw body with a verified Stripe signature, five-minute tolerance; no session cookie needed

Billing management accepts signed-in users, not API keys or a registry-admin
role without organization membership. Optional success_url, cancel_url, and
return_url must match the request's origin. Browser mutations also verify
Origin. Deployed billing UI calls its own origin, so preview never returns to production.
All billing responses are JSON with no-store headers. Missing configuration,
invalid signatures, database failures, and Stripe failures do not grant access.
Clients receive neither secret names nor raw Stripe objects.

Webhook ingress buffers at most 256 KiB (262,144 bytes), counting actual stream
bytes even without a truthful Content-Length. Oversized bodies are canceled and
return JSON payload_too_large with HTTP 413 before signature verification or
billing writes. The raw bytes are passed unchanged to Stripe's verifier. Review
legitimate events exceeding this budget before changing the cap.

Authorized billing requests use the existing org-tier KV limiter, keyed by
organization. Status has a 120-request/minute budget; Checkout and Portal share
a 30-request/minute budget across members. Both remain below the 500/minute
fail-open floor. HTTP 429 includes Retry-After and a JSON retry_after; waiting
and retrying is the recovery action. Preflight and unauthorized requests do not
spend an organization's budget, and webhooks do not share these budgets.
These KV limits are approximate under concurrency. Counter-write failures deny;
counter-read failures retain the shared limiter's existing fail-open behavior.
The limiter adds 13 counter reads and one counter write to an allowed request;
KV write throttling can deny requests before the configured number is reached.

For local development, run Next on http://localhost:3000 and Functions on
http://localhost:8787. Set NEXT_PUBLIC_API_BASE_URL=http://localhost:8787
for the web app (the local default) and APP_BASE_URL=http://localhost:3000
for Functions. Billing sends requests to the local API, supports CORS
preflight, and validates Origin/return URLs against that configured frontend.
This exception requires both URLs to be loopback addresses; deployed requests
ignore it and ignore forwarded-host headers. Checkout is unavailable unless
the API key, Team price, and webhook signing secret are all configured. Portal
requires both the API key and webhook signing secret so payment recovery can
be reconciled. Without either secret, status reports Portal unavailable and
session creation returns a generic 503 before calling Stripe. The billing tab
shows a support/retry message. Restore webhook configuration before resuming
hosted billing; Portal does not require a Checkout price.

The billing tab reads server state on every mount. checkout=success or
session_id triggers at most ten reads, two seconds apart; these query values
never activate access or retrieve a session on behalf of another organization.
Cancel returns display the same authoritative state. Changing organizations
clears the previous checkout return parameters. Only billing roles can open
payment actions; other members see an explanation and the upgrade path.
Paid plans display their shared-config price, including manual Enterprise pilots.
Free access displays the Team upgrade offer.
The access-through field uses only the entitlement resolver's access date.
Non-paying or unrecognized subscriptions show "No paid access" even when their
Stripe billing period ends in the future; billing periods do not grant access.
Effective Enterprise access, including active or converted manual pilots,
suppresses Team checkout. The status endpoint reports checkout unavailable and
the checkout endpoint returns HTTP 409 plan_change_requires_contact before
calling Stripe. Use the Enterprise contact path for plan changes; existing
Stripe customers retain Portal access. Expired or cancelled manual Enterprise
pilots can select Team once their effective access is Free. Manual Team pilots
can still convert through Team Checkout. The shared pricing module owns this
eligibility rule; caller-supplied plan metadata cannot override it.

Status and event transitions

Every recognized subscription/invoice event re-fetches the current Stripe
subscription inside the lease. Invoice subscription ids come from
parent.subscription_details.subscription in the pinned Stripe API version.
A delayed event for an old subscription cannot overwrite a newer subscription.
Arrival time and an old event's active payload are not entitlement evidence.
A different subscription replaces the stored subscription only when its
Stripe creation timestamp is strictly newer, even if the stored subscription
is still active. This preserves access when a replacement starts before the
old subscription is canceled. Subscription and customer ownership must still
match the organization. Equal timestamps preserve the
recorded subscription, including cancellation or expiry; an ambiguous
same-second replacement requires operator review.

Stripe event or state APort state / entitlement
New Checkout session checkout_pending; no new paid access
Checkout completed Retrieve subscription; map its current status
Matching Checkout expired expired only when Stripe confirms expiry and no subscription has taken over
Subscription created/updated/resumed Map authoritative subscription status and configured Price
Subscription deleted Usually canceled; retrieve current state, preserving a replacement subscription
Subscription paused or payment collection paused paused; Free access
Invoice payment succeeded/failed Reconcile subscription; no grant solely because an old invoice says paid
active, trialing Team/Enterprise only with recognized price and a future recorded period end
active with period-end cancellation Paid until that exact period boundary; show scheduled cancellation
Custom cancel_at Store/display its actual date separately; access expires at the earlier of this date and the recorded period end
past_due, unpaid, incomplete, canceled, paused Free access, payment recovery through Portal
incomplete_expired, or an active/trialing record past its period end expired; Free access
Unknown price / unsupported multiple subscription items Free access
Unrelated invoice or unsupported event Ignore and log event id/type plus a reason; do not log the payload

No grace period is introduced. An absent renewal webhook cannot extend access
past the last recorded period. Restore access by correcting Stripe state and
redelivering its event. A still-current manual OrgPilot (trialing or
converted) keeps its existing bounded access through the same resolver until
an actual Stripe subscription takes over. Checkout alone never cancels a pilot.
Existing OrgPilot data is not rewritten.

cancel_at_period_end preserves Stripe's flag; optional cancel_at preserves
its custom cancellation timestamp. Later cancellation dates require successful
renewals and do not extend entitlement freshness. Existing rows without the new
field keep their stored period boundary and acquire the field on the next
verified reconciliation. See Stripe's subscription fields.

Paid gates cover hosted organization audit/decision history, member additions
and role changes, org keys and delegated issuance, org-owned creation through
create/builder/legacy issuance and template instances, org API/setup-key creation,
organization API-key rotation/reactivation,
both passport webhook creation routes and webhook update/delete/test/rotation,
and hosted passport policy/status changes. Existing authorization checks still
run first. API-key revocation/deletion and member removal remain available after
a lapse so administrators can revoke access, including pending invitations.
Member removal retains its organization-admin check and last-admin safeguard;
it does not query billing, so a billing outage cannot block offboarding.
Registry operations and the server-configured
GitHub Free issuance owner remain available for support and Free identity creation.
A caller cannot select the GitHub Free owner through that route. Local
passport evaluation, local files, open policy packs, GitHub report-only and
enforcement receipts, public raw agent decisions, and personal identity creation remain Free. Billing
lapse never deletes passports, audit history, or memberships.

Passport-based gates select the paying workspace from the persisted org owner.
Provisioning metadata alone cannot select a payer: some creation paths accept
that metadata from users. An organization's authenticated org-key controls
check that key's org plan after their existing ownership/sponsorship check.
Personal passports do not inherit a plan merely because their owner belongs
to a paid organization. Use org-owned passports for shared hosted controls.
The organization API Keys tab keeps existing keys and revoke/delete actions
available on Free or lapsed plans, including while a plan check loads or fails.
Creation, rotation, and reactivation remain disabled until team_controls is
confirmed; the UI explains the restriction and links to the organization plan.
A failed plan check offers retry without blocking credential cleanup.
The Members tab follows the same pattern: existing members and removal controls
remain visible during Free access, plan-check loading, or billing errors.
Additions and all role changes stay disabled until team_controls is confirmed.
Role changes are not treated as cleanup because organization roles grant
different permissions rather than forming one ordered privilege ladder.
Ownership transfers into an organization also require its team_controls
entitlement, in addition to the existing source-owner hosted update check.
Instance owner fields supplied in an update are ignored; their stored owner
continues to determine billing, routing, and audit context.
Claim confirmation checks team_controls against an instance's persisted
organization controller before updating the passport, consuming the token, or
minting its API key. The key is stored as organization-owned, including its
owner index. A denied token remains usable until its original expiry; restore
access and reopen the claim link, or request a new link if it has expired.
API clients receive structured plan_required JSON. Direct email-link requests
receive an HTTP 402 page linking to billing on the request's origin. In split
local development, the shared billing-origin helper uses APP_BASE_URL only
when both the request and configured app origins are loopback addresses, so
the recovery link opens the frontend on port 3000 instead of Functions on 8787.
Preview and production keep their request origin. Personal
claims retain their existing behavior. Existing key records are not rewritten.
Email invitation redemption also rechecks the stored organization's
team_controls entitlement before activating any membership index, consuming
the magic link, or creating a login session. If access has lapsed, the invitation
stays pending and the link keeps its original expiry. Ask an organization admin
to restore access and reopen the link before it expires, or request a fresh
invitation. JSON clients receive plan_required; email-link visits show a
same-origin billing link. A billing outage fails closed with a retry response.
Ordinary sign-in and already-active membership retain their existing behavior;
the check does not remove members or automatically accept unrelated invites.

Verification and operator smoke test

npx vitest run functions/api/billing functions/services functions/utils/management
pnpm --dir web run type-check
pnpm --dir web run build
pnpm exec playwright install chromium
pnpm exec playwright test -c playwright.billing.config.ts
git diff --check

The tests use real local D1 transactions, the real Stripe signature verifier,
and mocked Stripe calls. Browser tests run the static build with mocked auth
and billing responses at 1440px and 375px. They do not charge a card.

For a Stripe sandbox smoke test, run the Functions with local D1 migrated, then:

stripe listen --forward-to http://localhost:8787/api/billing/webhook
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed

Put the listener's signing secret in .dev.vars. CLI-generated generic events
lack APort org metadata and are intentionally logged as unrelated. Exercise
APort Checkout with an actual test organization for the full flow: pay with a
Stripe test card, observe activation from the webhook, retry the same event,
use Portal cancellation, and exercise a failed renewal and recovery with a
Stripe test clock. Redeliver an older subscription event and confirm access
still matches Stripe. Also test member denial, missing secrets, two simultaneous
checkout attempts, and preview return URLs. Do this sandbox smoke test before
live enablement; it is separate from the deterministic code checks.

Rollback and recovery

Pause new purchases by setting Team's selfServe to false in the pricing
config in a reviewed patch while retaining all price ids,
Portal, webhook processing, storage, and gates. Never erase billing rows or
processed event ids to roll back code. Never roll back to ungated paid routes.

If webhook reconciliation fails, Stripe receives non-2xx and retries. Use event
ids from safe logs to inspect delivery in Stripe Workbench and redeliver after
repairing credentials, metadata, migrations, or connectivity. Keep the endpoint
and receipts available while customers have subscriptions. Disabling the
webhook can cause paid access to expire at the stored period boundary. Existing
passports, audit, and organization memberships remain stored throughout.