Skip to content

Documentation availability

Paycux is in private beta. Most pages describe roadmap or reference material. The implemented boundary is published at docs/API_CONTRACTS.md in the release source and under What exists today in this documentation.

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, automated workspace-invite delivery and Lemon Squeezy are not implemented either.

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)

IdentityWhoCarried asRoutes
Dashboard member sessionA 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 credentialThe customer's own application user (realm)Authorization: Bearer <Keycloak realm access token>/v1/usage/events, /v1/billing/*
Agent connectionCLI/agent bound to one project + environmentShort-lived agent token/v1/agent/*, /v1/verifications/*
Provider webhookStripe/Paddle on the customer's connectionProvider signature over the raw body/v1/provider-connections/:id/webhooks/*

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)

  • 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 calls POST /v1/auth/authorize-url; an invite is accepted only with mode=login and is kept in a fourth transient cookie, never in the authorization-provider request.
  • GET /auth/callback?code&state validates the single browser state cookie, then calls POST /v1/auth/exchange with 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 as INVITE_MISMATCH / INVITE_EXPIRED; no session is issued. Success sets __Host-paycux_session and clears every transient cookie.
  • GET /v1/auth/session → { workspaceId, actorId, role }; POST /v1/auth/refresh, POST /v1/auth/logout (exact Origin required).
  • 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; deletionPending is true while 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 } (exact Origin required) → 200 { workspaceId, slug, name, role, deletionPending } with a new __Host-paycux_session cookie 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 and dashboard.workspace_switched appended to the target workspace's audit ledger — a session revoked in the meantime answers 401 DASHBOARD_SESSION_INVALID and leaves no ledger entry. 404 WORKSPACE_NOT_FOUND when the identity holds no active membership there (inactive, foreign and unknown workspaces are indistinguishable); the current workspace answers 200 without 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_PENDING then:
    • 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: owner only by an owner, 403 otherwise) → 201 { invite, token, acceptUrl } where acceptUrl = 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 CONFLICT when the address already belongs to an active member or has a pending invite (an expired one is closed and replaced). Paycux sends no invitation e-mail — the console shows the link once to the inviter. Audit workspace_member.invited. Counts against the workspace plan's workspace_members limit (active members + pending invites; 402 PLAN_LIMIT_EXCEEDED writes 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) → 200 the invite with status: revoked; 409 unless pending, 404 for another workspace's invite. Audit workspace_member_invite.revoked.
    • PATCH /v1/workspace/members/:memberId { role } (owner/admin) → 200 the member. Granting or taking away owner requires an owner (403); the last active owner cannot be demoted (409); the same role again is a no-op. Audit workspace_member.role_changed.
    • DELETE /v1/workspace/members/:memberId (owner/admin; an owner can only be removed by an owner) → 200 the member with status: removed and revokedSessions: every dashboard session of that member is revoked in the same transaction. The last active owner cannot be removed (409). Audit workspace_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's projects limit (see workspace billing: 402 PLAN_LIMIT_EXCEEDED writes 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's provider_connections limit (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 (Stripe customers.list, so a restricted key needs at least Customers read; Paddle eventTypes.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 with status: disabled (idempotent). POST …/:connectionId/enable?projectId= (owner/admin) leases the stored API key, verifies it with the provider again and only then sets status: active (400 PROVIDER_REJECTED / 503 leave it disabled). A disabled connection is not counted against the plan, so re-enabling one is a create for the provider_connections limit: 402 PLAN_LIMIT_EXCEEDED before 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 the secret_retirement ledger 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 answers 409 IDEMPOTENCY_CONFLICT. A connection created without a webhook secret gets one attached by the same call. All of these exist only when OPENBAO_TOKEN_FILE is configured; otherwise 404.
  • 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, 403 otherwise; every write is audited as catalog.<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 answers 409 CONFLICT unless the body passes newVersion: true, which creates max(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 active provider_connection of the same environment (404 NOT_FOUND otherwise), and one provider price maps once per connection (409). A product, plan, feature or connection from another environment is 404.
  • 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 is 404 NOT_FOUND. POST …/organizations/:billingAccountId/invites/:inviteId/revoke?projectId= (owner/admin, 403 otherwise) → 200 the invite with status: revoked, audited as organization_invite.revoked with the dashboard member as actor and { billingAccountId, inviteId, role, via: "dashboard" }; an accepted, revoked or expired invite answers 409 CONFLICT, an invite of another organisation 404. 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) answers 409 CONFLICT with Product is archived; restore it first / Plan is archived; restore it first and writes nothing; restore the parent first.
  • Catalog lifecycle (owner/admin, 403 otherwise). Products, plans and prices are never deleted: subscription, plan_entitlement and provider_price_mapping reference plans and plans reference products, so a row that has ever been sold must stay resolvable. There is no DELETE route; 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's archivedAt (200, idempotent: a repeat returns the current row and records nothing; each real change is audited as catalog.<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 with 409 CONFLICT until 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.active is always archivedAt === null. A malformed id or missing projectId is 400; an id outside the environment is 404. 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-sessions answers 409 PLAN_ARCHIVED instead of creating a provider session (an unmapped key stays 404 INVALID_SCOPE). Restoring the version, or mapping a newer unarchived one, sells again. Paycux price rows are catalog metadata (currency, amount, interval); the provider price actually charged is chosen per plan through provider_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 ignore archivedAt entirely: entitlement_grant rows 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 the PAYCUX_PLATFORM_BILLING_{WORKSPACE,PROJECT,ENVIRONMENT}_ID triple, 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 key free whose product is unarchived (default), else none (no limits). current is 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); null for feature keys Paycux does not count. plans lists unarchived platform plans (all versions), purchasable = a provider price is mapped for it on the platform connection.
    • Mutations (owner/billing, 403 FORBIDDEN otherwise; exact console Origin required, 403 DASHBOARD_ORIGIN_FORBIDDEN otherwise; per-member damping; 404 BILLING_NOT_CONFIGURED without a platform scope; 503 SECRET_UNAVAILABLE when the platform provider key cannot be leased — no OPENBAO_TOKEN_FILE; 503 PROVIDER_UNAVAILABLE when 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 only GET and cancel stay reachable (an owner must be able to end the plan); checkout, portal and change-plan answer 403 WORKSPACE_DELETION_PENDING like every other frozen route.
      • POST /v1/workspace/billing/checkout { planKey } + Idempotency-Key (8–128 chars, 400 INVALID_INPUT without) → 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 CONFLICT while a trialing/active/past_due subscription exists (use change-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 answers 409, so two concurrent checkouts sell at most one plan; 404 INVALID_SCOPE when the key is not mapped on the platform connection, 409 PLAN_ARCHIVED when the highest mapped version or its product is archived. Provider-customer creation is serialised per account; the Paddle/Stripe customer id is stored in provider_customer_mapping. Return URLs are PAYCUX_WEB_ORIGIN/dashboard/billing?checkout=success|cancelled and are recorded in both audit payloads (successUrl, cancelUrl). Paddle transactions use the explicit approved checkout landing URL PAYCUX_WEB_ORIGIN/checkout/paddle rather 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_SCOPE before the workspace has a billing account or a provider customer (start a checkout first). Return URL PAYCUX_WEB_ORIGIN/dashboard/billing (recorded in the audit payloads as returnUrl).
      • POST /v1/workspace/billing/change-plan { planKey } + Idempotency-Key (8–128 chars, 400 INVALID_INPUT without) → 200 { subscriptionId, planKey, status, pendingProviderConfirmation: true }: Paddle subscriptions.update({ items: [{ priceId, quantity: 1 }], prorationBillingMode: "prorated_immediately" }), Stripe subscriptions.update replacing the single item with proration_behavior: create_prorations under 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 CONFLICT without a live subscription or when the plan is already current; 404 INVALID_SCOPE / 409 PLAN_ARCHIVED as 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_SCOPE without a subscription.
    • Plan limits: POST /v1/projects (projects), POST /v1/environments/:id/provider-connections (provider_connections) and POST /v1/workspace/members/invites (workspace_members) answer 402 PLAN_LIMIT_EXCEEDED { error, message, featureKey, limit, current } and write nothing when current + 1 would exceed the effective plan's entitlement. A feature the plan does not list, an effective plan of none, 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 of current + 1; a contender that cannot take the lock within 10 s answers 409 IDEMPOTENCY_CONFLICT. usage_events_month is 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 through POST /v1/workspace/billing/cancel first; 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 (409 otherwise). While a deletion request is open every other dashboard route answers 403 WORKSPACE_DELETION_PENDING for 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, and GET /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.

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 of billingAccountId.
  • 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 }. Checkout is Paddle-only (provider omitted or paddle; Stripe returns400). Return URLs must exactly match customer_auth_setup.checkout_return_urls; HTTPS, with loopback allowed only in development/preview. The result is a short-lived opaque /checkout/customer/:sessionToken URL hosted by Paycux using the customer's own Paddle account and per-connection client token. Missing identity setup, checkout settings or operator-confirmed event-worker coverage returns503. Identical retries keep the same session; changed payload409; expiry requires a new idempotency key. See [customer identity and checkout](CUSTOMER_IDENTITY_AND_CHECKOUT.md) for setup, capability resolution, worker coverage and release gates. 404 INVALID_SCOPE when no version of planKey is mapped on the connection, 409 PLAN_ARCHIVED when 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; otherwise 404.
  • Organisations (end users only; same realm token and scope rule, no Idempotency-Key, routes exist only when the realm token settings are configured; the organization_member role of the caller gates access):
    • POST /v1/billing/organizations { ..scope, displayName } → 201 { billingAccountId, kind: "organization", role: "owner" }; the caller becomes the active owner (audit billing_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. 409 when 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 are 409, unknown tokens 404 (audit organization_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 as a***@example.test, role, expiresAt) is filled for owner/admin only.
  • Abuse damping: 429 RATE_LIMITED (+ Retry-After) per identity, and per source only when PAYCUX_TRUSTED_PROXIES declares the topology (none for direct exposure, or the proxy IPs/CIDRs/loopback/uniquelocal whose X-Forwarded-For is trusted). Unset = per-identity only, so a shared proxy address can never 429 every tenant. In-process, per API instance.

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).

Not implemented (do not document as available)

Email provider connections, automated workspace-invite delivery, Lemon Squeezy.

Source: docs/API_CONTRACTS.md, read at build time. Rendered verbatim; nothing is added or omitted.