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)
| 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 + environment | Short-lived agent token | /v1/agent/*, /v1/verifications/* |
| Provider webhook | Stripe/Paddle on the customer's connection | Provider 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 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 }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). Paycux sends no invitation e-mail — the console shows the link once to the inviter. 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, 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.
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 }. Checkout is Paddle-only (provideromitted orpaddle; Stripe returns400). 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 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_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.
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.