Implemented API
What exists today
The HTTP contracts that exist in the Paycux API on this branch, and how each is authenticated. Anything not listed here is not implemented; the rest of these docs describe the planned product surface and are not a contract.
Summary
Paycux is a control plane. It keeps your catalog, entitlements and usage decisions and creates checkout, portal and cancellation sessions on your own Stripe or Paddle account. It does not process payments, hold funds or act as merchant of record.
- Provider connectionsConnect your own Paddle account; keys verified and stored in OpenBao
- CatalogProducts, plans, prices, features and meters — versioned, archived, never deleted
- Entitlements & limitsPer-plan feature grants with quantity limits, projected onto subscriptions
- Usage eventsIdempotent POST /v1/usage/events returning an allow/deny decision
- Hosted checkout, portal and cancelSessions created on the customer's own provider; Paycux never touches funds
- Signed provider webhooksStripe and Paddle events verified against the connection's signing secret
- Organisations & invitesEnd users create organisations, invite members and accept by verified e-mail
- Workspace members, roles & invitesOwner and admin roles, single-use invite links, session revocation on removal
- Workspace export & deletionSecret-free JSON export; deletion with a 30-day cancellation window
- Audit ledgerEvery catalog, membership and lifecycle write appended to the workspace ledger
Not implemented — do not document as available
Every product in the Products menu (Sign-in UI, Enterprise SSO, Abuse Checks, Directory Provisioning, RBAC, Agent Access, Organization Setup, Secret Storage, MFA, Audit Logs, App Connections, User Management), plus Atlas, Airlock and the migration tooling, is planned and cannot be enabled. Email provider connections and Lemon Squeezy are not implemented either. Workspace invitations return the accept link once and, when the platform mail scope is configured, the same link is also e-mailed to the invitee; end-user organisation invites are returned to the inviting application, which delivers them.
This lists the HTTP contracts that exist in apps/api on this branch and how each is authenticated. Anything not listed here is not implemented; the marketing /docs pages describe the intended product surface and are not a contract. Every response is JSON; validation failures return 400 {"error":"INVALID_INPUT"}.
Identities (never mixed)
| Identity | Who | Carried as | Routes |
|---|---|---|---|
| Dashboard member session | A Paycux customer's team member (workspace) | __Host-paycux_session cookie, PostgreSQL-backed, rotated, revocable | /v1/projects, /v1/environments, /v1/workspace, /v1/auth/* |
| End-user usage credential | The customer's own application user (realm) | Authorization: Bearer <Keycloak realm access token> | /v1/usage/events, /v1/billing/* |
| Agent connection | CLI/agent bound to one project's development environment | Authorization: Bearer pcx_<connection id>.<secret> (5-minute, single-activation token; SHA-256 hash at rest) | /v1/agent/activate, /v1/agent/contract, /v1/agent/verifications/* (minting/revocation is a dashboard route) |
| Provider webhook | Stripe/Paddle on the customer's connection | Provider signature over the raw body | /v1/provider-connections/:id/webhooks/* |
| E-mail provider webhook | Resend, for mail Paycux itself sends (platform scope) | Svix signature (svix-id, svix-timestamp, svix-signature) over the raw body, +/-300 s; exact sender allowlist | /v1/webhooks/resend (requires RESEND_WEBHOOK_SECRET_FILE, RESEND_WEBHOOK_ALLOWED_SENDERS and platform scope; unrelated/missing sender -> ignored without writes; bounce/complaint -> suppression, duplicate Svix id -> { duplicate: true }) |
A dashboard session can never satisfy an end-user route: the usage credential authenticator only trusts tokens whose issuer is a Keycloak realm owned by an environment (KEYCLOAK_BASE_URL/realms/pcx-<uuid>), verifies them against that realm's JWKS/audience, requires a verified email and maps the subject to the realm-scoped identity row (created on first use).
Dashboard member routes (session cookie)
POST /v1/applicationsregisters a project, its first environment and setup draft atomically. Requires owner/admin; production requires owner. The same retained project/environment UUIDs and validated body resume an earlier success with 200; new registration returns 201. Changed reuse or duplicate slug is 409; a new project exceeding its plan limit is 402. No provider calls or credentials. GET/PUT/v1/environments/:environmentId/setup?projectId=:projectIdread/save setup with revision conflict handling. See [application setup](APPLICATION_SETUP.md).
GET /auth/start?mode=login|register[&invite=<token>]is the web BFF entry. It creates state, nonce and PKCE S256 values in 10-minute, host-bound, HttpOnly cookies and callsPOST /v1/auth/authorize-url; an invite is accepted only withmode=loginand is kept in a fourth transient cookie, never in the authorization-provider request.GET /auth/callback?code&statevalidates the single browser state cookie, then callsPOST /v1/auth/exchangewith the verifier, nonce and optional invite token. The API verifies the OIDC response and stores only the invite token's SHA-256. A pending invite is accepted only when the OIDC identity has the same verified normalized email; concurrent/repeated use creates one membership and one acceptance audit event. Mismatch and invalid links fail closed asINVITE_MISMATCH/INVITE_EXPIRED; no session is issued. Success sets__Host-paycux_sessionand clears every transient cookie.GET /v1/auth/session→{ workspaceId, actorId, role };POST /v1/auth/refresh,POST /v1/auth/logout(exactOriginrequired).GET /v1/auth/workspaces→{ items: [{ workspaceId, slug, name, role, deletionPending, current }] }: every ACTIVE membership of the signed-in identity (same OIDC issuer + subject as the session's member), current workspace flagged, ordered by name;deletionPendingistruewhile the workspace is frozen by an open deletion request. Suspended/removed memberships and other identities' workspaces are never listed. Reachable while the workspace is frozen for deletion.POST /v1/auth/switch{ workspaceId }(exactOriginrequired) →200 { workspaceId, slug, name, role, deletionPending }with a new__Host-paycux_sessioncookie bound to that membership. The target membership is read first, then in one transaction the calling session's family is revoked and the new session created (sessions of other browsers stay valid), and only then are the target member's last use and the identity's verified e-mail recorded anddashboard.workspace_switchedappended to the target workspace's audit ledger — a session revoked in the meantime answers401 DASHBOARD_SESSION_INVALIDand leaves no ledger entry.404 WORKSPACE_NOT_FOUNDwhen the identity holds no active membership there (inactive, foreign and unknown workspaces are indistinguishable); the current workspace answers200without touching the session. Reachable while the workspace is frozen for deletion so a member can leave it.- Workspace members (dashboard operators; workspace RLS scope, e-mail addresses unmasked for colleagues, OIDC issuer/subject never returned). Reads stay available while the workspace is frozen for deletion, every write answers
403 WORKSPACE_DELETION_PENDINGthen:GET /v1/workspace/members(any member role) →{ items: [{ id, email, displayName, role, status: active|suspended, emailVerified, lastLoginAt, createdAt }] }(owners first, then admins, then by creation; removed members are not listed).POST /v1/workspace/members/invites{ email, role }(owner/admin;role: owneronly by an owner,403otherwise) →201 { invite, token, acceptUrl, emailQueued }whereacceptUrl=PAYCUX_WEB_ORIGIN/auth/start?mode=login&invite=<token>. The token and link are returned exactly once and stored only as a sha256; invites expire after 7 days;409 CONFLICTwhen the address already belongs to an active member or has a pending invite (an expired one is closed and replaced).emailQueued: truemeans an invitation e-mail with the same link was queued in the same transaction (platform billing scope configured; the event worker sends it);falsemeans the console shows the link once to the inviter, who passes it on. Auditworkspace_member.invited. Counts against the workspace plan'sworkspace_memberslimit (active members + pending invites;402 PLAN_LIMIT_EXCEEDEDwrites nothing).GET /v1/workspace/members/invites(any member role) →{ items: [{ id, email, role, status: pending|accepted|revoked|expired, invitedByMemberId, expiresAt, createdAt }] }(pending first, then newest; max 200).DELETE /v1/workspace/members/invites/:inviteId(owner/admin) →200the invite withstatus: revoked;409unless pending,404for another workspace's invite. Auditworkspace_member_invite.revoked.PATCH /v1/workspace/members/:memberId{ role }(owner/admin) →200the member. Granting or taking awayownerrequires an owner (403); the last active owner cannot be demoted (409); the same role again is a no-op. Auditworkspace_member.role_changed.DELETE /v1/workspace/members/:memberId(owner/admin; an owner can only be removed by an owner) →200the member withstatus: removedandrevokedSessions: every dashboard session of that member is revoked in the same transaction. The last active owner cannot be removed (409). Auditworkspace_member.removed. A removed person can be invited again; accepting reactivates the same member row with the new role.
GET /v1/projects→{ items: Project[] }(workspace-only RLS scope).POST /v1/projects{ id, slug, name }(owner/admin) →201 Project; counts against the workspace plan'sprojectslimit (see workspace billing:402 PLAN_LIMIT_EXCEEDEDwrites nothing).GET /v1/projects/:projectId→Project.GET /v1/environments?projectId=→Environment[];POST /v1/environments{ id, projectId, name }(owner/admin).GET /v1/environments/:environmentId/{overview,billing-accounts,subscriptions,usage,entitlements,provider-connections}?projectId=[&limit=][&billingAccountId=]→ read-only, environment RLS scope, no secrets, no raw provider payloads.POST /v1/environments/:environmentId/provider-connections(owner/admin){ projectId, name, provider: stripe|paddle, mode: test|sandbox|live, apiKey, webhookSecret? }→201 { id, name, provider, mode, status, hasWebhookSecret, createdAt, updatedAt, webhookPath }. The key prefix must match the mode (sk_|rk_+test|live;pdl_sdbx_↔sandbox), environment visibility, name availability and the workspace plan'sprovider_connectionslimit (non-disabled connections across the workspace;402 PLAN_LIMIT_EXCEEDED, checked again around the insert) are settled first, then the key is verified with a read-only, 10-second-bounded provider call (Stripecustomers.list, so a restricted key needs at least Customers read; PaddleeventTypes.list), written to OpenBao, and only{path, version}references are persisted. Per-member damping applies (429). Failures store nothing:400 INVALID_SCOPE(format/mode),400 PROVIDER_REJECTED,503 PROVIDER_UNAVAILABLE,503 SECRET_UNAVAILABLE,409 CONFLICT(name). The key is never returned or logged.POST /v1/environments/:environmentId/provider-connections/:connectionId/disable?projectId=(owner/admin) → connection withstatus: disabled(idempotent).POST …/:connectionId/enable?projectId=(owner/admin) leases the stored API key, verifies it with the provider again and only then setsstatus: active(400 PROVIDER_REJECTED/503leave it disabled). A disabled connection is not counted against the plan, so re-enabling one is a create for theprovider_connectionslimit:402 PLAN_LIMIT_EXCEEDEDbefore the provider is contacted and again under the same per-workspace guard around the status change; enabling an already non-disabled connection is a no-op without the check.POST …/:connectionId/rotate{ projectId, purpose: api_key|webhook_signing, value }(owner/admin) writes the replacement as a new OpenBao version, verifies it (provider call for API keys, prefix/mode check first), activates it through thesecret_retirementledger and revokes the previous version →{ …connection, rotated: { purpose, version, previousRetirement } }; a failed verification revokes the unactivated version and keeps the current one (400 PROVIDER_REJECTED), a concurrent rotation answers409 IDEMPOTENCY_CONFLICT. A connection created without a webhook secret gets one attached by the same call. All of these exist only whenOPENBAO_TOKEN_FILEis configured; otherwise404.GET /v1/environments/:environmentId/catalog?projectId=(any member role) →{ products: [{ id, key, name, version, active, archivedAt, createdAt, plans: [{ id, productId, key, name, version, archivedAt, createdAt, prices: [{ id, planId, key, version, currency, unitAmountMinor, billingInterval, archivedAt, createdAt }], entitlements: [{ featureId, featureKey, quantityLimit }] }] }], features: [{ id, key, name, version, seatRequired, createdAt, meters: [{ id, featureId, key, name, aggregation, version, createdAt }] }], priceMappings: [{ id, planId, connectionId, providerPriceId, createdAt }] }, ordered by key then version descending.- Catalog writes (owner/admin,
403otherwise; every write is audited ascatalog.<entity>.created/catalog.plan_entitlement.{created,updated}in the environment scope). Keys match^[a-z][a-z0-9_-]*$(max 64) and are unique per environment and version: re-using a key answers409 CONFLICTunless the body passesnewVersion: true, which createsmax(version) + 1; the highest version is what checkout (planKey→provider_price_mapping) and the console treat as current.POST …/catalog/products{ projectId, key, name, newVersion? }→201;POST …/catalog/products/:productId/plans{ projectId, key, name, newVersion? }→201;POST …/catalog/plans/:planId/prices{ projectId, key, currency (3 uppercase letters), unitAmountMinor (int ≥ 0), billingInterval: month|year|one_time, newVersion? }→201;POST …/catalog/features{ projectId, key, name, seatRequired: 0|1, newVersion? }→201;POST …/catalog/features/:featureId/meters{ projectId, key, name, aggregation: sum|count|max, newVersion? }→201;PUT …/catalog/plans/:planId/entitlements{ projectId, featureId, quantityLimit: int ≥ 0 | null }upserts the (plan, feature) row →200 { planId, featureId, featureKey, quantityLimit };POST …/catalog/plans/:planId/price-mappings{ projectId, connectionId, providerPriceId }→201; the connection must be an activeprovider_connectionof the same environment (404 NOT_FOUNDotherwise), and one provider price maps once per connection (409). A product, plan, feature or connection from another environment is404. - Organisation accounts (workspace-operator view of what end users created through the end-user organisations API; environment RLS scope, e-mails masked as
a***@example.test, invite tokens/hashes never selected):GET /v1/environments/:environmentId/organizations?projectId=[&limit=1..100](any member role) →{ items: [{ id, displayName, kind: "organization", members (active), pendingInvites, createdAt }] }, newest first, 100 by default;GET …/organizations/:billingAccountId?projectId=(any member role) →{ account, members: [{ identityId, email (masked | null), role, status, joinedAt }], invites: [{ id, email (masked), role, status: pending|accepted|revoked|expired, expiresAt, createdAt }] }(pending invites first, then newest; max 200); an individual account or one of another environment is404 NOT_FOUND.POST …/organizations/:billingAccountId/invites/:inviteId/revoke?projectId=(owner/admin,403otherwise) →200the invite withstatus: revoked, audited asorganization_invite.revokedwith the dashboard member as actor and{ billingAccountId, inviteId, role, via: "dashboard" }; an accepted, revoked or expired invite answers409 CONFLICT, an invite of another organisation404. Archived parents refuse new children: creating a plan under an archived product, or a price, entitlement or price mapping on an archived plan (or on a plan whose product is archived) answers409 CONFLICTwithProduct is archived; restore it first/Plan is archived; restore it firstand writes nothing; restore the parent first. - Catalog lifecycle (owner/admin,
403otherwise). Products, plans and prices are never deleted:subscription,plan_entitlementandprovider_price_mappingreference plans and plans reference products, so a row that has ever been sold must stay resolvable. There is noDELETEroute; the safe operation is archiving.POST …/catalog/products/:productId/archive?projectId=and…/restore?projectId=,POST …/catalog/plans/:planId/{archive,restore}?projectId=,POST …/catalog/prices/:priceId/{archive,restore}?projectId=— no body — set or clear the row'sarchivedAt(200, idempotent: a repeat returns the current row and records nothing; each real change is audited ascatalog.<entity>.{archived,restored}). Archiving is explicit per row and cascades to nothing: an archived product leaves its plans and prices exactly as they are, an archived plan leaves its prices and mappings (new children are refused with409 CONFLICTuntil the parent is restored, see above). The response says what stays active underneath — product:{ …product, active: false, activePlans: [{ id, key, version }] }(its unarchived plans); plan:{ …plan, activePrices: [{ id, key, version }], activeSubscriptions }(unarchived prices and the count of trialing/active/past-due subscriptions on the plan, which keep working); price: the price row.product.activeis alwaysarchivedAt === null. A malformed id or missingprojectIdis400; an id outside the environment is404. Effects for end users: hosted checkout sells the highest mapped plan version of the key — the same version provider webhooks and reconciliation attach the resulting subscription to — and never falls back to an older version; when that version or its product is archived,POST /v1/billing/checkout-sessionsanswers409 PLAN_ARCHIVEDinstead of creating a provider session (an unmapped key stays404 INVALID_SCOPE). Restoring the version, or mapping a newer unarchived one, sells again. Paycuxpricerows are catalog metadata (currency, amount, interval); the provider price actually charged is chosen per plan throughprovider_price_mapping, so archiving a price changes what the console shows and offers, not an existing subscription. Existing subscriptions, their provider webhooks, reconciliation and entitlement grants ignorearchivedAtentirely:entitlement_grantrows belong to the subscription, and a renewal of a subscription on an archived plan re-projects that plan's entitlements unchanged. - Workspace billing — Paycux's own plans for the workspace, sold through the Paddle connection of a Paycux-owned platform environment (
docs/PLATFORM_BILLING.md; configured by thePAYCUX_PLATFORM_BILLING_{WORKSPACE,PROJECT,ENVIRONMENT}_IDtriple, never from request data). Provider ids never reach the browser.GET /v1/workspace/billing(any member role,cache-control: no-store) →{ configured, provider: { provider, mode } | null, account: { billingAccountId, providerCustomerLinked } | null, subscription: { id, status, planKey, planName, planVersion, currentPeriodEndsAt, cancelAtPeriodEnd, staleProviderState } | null, effectivePlan: { key, name, source: subscription|default|none }, entitlements: [{ featureKey, featureName, quantityLimit, current }], plans: [{ id, key, name, version, productKey, productName, prices, entitlements, purchasable, current }] }.configured: false(everything else empty/null) when the platform scope is not set.effectivePlan: a trialing/active/past_due platform subscription's plan (subscription), else the highest unarchived platform plan with keyfreewhose product is unarchived (default), elsenone(no limits).currentis counted in the customer's own workspace (projects,provider_connections= non-disabled across all environments,workspace_members= active members + pending invites,usage_events_month= usage consumed this UTC month across all environments);nullfor feature keys Paycux does not count.planslists unarchived platform plans (all versions),purchasable= a provider price is mapped for it on the platform connection.- Mutations (owner/billing,
403 FORBIDDENotherwise; exact consoleOriginrequired,403 DASHBOARD_ORIGIN_FORBIDDENotherwise; per-member damping;404 BILLING_NOT_CONFIGUREDwithout a platform scope;503 SECRET_UNAVAILABLEwhen the platform provider key cannot be leased — noOPENBAO_TOKEN_FILE;503 PROVIDER_UNAVAILABLEwhen the platform environment has no active Stripe/Paddle connection with an API key;409 CONFLICT "The platform workspace cannot subscribe to itself"when the signed-in workspace is the platform workspace). Each appends an audit event in the customer workspace ledger (workspace_billing.checkout_created,.portal_opened,.plan_change_requested,.cancel_requested) and the platform-scope journey event. While a workspace deletion request is open onlyGETandcancelstay reachable (an owner must be able to end the plan);checkout,portalandchange-plananswer403 WORKSPACE_DELETION_PENDINGlike every other frozen route.POST /v1/workspace/billing/checkout{ planKey }+Idempotency-Key(8–128 chars,400 INVALID_INPUTwithout) →201 { provider, mode, checkoutUrl, planKey, billingAccountId }. The acting member needs a verified e-mail (400 INVALID_INPUT); the workspace's platform billing account (kind: workspace,external_id: workspace:<uuid>, display name = workspace name) is created on first use;409 CONFLICTwhile a trialing/active/past_due subscription exists (usechange-plan) — checked before the account is provisioned and again under the per-account lock, where a non-cancelled subscription of the account on the platform connection (provider_customer_mapping+subscription) also answers409, so two concurrent checkouts sell at most one plan;404 INVALID_SCOPEwhen the key is not mapped on the platform connection,409 PLAN_ARCHIVEDwhen the highest mapped version or its product is archived. Provider-customer creation is serialised per account; the Paddle/Stripe customer id is stored inprovider_customer_mapping. Return URLs arePAYCUX_WEB_ORIGIN/dashboard/billing?checkout=success|cancelledand are recorded in both audit payloads (successUrl,cancelUrl). Paddle transactions use the explicit approved checkout landing URLPAYCUX_WEB_ORIGIN/checkout/paddlerather than the account-wide default payment link. The provider idempotency key is<platform environment>:<workspace>:<Idempotency-Key>.POST /v1/workspace/billing/portal→201 { provider, mode, portalUrl };404 INVALID_SCOPEbefore the workspace has a billing account or a provider customer (start a checkout first). Return URLPAYCUX_WEB_ORIGIN/dashboard/billing(recorded in the audit payloads asreturnUrl).POST /v1/workspace/billing/change-plan{ planKey }+Idempotency-Key(8–128 chars,400 INVALID_INPUTwithout) →200 { subscriptionId, planKey, status, pendingProviderConfirmation: true }: Paddlesubscriptions.update({ items: [{ priceId, quantity: 1 }], prorationBillingMode: "prorated_immediately" }), Stripesubscriptions.updatereplacing the single item withproration_behavior: create_prorationsunder the idempotency key<platform environment>:<provider subscription>:change-plan:<provider price>:<Idempotency-Key>— a retried submit is one swap, a later reversing change (A→B→A) with a new key is a new swap; Paddle has no idempotency header, so a retry re-issues the same swap, which Paddle treats as a no-op when the price is already current. The key is recorded in both audit payloads (idempotencyKey). The console mints one key per plan per render, like checkout. The local subscription is not rewritten from the response — the provider webhook and reconciliation stay the only writers.409 CONFLICTwithout a live subscription or when the plan is already current;404 INVALID_SCOPE/409 PLAN_ARCHIVEDas for checkout.POST /v1/workspace/billing/cancel{ atPeriodEnd?: boolean = true }→200 { subscriptionId, status, cancelAtPeriodEnd, currentPeriodEndsAt, alreadyCancelled }, same semantics as/v1/billing/subscriptions/:id/cancel;404 INVALID_SCOPEwithout a subscription.
- Plan limits:
POST /v1/projects(projects),POST /v1/environments/:id/provider-connections(provider_connections) andPOST /v1/workspace/members/invites(workspace_members) answer402 PLAN_LIMIT_EXCEEDED { error, message, featureKey, limit, current }and write nothing whencurrent + 1would exceed the effective plan's entitlement. A feature the plan does not list, an effective plan ofnone, an unconfigured platform scope and the platform workspace itself are unlimited. The count and the write run under one per-workspace, per-feature advisory lock (held across the count transaction and the insert transaction), so two concurrent creates cannot both pass a limit ofcurrent + 1; a contender that cannot take the lock within 10 s answers409 IDEMPOTENCY_CONFLICT.usage_events_monthis reported only.
GET /v1/workspace→ lifecycle{ workspaceId, slug, name, deletion };POST /v1/workspace/deletion(owner) →202, revokes every session, 30-day window;409 CONFLICT "Cancel the workspace plan before requesting deletion"while the workspace holds a trialing/active/past_due Paycux plan (cancel it throughPOST /v1/workspace/billing/cancelfirst; the check is a no-op without a platform scope);DELETE /v1/workspace/deletion(owner, within window);GET /v1/workspace/export(owner/admin) → JSON attachment, bounded, secret-free, one in flight per workspace per API instance (409otherwise). While a deletion request is open every other dashboard route answers403 WORKSPACE_DELETION_PENDINGfor every member (a re-issued session cannot keep operating) except the routes that opt out: the four lifecycle routes above, the workspace switcher, the member and invite reads, the audit ledger reads below, andGET /v1/workspace/billing+POST /v1/workspace/billing/cancel; end-user realm-token routes and provider webhooks keep serving during the window so customers' apps are not cut off by a request that may still be cancelled.- Audit ledger (owner/admin,
403 FORBIDDENotherwise; workspace RLS scope over the workspace's own rows of every scope type;cache-control: no-store; reachable while the workspace is frozen for deletion):GET /v1/workspace/audit-events?[limit=1..100 (50)][&cursor=][&scopeType=workspace|project|environment][&projectId=][&environmentId=][&action=<prefix>]→{ events: [{ id, scopeType, projectId, environmentId, sequence, actorType, actorId, action, payload, previousHash, eventHash, createdAt }], nextCursor }newest first (created_at desc, id desc).cursoris the opaquenextCursorof the previous page (nullon the last page); a malformed cursor, limit or filter answers400 INVALID_INPUT.actionis a prefix match (workspace.lists every workspace action).payloadis the canonical document that was hashed (eventHash = sha256(previousHash + "\n" + canonical(payload))), with the event's own data under itspayloadkey, so a page can be re-verified offline. Reads are not audited.GET /v1/workspace/audit-events/export→ JSON attachmentpaycux-audit-<workspaceId>.json{ workspaceId, exportedAt, truncated, scopes: [{ scopeKey, scopeType, projectId, environmentId, events, chainVerified, truncated }], events }, every row ascending per scope (scope_key, sequence), bounded to 10,000 rows in total.chainVerifiedis true only when every row of the scope is present and the hash chain is intact; a scope cut by the bound is reported withtruncated: trueandchainVerified: false, and the document'struncated: truemeans later scopes may be missing entirely. Per-member damping (429), one in flight per workspace per API instance (409). Auditaudit.exported(workspace scope:{ exportedAt, events, scopes, truncated, outcome }). Because the GET has side effects, a request whosesec-fetch-siteheader is neithersame-originnornone(a cross-site navigation carrying the SameSite=Lax session cookie) answers403 FORBIDDENbefore anything is read or appended; an absent header (server-to-server, the console proxy) passes, and the console proxy applies the same rule itself.
Customer identity setup
Environment Authentication and Users routes, /v1/identity/me, and per-connection Paddle browser-token settings are defined in [customer identity and checkout](CUSTOMER_IDENTITY_AND_CHECKOUT.md). Configuration is durable pending/ready/failed and reconciled by a dedicated environment maintenance worker. No browser or API password handling is added.
End-user routes (realm token)
POST /v1/usage/events(+Idempotency-Key) →UsageDecision. The body scope (projectId,environmentId,realmId) must match the credential; the identity must be an active member ofbillingAccountId.POST /v1/billing/accounts{ projectId, environmentId, realmId, displayName }→ individual billing account with owner membership (idempotent per identity).POST /v1/billing/checkout-sessions(+Idempotency-Key){ ..scope, billingAccountId, planKey, provider?, successUrl, cancelUrl }→201 { provider, mode, checkoutUrl, expiresAt, billingAccountId, planKey }.providerispaddle(the default when omitted) orstripe; any other value is 400. Return URLs must exactly matchcustomer_auth_setup.checkout_return_urls; HTTPS, with loopback allowed only in development/preview. The result is a short-lived opaque/checkout/customer/:sessionTokenURL hosted by Paycux. On Paddle the page opens the customer's own Paddle account with the per-connection client token; on Stripe it redirects to a subscription-mode Checkout Session created on the customer's own Stripe account (no client token is involved). Missing identity setup, Paddle checkout settings, return addresses or operator-confirmed event-worker coverage returns 503. Identical retries keep the same session; changed payload 409; expiry requires a new idempotency key. The durable session binds the active connection of the requested provider:modeissandbox|liveon Paddle andtest|liveon Stripe (a Stripe connection in any other mode is 503CHECKOUT_NOT_CONFIGURED). The Stripe Checkout Session id is stored with the session; its URL is never stored.GET /v1/billing/checkout-sessions/:sessionToken(no auth,no-store) resolves the capability the Paycux page acts on: on Paddle{ provider: "paddle", clientToken, environment, transactionId, successUrl, cancelUrl, expiresAt }; on Stripe{ provider: "stripe", checkoutUrl, successUrl, cancelUrl, expiresAt }, wherecheckoutUrlis the hosted Checkout Session URL read back from the customer's Stripe account with a per-call leased API key, and a Checkout Session that is no longer open is404 CHECKOUT_EXPIRED. Both shapes re-check billing account membership and the registered return addresses at resolve time. See [customer identity and checkout](CUSTOMER_IDENTITY_AND_CHECKOUT.md) for setup, capability resolution, worker coverage and release gates.404 INVALID_SCOPEwhen no version ofplanKeyis mapped on the connection,409 PLAN_ARCHIVEDwhen the highest mapped version is archived or belongs to an archived product (see catalog lifecycle above; there is no fallback to an older version).POST /v1/billing/portal-sessions{ ..scope, billingAccountId, provider?, returnUrl }→201 { provider, mode, portalUrl, billingAccountId }.POST /v1/billing/subscriptions/:subscriptionId/cancel{ ..scope, atPeriodEnd? }→{ subscriptionId, status, cancelAtPeriodEnd, currentPeriodEndsAt, alreadyCancelled }. Provider API keys are leased from OpenBao per call. Routes exist only when OpenBao and the realm token settings are configured; otherwise404.- Organisations (end users only; same realm token and scope rule, no
Idempotency-Key, routes exist only when the realm token settings are configured; theorganization_memberrole of the caller gates access):POST /v1/billing/organizations{ ..scope, displayName }→201 { billingAccountId, kind: "organization", role: "owner" }; the caller becomes the active owner (auditbilling_account.created).GET /v1/billing/accounts→{ accounts: [{ billingAccountId, kind, displayName, role, status }] }for the calling identity.POST /v1/billing/organizations/:billingAccountId/invites{ ..scope, email, role: "member" | "admin" }(owner/admin only) →201 { inviteId, token, role, expiresAt }. The token is shown once and stored only as a sha256 hash; invites expire after 7 days.409when the e-mail already belongs to an active member or has a pending invite.POST /v1/billing/invites/accept{ ..scope, token }→{ billingAccountId, role, status: "active" }. The caller's verified e-mail must equal the invited address (403, never revealing it); expired, revoked or already accepted invites are409, unknown tokens404(auditorganization_member.joined).POST /v1/billing/organizations/:billingAccountId/invites/:inviteId/revoke{ ..scope }(owner/admin only) →{ inviteId, status: "revoked" }.GET /v1/billing/organizations/:billingAccountId/members?projectId&environmentId&realmId(any active member) →{ billingAccountId, role, members: [{ identityId, role, status, joinedAt }], invites };invites(masked e-mails such asa***@example.test, role, expiresAt) is filled for owner/admin only.
- Abuse damping:
429 RATE_LIMITED(+Retry-After) per identity, and per source only whenPAYCUX_TRUSTED_PROXIESdeclares the topology (nonefor direct exposure, or the proxy IPs/CIDRs/loopback/uniquelocalwhoseX-Forwarded-Foris trusted). Unset = per-identity only, so a shared proxy address can never 429 every tenant. In-process, per API instance.
Agent connection routes (`/v1/agent`)
Always mounted; the only dependency is PostgreSQL. A connection is minted from the dashboard, activated once by the CLI (paycux link) and bound to one project's canonical development environment: it can never read a provider secret, a secret reference, the internal issuer of a realm or another environment (design 7.5 / 9.4).
- Dashboard session, owner/admin/developer only (
403 FORBIDDENotherwise):POST /v1/agent/connections{ projectId, capabilities? }→201 { token, projectId, environmentId, expiresAt, capabilities }. The project'sdevelopmentenvironment is resolved server-side (404 NOT_FOUNDwithout one); capabilities default tocontract:read,verification:start,verification:read. The token (pcx_<connection id>.<43-char secret>) is returned once, expires five minutes after minting and is stored only as its SHA-256 hash. Auditagent.connection.mint_requestedin the environment ledger. Damping:429 RATE_LIMITED(+Retry-After) per workspace at 30 mints/hour (burst 10).POST /v1/agent/connections/:id/revoke→204(403 AGENT_SCOPE_DENIEDfor another workspace's connection). Auditagent.connection.revoke_requestedwithconnectionId.
- Agent token (
Authorization: Bearer, no cookie;401 AGENT_TOKEN_REQUIRED/AGENT_TOKEN_INVALID/AGENT_TOKEN_EXPIRED/AGENT_TOKEN_REVOKED/AGENT_TOKEN_INACTIVE,403 AGENT_CAPABILITY_DENIED). Damped before any database access: per connection id, which the token format carries (30/min, burst 15), and per source (120/min, burst 60) only whenPAYCUX_TRUSTED_PROXIESdeclares the topology;429 RATE_LIMITED(+Retry-After):POST /v1/agent/activate{ projectId, environment: "development" }→200 { projectId, environmentId, environment, expiresAt, capabilities }. Single use: a second call is409 AGENT_ALREADY_ACTIVATED; another project403 AGENT_SCOPE_DENIED; any other environment403 AGENT_ENVIRONMENT_DENIED. Auditagent.connection.activated(actor typeagent, the connection id) after a successful activation only.GET /v1/agent/contract(contract:read) → the publicAgentProjectContractof the linked environment:projectId,environmentId,environment,sourceSha(the running API's source revision,PAYCUX_SOURCE_SHA),integrationInstructions[](project and environment identity; application URL and identity mode from the setup draft; the OIDC discovery document, client id and registered redirect, logout and checkout return URLs once the authentication setup isready; the usage and hosted-checkout routes),hostedUrls.auth(the realm's public issuer, only when ready and HTTPS or loopback) andpublicConfig.authMode(paycuxfor amanagedidentity mode,bridgeforexternal). Provider connections, secret references, tokens and the internal issuer are never part of it.hostedUrls.checkout,hostedUrls.portal,publicConfig.usageEndpointandpublicConfig.paycuxPublishableKeyare omitted: the API does not know its public origin and no publishable key exists.503 AGENT_CONTRACT_UNAVAILABLEwhilePAYCUX_SOURCE_SHAis not configured (the API image exports its build argument; runs outside the image set it by hand).403 WORKSPACE_DELETION_PENDINGwhile the workspace has an open deletion request: these routes carry no member session, so the workspace lifecycle state is checked on every contract and verification read.POST /v1/agent/verifications(verification:start) →503 VERIFICATION_NOT_CONFIGURED: verification is not available in hosted environments. There is no hosted runner; evidence comes only from the local runner (docs/VERIFICATION.md).GET /v1/agent/verifications/:id(verification:read) →200 { id, projectId, environmentId, environment, state }for a run recorded in the connection's own scope (PASS/OPEN/BLOCKED/UNMEASUREDfrom its gate results);404 VERIFICATION_NOT_FOUNDotherwise.
Error codes
INVALID_INPUT, INVALID_SCOPE, BILLING_NOT_CONFIGURED (404), CREDENTIAL_INVALID (401), INVITE_EXPIRED, INVITE_MISMATCH (403), EMAIL_UNVERIFIED (403), IDEMPOTENCY_CONFLICT (409), LIMIT_EXCEEDED, PLAN_ARCHIVED (409), PLAN_LIMIT_EXCEEDED (402; body also carries featureKey, limit, current), PROVIDER_UNAVAILABLE (503), PROVIDER_REJECTED (502; 400 when the rejected credential came from the request itself), RATE_LIMITED (429), SECRET_UNAVAILABLE, SIGNATURE_INVALID, WORKSPACE_DELETION_PENDING (403), WORKSPACE_NOT_FOUND (404), NOT_FOUND, FORBIDDEN, CONFLICT, DASHBOARD_SESSION_*, DASHBOARD_ORIGIN_FORBIDDEN (403). Agent connection: AGENT_TOKEN_REQUIRED, AGENT_TOKEN_INVALID, AGENT_TOKEN_EXPIRED, AGENT_TOKEN_REVOKED, AGENT_TOKEN_INACTIVE (401), AGENT_SCOPE_DENIED, AGENT_ENVIRONMENT_DENIED, AGENT_CAPABILITY_DENIED (403), AGENT_ALREADY_ACTIVATED (409), AGENT_CONTRACT_UNAVAILABLE (503). Verification: VERIFICATION_NOT_CONFIGURED (503), VERIFICATION_NOT_FOUND (404).
Not implemented (do not document as available)
Email provider connections, Lemon Squeezy. (Workspace-invite e-mail is delivered only when the platform billing scope is configured; see emailQueued above.) Hosted verification runner: POST /v1/agent/verifications answers 503 VERIFICATION_NOT_CONFIGURED and the dashboard /v1/verifications/* routes are not mounted; verification evidence exists only from the local runner.
Source: docs/API_CONTRACTS.md, read at build time. Rendered verbatim; nothing is added or omitted.