Reference

API reference

Complete HTTP API reference for Auth Service, grouped by area.

Authentication

Unless a route is marked public, it requires an authenticated BetterAuth session. Admin routes (/api/admin/*) require the admin or superadmin global role. Pass the session cookie set on sign-in. An Authorization: Bearer <token> header is honoured only where a route says so: the wallet read routes (a user JWT with scope wallet:self:read), the machine routes (/api/internal/*, /api/app/billing/*) and the consumption routes (a client_credentials token). Unauthenticated requests to session routes receive 401 AUTH_001; authenticated requests lacking the required role receive 403 AUTH_011. Errors use the envelope described in Error codes.

System

MethodPathAuthDescription
GET/healthpublicHealth check. Returns { "status": "ok", "version": "<version>" }
GET/errorpublicTemplated authentication-error page (HTML; requires TEMPLATES_DIR, otherwise the SPA handles the path). Query: error, error_description, optional client_id (per-application template and branding) and login (same-origin back-link)
GET/select-orgsessionOrganization selection page shown during an authorization with the org scope when the user belongs to several organizations (templated with TEMPLATES_DIR, otherwise the SPA). Anonymous callers are redirected to /login
GET/complete-registrationpublicTemplated one-shot registration page reached from an application invitation email. Query: token (the invitation), optional client_id. A pending token renders the email/password form and social-provider buttons. A provider callback returns here with a session and renders the completion page. Anything else redirects to /error
GET/accept-invitationpublicTemplated organization invitation page. Query: id (the BetterAuth invitation). Renders registration (email/password and social providers) or the acceptance/completion state. Unknown ids redirect to /error

Discovery (OIDC / OAuth 2.1)

MethodPathAuthDescription
GET/.well-known/openid-configurationpublicOIDC discovery document
GET/.well-known/oauth-authorization-serverpublicRFC 8414 authorization-server metadata

BetterAuth

All BetterAuth-managed routes are served under /api/auth/. See OAuth 2.1 / OIDC for the full flow.

MethodPathAuthDescription
ALL/api/auth/*variesBetterAuth handler — sign-in, sign-up, sign-out, sessions, OAuth2, OIDC, MFA, passkeys, social login
GET/api/auth/jwkspublicJSON Web Key Set
GET/POST/api/auth/oauth2/authorizepublicAuthorization endpoint
POST/api/auth/oauth2/tokenclient credentials / public (PKCE)Token endpoint
POST/api/auth/oauth2/introspectclient credentialsToken introspection
POST/api/auth/oauth2/revokeclient credentialsToken revocation
GET/POST/api/auth/oauth2/end-sessionid_token_hint / sessionRP-initiated logout (needs enableEndSession on the application). See Sessions and sign-out
GET/api/auth/oauth2/userinfoBearerUserInfo endpoint

App configuration (public)

MethodPathAuthDescription
GET/api/app-configpublicRegistration policy and enabled social providers. Query param client_id=<app-slug> (an unknown slug answers 404 APP_002). Without it (the service's own sign-in) registration is closed and no social provider is offered.

User self-service

All routes require the caller's own session.

MethodPathAuthDescription
GET/api/user/subscriptionsessionOwn app access list with plan details, each app's availablePlans, and stripeConfigured
PATCH/api/user/subscription/:applicationIdsessionSelf-service plan change ({ planId }). A free plan applies at once. 403 SUB_005 when Stripe is not configured. A paid plan answers { ok, changed: false, pending, checkoutUrl }: pending: true when a live paid subscription was changed in place (the plan follows Stripe's update), otherwise checkoutUrl to open (Stripe Checkout, which grants the plan on completion). 409 SUB_007 is not returned here: a checkout over a live paid subscription is never offered
GET/api/user/billing/invoicessessionThe caller's invoices across applications and wallet top-ups, newest first. Query limit (1–100, default 20), cursor. Returns { items, nextCursor } (same item shape as the application read API)
GET/api/user/billing/usagesessionMetered usage of the current period for an application the caller uses: ?applicationId= → { periodStart, features }. 403 APP_005 without access
POST/api/user/billing/checkoutsessionOpen a Stripe Checkout in subscription mode for a paid plan: { applicationId, planId } → { url }. The plan must have a recurring price; 403 APP_005 without access to the application
POST/api/user/billing/portalsessionOpen the Stripe Billing Portal (payment methods, cancellation) for the user's Stripe Customer → { url }
GET/api/user/consumption/:appIdsessionOwn consumption aggregates for an application
GET/api/user/sessionssessionOwn active sessions
DELETE/api/user/sessions/:idsessionRevoke one of own sessions (404 AUTH_016 when it does not exist or is not yours)
GET/api/user/organizationssessionOrganizations the user belongs to
PATCH/api/user/profilesessionUpdate own profile (name, phone, company, position, address, image)

Wallet

Credit is held in integer micro-euros (1 EUR = 1 000 000), shared by every application and never expiring. The balance goes negative only when something that already happened is recorded: usage reported after the call, or a refunded payment whose credit was spent. The debt blocks further spending (canSpend: false) and is repaid by the next top-up. Amounts are sent and returned as strings (JSON has no 64-bit integer). Every write takes an idempotencyKey: replaying the same key with the same parameters returns the original transaction (200, replayed: true), with other parameters 409 WAL_003. A first application answers 201.

Account: { "balance": "5000000", "isUnlimited": false, "canSpend": true, "currency": "eur" }. canSpend is true for an unlimited account or a strictly positive balance: applications should block on it rather than re-derive it. Transaction: { id, type, amount, delta, balanceAfter, applicationId, metadata, createdAt } where type is topup, grant, refund, usage, adjust or reversal (a payment taken back by a refund or dispute), amount is the signed value of the operation and delta its effect on the balance (0 for usage on an unlimited account).

MethodPathAuthDescription
GET/api/user/walletsession or JWT scope wallet:self:readThe caller's wallet
GET/api/user/wallet/topup-optionssession or JWT scope wallet:self:readWhether online top-up is available and its bounds: { enabled, currency, minCents, maxCents }
POST/api/user/wallet/checkoutsessionOpen a Stripe Checkout to buy credit: { amountCents } → 201 { url }. 503 WAL_006 when Stripe is not configured
GET/api/user/wallet/statssession or JWT scope wallet:self:readThe caller's spend over the last days (1–90, default 30): { days, total, notBilled, calls, series, byApplication, byKind, byModel, daily }. total is the real consumption, unlimited accounts included; notBilled is the part of it that was never deducted. series has one point per UTC day, empty days included. byModel groups by model (with prompt/completion/total tokens; mcp for tool calls), and daily gives the per-day split by application and by model
GET/api/user/wallet/usagesession or JWT scope wallet:self:readWhat one chat session spent: query sessionId (required, ≤200) and days (1–90, default 30) → { sessionId, applicationSlug, days, total, notBilled, calls, byModel }. The sessionId is the one the application passed to the gateway (metadata.session_id); a bearer caller is bound to its own application (the token azp), so two applications cannot read each other's identical session ids
GET/api/user/wallet/transactionssession or JWT scope wallet:self:readThe caller's history, newest first. Query: limit (1–200, default 50), cursor (from nextCursor), type. Returns { items, nextCursor }
GET/api/internal/wallet/users/:userIdmachine wallet:readA user's wallet (created empty on first read)
POST/api/internal/wallet/users/:userId/usagemachine wallet:debitCharge a consumption: { amount, idempotencyKey, applicationSlug?, metadata? }. Always recorded, even beyond the balance (the debt blocks the next calls). The gateway's metadata carries kind, model, the token counts and, when the client passed one, sessionId
GET/api/internal/gateway/users/:userId/planmachine wallet:readThe plan a user holds for an application, and its gateway team: ?applicationSlug= → { plan, teamId } (teamId is null when the user holds no plan). Called by the gateway's custom auth to pick the team of each request
GET/billing/subscribe, /billing/invoices, /billing/usagesession (the signed-in user)The delegated billing pages of an application (?client_id=<slug>), served only when its billing_mode is delegated and the user has access. Anything else: 302 to the application's sign-in when anonymous, 404 for a non-delegated application, 403 without access, 500 (static page, never the SPA) when the template fails to render
GET/api/app/billing/users/:userId/subscriptionmachine billing:read (for the calling application)The user's subscription in the calling application: { subscription } with plan, isActive, expiresAt, stripeStatus, currentPeriodStart/End, cancelAtPeriodEnd, canceledAt. subscription is null when the user holds none. The application is the token's client, never a request parameter
GET/api/app/billing/users/:userId/usagemachine billing:readMetered usage of the current UTC month for the calling application: { periodStart, features }, each feature with featureKey, unit, usageLimit (-1 = unlimited), used, remaining (null when unlimited)
GET/api/app/billing/users/:userId/invoicesmachine billing:readThe user's Stripe invoices for the calling application, newest first. Query: limit (1–100, default 20), cursor (from nextCursor). Returns { items, nextCursor }; each item has id, status, billingReason, currency, amountDue, amountPaid, amountRemaining (minor units), periodStart, periodEnd, createdAt, hostedInvoiceUrl, invoicePdf. Wallet top-up invoices are not listed here (they belong to no application)
GET/api/admin/walletsadminWallets with the owner's name and email, balance and 30-day spend. Query: page, limit, search (name or email), filter (all, debt, credit, unlimited). Returns { wallets, total, page, limit }
GET/api/admin/wallets/statsadminPlatform totals (credit held, debt, accounts in debt or unlimited), spend (with its notBilled share, i.e. usage on unlimited accounts) and top-up series over days, five top consumers, breakdown by application, by type and by model
GET/api/admin/wallets/pricingadminThe pricing in force and its last 20 changes: { current: { usdToEurRate, marginPercent, source, change } | null, history }. source is environment until a superadmin saves a value; current is null when no rate is set anywhere (usage answers 503 WAL_007)
PUT/api/admin/wallets/pricingsuperadminSave the exchange rate and margin: { usdToEurRate, marginPercent, reason } (rate: positive decimal string, up to 8 decimals; margin: integer 0–1000). Applies to the next debit; recorded with its author
GET/api/admin/wallets/usage-ledgeradminUsage debits reported by the gateway in [from, to) (ISO dates, limit up to 1000, cursor), oldest first: { items: [{ requestId, userId, amount, costUsd, createdAt }], nextCursor }. Read by pnpm llm:reconcile
GET/api/admin/wallets/users/:userId/statsadminA user's spend statistics (same shape as the user endpoint)
GET/api/admin/wallets/users/:userIdadminA user's wallet
GET/api/admin/wallets/users/:userId/transactionsadminA user's history (same query as above)
POST/api/admin/wallets/users/:userId/topupssuperadminRecord credit bought outside Stripe (manual invoice): { amount, idempotencyKey, reference }
POST/api/admin/wallets/users/:userId/grantssuperadminGive credit: { amount, idempotencyKey, reason }
POST/api/admin/wallets/users/:userId/adjustmentssuperadminSigned correction: { amount, idempotencyKey, reason } (negative removes credit; 402 WAL_002 if it would take the balance below zero: only usage and reversals may)
PUT/api/admin/wallets/users/:userId/unlimitedsuperadminToggle unlimited access: { isUnlimited, reason }. Audited

Machine routes (/api/internal/*) accept only a client_credentials token carrying the route's scope. The client's current configuration is checked too, so disabling the client or removing a scope takes effect immediately. The usage metadata is a flat map (max 32 entries) for raw quantities such as model and token counts: never put secrets in it. applicationSlug is the calling application (the end user's token azp); the usage is still charged if that application has been deleted since.

Consumption

MethodPathAuthDescription
POST/api/consumptionclient_credentials token / admin sessionRecord a consumption entry ({ applicationId, userId, key, value, idempotencyKey? }). When key is a metered feature of the user's active plan, it is billed instead: the per-period aggregate is capped (403 BIL_003 past the limit), the report is idempotent on idempotencyKey, and the response is { success, metered: true, aggregate }
GET/api/consumption/:userId/:applicationIdclient_credentials token / admin sessionAggregates for a user + application
GET/api/consumption/:userId/:applicationId/:keyclient_credentials token / admin sessionSingle aggregate by key
DELETE/api/consumption/:userId/:applicationId/:keyadminReset a consumption counter

A client_credentials token is bound to its own application (the token's azp) and gets 403 AUTH_011 on another one; a user-bound bearer token is refused with 403 CONS_004, and so is a caller with neither a valid token nor an admin session.

Admin — Applications

MethodPathAuthDescription
GET/api/admin/applicationsadminList all applications
POST/api/admin/applicationsadminCreate an application (auto-creates default roles and a free plan)
GET/api/admin/applications/favicon?url=adminDetect the favicon declared by the page at url ({ iconUrl }, null when none). Public hosts only (SSRF-guarded, redirects re-checked), 30 requests per minute; the admin form uses it to pre-fill the icon
GET/api/admin/applications/:idadminGet a single application
PATCH/api/admin/applications/:idadminUpdate an application
DELETE/api/admin/applications/:idadminDelete an application (cascades)
POST/api/admin/applications/:id/rotate-secretadminRotate the OAuth client secret
PATCH/api/admin/applications/:id/providersadminUpdate per-application enabled social providers
GET/api/admin/applications/:id/machine-scopesadminScopes the application may request through client_credentials
POST/api/admin/applications/:appId/users/:userId/subscription/canceladminStop the user's Stripe subscription at the end of its period → { ok, cancelAtPeriodEnd: true }. 404 SUB_008 without a Stripe subscription
POST/api/admin/applications/:appId/users/:userId/subscription/resumeadminUndo the above → { ok, cancelAtPeriodEnd: false }
PUT/api/admin/applications/:id/machine-scopessuperadminReplace them ({ scopes }, among m2m, wallet:read, wallet:debit, billing:read). Private applications only; removing a scope revokes the client's tokens

Registration (public)

MethodPathAuthDescription
POST/api/registration/completepublicRedeem an application invitation with email/password ({ token, name, password }, password ≥ 8). Creates a verified account, grants the invited application's access and consumes the invitation. 400 AUTH_012 for an invalid, expired or already-used token; 409 AUTH_013 when the address already has an account. Rate-limited (20/min)
POST/api/registration/organization-invitation/completepublicRedeem an organization invitation with email/password ({ invitationId, name, password }). Creates a verified account, adds it to the invited organization and consumes the invitation in one transaction. No application access is granted. 400 AUTH_012, 409 AUTH_013. Rate-limited (20/min)
POST/api/registration/organization-invitation/acceptsessionAccept an organization invitation for the signed-in account ({ invitationId }). The session email must match the invited address (403 AUTH_014) and be verified (403 AUTH_015). Delegates to the organization plugin. Rate-limited (20/min)
POST/api/registration/organization-invitation/socialpublicStart a social sign-up/sign-in for an organization invitation ({ invitationId, provider }). Returns { url } to redirect the browser. Carries a short-lived signed invitation in BetterAuth's server-trusted OAuth state; the account created by the provider must match the invited address. Rate-limited (20/min)
POST/api/registration/application-invitation/acceptsessionFinish an application invitation for a session created after the invitation (social sign-up). Body { token }; the session email must match (403 AUTH_014) and be verified (403 AUTH_015). Grants application access and consumes the token. No organization is assigned. Rate-limited (20/min)
POST/api/registration/application-invitation/socialpublicStart a social sign-up/sign-in for an application invitation ({ token, provider }). The provider must be enabled on the application. Returns { url } and carries the signed invitation in BetterAuth's server-trusted OAuth state. Rate-limited (20/min)

Admin — Application users

MethodPathAuthDescription
GET/api/admin/applications/:id/usersadminList users with access to the application. Paginated (?page=&limit=&search=); returns { users, total, page, limit }
GET/api/admin/applications/:id/subscriptionsadminCompact subscription rows for the financial tab ({ userId, planId, isActive })
POST/api/admin/applications/:id/usersadminGrant a user access ({ userId, roleId?, subscriptionPlanId?, reason? }; defaults to the app's default plan when no plan is given)
POST/api/admin/applications/:id/users/bulkadminGrant many users ({ organizationId | userIds, roleId?, subscriptionPlanId? })
PATCH/api/admin/applications/:id/users/:userIdadminUpdate a user's access record (isActive, roleId, subscriptionPlanId, expiresAt, reason). reason is required when subscriptionPlanId or expiresAt is present; set subscriptionPlanId to null to revoke the subscription
DELETE/api/admin/applications/:id/users/:userIdadminRevoke a user's access
GET/api/admin/applications/:id/users/:userId/historyadminLogin history for the user in this application. Paginated (?page=&limit=); returns { entries, total, page, limit }

Admin — Registration invitations

Admin-triggered, one-shot registration for an address that has no account, on an application whose self-registration is closed. The token is emailed as a link to /complete-registration, stored only as a hash, single-use and time-limited (72 hours).

MethodPathAuthDescription
POST/api/admin/registration-invitationsadminCreate an invitation ({ email, applicationId }). Emails the link and returns { invitation, inviteUrl, emailSent } (201). inviteUrl is returned once so the administrator can hand it over when mail is not configured. 409 AUTH_013 when the address already has an account
GET/api/admin/registration-invitationsadminList invitations, newest first. Optional applicationId filter and page/limit or offset paging (default 20, max 100). Returns { invitations, total, page, limit }
DELETE/api/admin/registration-invitations/:idadminRevoke a pending invitation (400 AUTH_012 when it is missing or already used)
POST/api/admin/registration-invitations/:id/resendadminIssue a fresh invitation for the same address and application, voiding the previous pending link (201)

Admin — Roles and permissions

MethodPathAuthDescription
GET/api/admin/applications/:appId/rolesadminList roles with their permission assignments
POST/api/admin/applications/:appId/rolesadminCreate a role
PATCH/api/admin/applications/:appId/roles/:roleIdadminUpdate a role's description (null clears it) and/or isDefault. The name is immutable: any other field is a 400 APP_001
DELETE/api/admin/applications/:appId/roles/:roleIdadminDelete a role (fails if users are assigned)
GET/api/admin/applications/:appId/permissionsadminList permissions
POST/api/admin/applications/:appId/permissionsadminCreate a permission (resource + action)
DELETE/api/admin/applications/:appId/permissions/:permIdadminDelete a permission
POST/api/admin/applications/:appId/roles/:roleId/permissionsadminAssign a permission to a role ({ permissionId }, idempotent). Role and permission must belong to the application (PERM_002 / PERM_003)
DELETE/api/admin/applications/:appId/roles/:roleId/permissions/:permIdadminRemove a permission from a role (same ownership checks)

Admin — Accounting journal

MethodPathAuthDescription
GET/api/admin/accounting/journaladminRead-only, newest-first merge of every billing action: wallet top-ups, grants, refunds, adjustments and reversals, unlimited-access changes and subscription plan changes (metered usage is excluded). Query: page, limit (≤ 100, default 25), kind (topup, grant, refund, adjust, reversal, unlimited_enabled, unlimited_disabled, subscription_assigned, subscription_changed, subscription_removed), userId, applicationId (account events carry no application and drop out), from (inclusive) and to (exclusive) ISO dates; page * limit is capped at 5000 (400 APP_001 beyond). Returns { entries, total, page, limit }; an entry carries owner, the actor (null for Stripe and the default-plan rule), source, application, signed micro-euro amount and balanceAfter, fromPlan / toPlan, reason and reference

Admin — Subscription plans

MethodPathAuthDescription
GET/api/admin/applications/:appId/plansadminList plans with prices
POST/api/admin/applications/:appId/plansadminCreate a plan with plain features (string, number or boolean values). Creates its Stripe product when Stripe is configured. Refused with 400: a Stripe id, a metered feature declared as an object, or any unknown field
PATCH/api/admin/applications/:appId/plans/:planIdadminUpdate a plan
DELETE/api/admin/applications/:appId/plans/:planIdadminDelete a plan (fails if active subscriptions exist)
POST/api/admin/applications/:appId/users/:userId/subscriptionadminAssign a plan to a user without one ({ planId, expiresAt?, reason? }); 409 SUB_002 when one is active (change it with the PATCH above). Returns 201
DELETE/api/admin/applications/:appId/users/:userId/subscriptionadminRemove the user's subscription (404 SUB_001 when there is none). Returns 204
POST/api/admin/applications/:appId/plans/:planId/pricesadminAdd a price: billingType fixed (name, amount, currency, interval) or metered (adds featureKey, unit, usageLimit, -1 = unlimited; interval is month). Refused with 400 on a Stripe id or an unknown field
PATCH/api/admin/applications/:appId/plans/:planId/prices/:priceIdadminRename a price; on a metered price also change unit and usageLimit. Amount, currency, interval and feature key are fixed
DELETE/api/admin/applications/:appId/plans/:planId/prices/:priceIdadminDelete a price tier

Admin — Consumption

MethodPathAuthDescription
GET/api/admin/applications/:id/consumptionadminConsumption aggregates for all users of an application
GET/api/admin/applications/:appId/consumption/monthlyadminMonthly aggregates grouped by user and key. Query params year, month.

Admin — Users

MethodPathAuthDescription
GET/api/admin/usersadminList users (paginated; ?page=&limit=&search=)
POST/api/admin/usersadminCreate a user ({ name, email, password, role? }; role: "admin" is reserved to superadmins)
GET/api/admin/users/:idadminGet a user with application access and roles
PATCH/api/admin/users/:idadminUpdate a user (name, global role, isMfaRequired)
DELETE/api/admin/users/:idadminDelete a user (cascades)
POST/api/admin/users/:id/disableadminBan (disable) a user
POST/api/admin/users/:id/enableadminUnban (enable) a user
POST/api/admin/users/importadminCreate up to 500 users: { users: [{ name, email, password?, role?, id?, emailVerified?, passwordHash? }], sendVerification? }. role is user or admin; admin, id, emailVerified and passwordHash are reserved to superadmins. id becomes the user id and the token sub. passwordHash is { algorithm: "bcrypt", hash } or { algorithm: "pbkdf2", digest: "sha256"|"sha512", iterations, salt, hash } (base64), replaced by a native hash at the first sign-in; send it instead of password. sendVerification (default true) e-mails unverified rows. Answers per row created or error; a temporary password is returned once for rows with neither password nor hash
POST/api/admin/users/:id/send-verificationadminSend the verification e-mail again
POST/api/admin/users/:id/verify-emailadminMark the address verified without e-mail (strictly lower-ranked users only; 403 AUTH_011 otherwise)
POST/api/admin/users/:id/set-passwordadminSet a new password for a strictly lower-ranked user and revoke their sessions and OAuth tokens ({ newPassword })

Admin — Organizations

MethodPathAuthDescription
GET/api/admin/organizationsadminList organizations (paginated; ?page=&limit=&search=&sortBy=&sortOrder=)
POST/api/admin/organizationsadminCreate an organization
GET/api/admin/organizations/:idadminGet an organization
PATCH/api/admin/organizations/:idadminUpdate an organization (name, slug, logo, metadata)
DELETE/api/admin/organizations/:idadminDelete an organization
GET/api/admin/organizations/:id/membersadminList members (?limit=&offset=&search=); returns { members, total }
POST/api/admin/organizations/:id/membersadminAdd a member ({ userId, role })
PATCH/api/admin/organizations/:id/members/:memberId/roleadminUpdate a member's role (owner / admin / member)
DELETE/api/admin/organizations/:id/members/:userIdadminRemove a member
GET/api/admin/organizations/:id/invitationsadminList pending invitations
POST/api/admin/organizations/:id/invitationsadminSend an invitation ({ email, role })
DELETE/api/admin/organizations/:id/invitations/:invitationIdadminCancel an invitation

Admin — Sessions

MethodPathAuthDescription
GET/api/admin/sessionsadminList active sessions (paginated; ?page=&limit=&search=). applications always starts with Auth Service (every session is one of its own), then the applications the user obtained tokens for since; system applications are hidden except the LiteLLM admin interface, shown as LiteLLM

Admin — Statistics

MethodPathAuthDescription
GET/api/admin/stats/active-usersadminCount of distinct users with a non-expired session
GET/api/admin/stats/loginsadminDaily login series. Query params range (7d / 30d) and optional appId.
GET/api/admin/stats/registrationsadminDaily account-creation series, platform-wide. Query param range (7d / 30d). Returns { range, series, total }
GET/api/admin/stats/applications-activityadminPer-application activity (online count, recent logins, 7-day sparkline)

Admin — Real-time channel

MethodPathAuthDescription
GET/api/admin/eventsadminServer-Sent Events stream. Frames name an event type (login.recorded, wallet.changed, subscription.changed…) and carry at most a timestamp — never a payload. The client re-fetches through the REST endpoints.

Admin — Services

MethodPathAuthDescription
GET/api/admin/servicesadminWhich optional integrations (Stripe, social providers) are configured, and the active compatibility profile (compat)

Stripe webhooks

MethodPathAuthDescription
POST/api/webhooks/stripeStripe signatureStripe event receiver (subscription and invoice lifecycle events)

The receiver parses the raw body and verifies Stripe-Signature before any state change. Responses:

StatusWhen
200Event applied, or a duplicate delivery (same event.id) acknowledged without re-applying state
400 APP_001Missing or invalid signature. The message is generic; the event is not claimed, so a valid retry is still processed
503 SRV_002STRIPE_SECRET_KEY or STRIPE_WEBHOOK_SECRET is not configured
500 SRV_001Handler failure. The idempotency claim is released so Stripe's retry is processed

Invoice links are cached only when they are https:// URLs. The webhook carries no session and must stay reachable without authentication; do not put a session guard in front of it.

Copyright © 2026