Skip to content

API contract for programmatic clients

The HTTP surface (src/BookTranslation.Api) is the substrate the web app, the MCP server, and the desktop apps all consume. This document is the stable contract an agent/MCP client codes against: the error envelope, the idempotency guarantee, the list convention, and the discovery endpoint. Shipped by PBI #1428 (Feature #1410, Public API GA). It complements docs/spec.md §7.

The driving constraint: an agent can't pattern-match a human sentence. Every response is shaped so a client can branch on typed, predictable fields — never on prose that may be reworded.

1. Errors — RFC 9457 ProblemDetails

Every non-2xx response is a single application/problem+json envelope:

json
{
  "type":   "https://kenly/errors/project_wrong_state",
  "title":  "Invalid state transition",
  "status": 409,
  "detail": "Cannot 'normalize' from state 'Calibrating'. Expected: Created.",
  "code":   "project_wrong_state"
}
  • code is the contract. It is a stable, machine-readable string clients switch on. title/detail are human-facing and may be reworded; code and type (which is https://kenly/errors/{code}) will not change for a given condition.
  • The shape is produced in exactly one place — the ApiProblem factory in src/BookTranslation.Api/ApiContract.cs, plus a registered IExceptionHandler that maps an uncaught InvalidProjectStateException through the same factory — so it cannot drift per endpoint.
  • Domain extras ride along as typed extension members, not a different top-level shape: a 402 carries the affordability quote.

Stable codes

codeStatusMeaning
validation400A field was missing, malformed, too large, or otherwise invalid.
invalid_credentials401/auth/login credentials did not match.
insufficient_credit402The wallet can't cover the next batch. Carries quote (a BatchQuote).
project_not_found404No project with that id exists for the caller (owner-scoped; never leaks existence).
account_not_found / job_not_found / notification_not_found / avatar_not_found / source_not_found / export_not_found / image_not_found404The named resource was not found.
topup_not_found404No top-up/redeem with that reference exists for the caller (GET /v1/wallet/top-up/{reference}; owner-scoped).
email_taken409Registration: the email already has an account.
project_wrong_state409A pipeline transition was invoked from a state its guard forbids.
no_failed_pages409POST /projects/{id}/retry when the project has no failed pages to retry.
queue_changed409The queue changed under a reorder (an item started/finished).
idempotency_in_flight409An identical request for the same operation is already being processed (server-side dedup, §2); retry shortly.
rate_limited429The per-principal request rate limit was exceeded. Carries Retry-After + RateLimit-* headers (§8).

2. Idempotency — internal, no client action (PBI #2120)

Retry-safety is a pure backend concern. There is no client Idempotency-Key header — a client never sends, reads, or sees an idempotency key. For each billable / state-changing POST the server derives a stable dedup key from the request content and the project's current state, so a double-submit can't double-charge or double-enqueue. This protects even clients that would never have thought to send a key.

Internal-only mechanics (not part of the public contract; documented here so the behaviour is traceable):

  • Server-derived keys. Project transitions (normalize, process, process-next-batch, export, retry, cancel) key on {scope}:{owner}:{projectId}:{state}, where {state} is the status + batch cursor (ApprovalCount), or the failed-page set for retry — so a duplicate of the same operation is deduped while the legitimately-next one composes a fresh key. The wallet purchase (§6) has no state cursor, so it keys on the request content (owner + amount + method + card) within a short time window: a double-submit collapses while a deliberate repeat after the window proceeds.
  • Precision trade-off (flagged). A server-derived content+window key is slightly less precise than a client-chosen one: two intentional identical top-ups in the same short window would collapse into one. Acceptable for this app; the simplification is worth it.
  • Pre-reservation guards. Affordability (402) is checked before the key is reserved, so a rejected attempt is never cached.
  • Fail-open. Backed by Redis (the Aspire cache resource) via IIdempotencyStore; if Redis is unreachable the store returns Bypassed and the endpoint proceeds (retry protection is lost for that request, availability is not). With no Redis configured (a bare dotnet run or the tests) an in-memory store with identical semantics stands in. See CLAUDE.md → Redis (cache) + idempotency.

3. List convention

Collection endpoints return a bare JSON array. The one exception is the searchable, sortable, paginated project library — GET /projects — which returns a PagedResult envelope:

json
{ "items": [  ], "total": 128, "page": 1, "pageSize": 25 }

4. Versioning + OpenAPI (PBI #1427)

The whole surface is served under a stable /v1 base path (e.g. POST /v1/projects). A narrow server-side back-compat rewrite still accepts the old unversioned paths, so /projects is an alias for /v1/projects — older clients keep working while /v1 is the versioned contract.

A generated OpenAPI 3 document is served at GET /openapi/v1.json (.NET 10 first-party Microsoft.AspNetCore.OpenApi). Every operation has a stable operationId, a resource tag, and typed request/response + ProblemDetails (402/404/409) responses; the document declares the Bearer (JWT) and ApiKey (X-Api-Key, PBI #1426) security schemes so a code generator / MCP server can authenticate.

4a. One cross-surface status/event contract (PBI #2006)

Every surface — the SPA, the MCP server (#1433), a future mobile app, outbound webhooks (#2003) — reasons about the same book status + lifecycle events, so the contract for them is published once, typed, in the OpenAPI doc and generated against (never hand-duplicated):

  • The status DTO (ProjectStatusView, GET /v1/projects/{id}/status, #2000/#2004) and the lifecycle event (ProjectLifecycleEvent, GET /v1/projects/{id}/events, #2002) are named schemas in the doc.
  • Their closed taxonomies are declared as enum types so a generated client gets exhaustive, non-drifting string-literal types instead of a bare string: state · phase (intake|working|waiting|done|error|cancelled) · waitingOn (gpu_capacity|gpu_warmup|queue|credit) · blockedOn (credit|retry) · nextAction (add_credit|retry|download), plus the transition event (create|normalize|processBatch|retry| autoAdvance|cancel|finalizeCancel). waitingOn=credit is reserved — published for forward-compat but never emitted (an out-of-credit book is blocked, not waiting; see book-status-state-machine.md §4.2). Each set is also published as a reusable, named enum schema (ProjectPhase, ProjectWaitingOn, …).
  • The enums are single-sourced from the server's own constants (ProjectStatusTaxonomy, ProjectLifecycleEventNames, the ProjectStatus enum) via an OpenAPI document transformer, so the published contract and the emitted payloads cannot diverge. Two conformance tests enforce it and fail CI on any break: StatusEventContractConformanceTests (every value the server can emit is inside the published set — always-on, no container) and OpenApiContractTests (the doc declares exactly those enums).
  • The SPA generates its types from the doc: a focused slice src/BookTranslation.Web/openapi/status-contract.json is committed and npm run codegen turns it into src/api/generated/status-contract.ts (re-exported from src/api/types.ts). StatusContractSpecTests guards the committed slice against the live doc.

4b. Versioning rule — additive-only within /v1

/v1 is a stable, additive-only contract. Within /v1 we only ever make backward-compatible changes, so a generated agent/mobile client never breaks silently:

  • Allowed (additive): new endpoints; new optional response fields; a new value appended to a closed enum only when every documented consumer already treats the enum as open / has a default branch (e.g. activating the reserved waitingOn=credit); new optional request parameters with safe defaults.
  • Breaking (requires a new version, e.g. /v2): removing/renaming a field or endpoint; changing a field's type or making an optional field required; removing an enum value; changing the meaning of an existing value. A breaking change is a new versioned surface, never an in-place edit of /v1.

Because the enum sets are single-sourced and conformance-tested, an accidental breaking change (e.g. deleting an enum value the DTO still emits, or renaming one) fails CI rather than shipping.

4c. Real-time push — SignalR + signed webhooks (PBI #2003)

Clients don't have to poll: the server pushes a book's status the instant it changes, over two transports driven off the same #2002 event log, both carrying the §4a ProjectStatusChange envelope (the durable event with its eventId replay cursor + the current status DTO — the same shape GET …/status returns).

  • SignalR hub (the SPA)GET /v1/hubs/status (WebSocket). Authenticated with the same JWT as the REST API (it rides via the access_token query on the handshake). Each connection joins only its own owner group, so a client can never receive another account's events (cross-tenant subscription is impossible — the group is keyed by the authenticated uid, never client input). Server → client method statusChanged delivers the envelope; the client method CatchUp(projectId, afterEventId) replays missed events from the last id it saw, so a reconnect never drops a terminal state. A Redis backplane (the Aspire cache) makes it work across multiple API instances.
  • Signed webhooks (headless agents / MCP) — register per-owner endpoints under /v1/webhooks (POST to register — the signing secret is returned once; GET to list without secrets; DELETE to remove). Each delivery is a JSON POST of the envelope, HMAC-SHA256 signed with the subscription secret: header X-Kenly-Signature: t=<unixSeconds>,v1=<hex> over "{t}.{body}" (Stripe-style — the t makes it replay-checkable), plus X-Kenly-Event-Id for idempotent receivers. Delivery retries with exponential backoff; an exhausted delivery is recoverable via the GET …/events replay feed.

5. Discovery — GET /v1/meta

Anonymous (no token required) so a client can self-configure before it authenticates:

json
{
  "apiVersion": "1.0",
  "batchLadder": [1, 5, 25, 100],
  "finalBatch": "rest",
  "exportFormats": ["All","Markdown","PlainText","Html","Epub","Docx","Pdf","Archive"],
  "problemTypeBase": "https://kenly/errors/"
}

The OpenAPI document is the discovery mechanism for the closed vocabularies (PBI #2112). There is deliberately no separate public /v1/discovery endpoint — a runtime endpoint would duplicate contract facts into a second source of truth that can drift. Instead the published doc (GET /openapi/v1.json) self-describes the app-wide facts a typed SDK/MCP client needs, as enums/constants it self-configures from: the API version (info.version), the export formats (ExportFormat enum), and the destination languagestargetLanguageCode is constrained to the supported ISO-639 set (published once as the reusable TargetLanguageCode component schema) everywhere a client chooses a destination language (the create-project body and the anonymous-estimate query). The source language is auto-detected and is never enumerated. /v1/meta stays Internal because it also carries deployment/feature-flag internals a public client must not see (OAuth client ids + enablement, payment-rail flags, the batch ladder, the problem-type base). Mechanically this mirrors the status/event contract (StatusContractDocumentTransformer) — see LanguageContractDocumentTransformer + OpenApiContractTests.

Model-backend readiness — GET /v1/model-status (PBI #1833)

Anonymous, poll-friendly. Reports whether the real model backend is up and GPU-ready right now, so the SPA can show a "GPU infrastructure is starting, please stand by…" banner and gate the process action instead of letting it fail (the graceful counterpart to the fail-closed backend, #1840):

json
{
  "backend": "RunPod",
  "isReal": true,
  "ready": false,
  "state": "starting",
  "detail": "GPU infrastructure is starting (cuda:false (device=cpu))"
}
  • state is ready | starting (a real backend that is cold/unreachable) | unconfigured (no backend configured); ready is the convenience boolean (state == "ready"). isReal surfaces ModelBackendInfo.IsReal (true only for RunPod).
  • The readiness check runs the worker's health op through the same RunPodClient transport that serves real OCR/translation, so it is pod/serverless-agnostic — identical behaviour on the dev persistent pod and a production serverless endpoint (only BaseUrl/EndpointId differ; see docs/runbooks/runpod-model-processing.md §3).
  • The result is cached for a short TTL server-side (and coalesced), so many polling tabs trigger at most one backend health call per window — important on serverless, where a health op can wake a worker.

6. Agent wallet surface — /v1/wallet (PBI #1429)

The machine-callable credit surface the MCP purchase_tokens tool (#1433) binds to: read the balance and add credit without a human opening the web app. A thin, versioned layer over the one credit-grant routine (docs/payments.md §2) — it adds no second ledger; every credit is a confirmed CreditTransaction. All routes require auth and are owner-scoped.

Method & routeBodyReturns
GET /v1/wallet{ balanceUsd, currency, unlimited, lowBalance }
POST /v1/wallet/top-up{ amountUsd, method?, coin?, savedCardId? }{ reference, status, method, amountUsd, creditedUsd, balanceUsd, checkoutUrl?, message, cryptoCoin?, cryptoAddress?, cryptoAmount? }
GET /v1/wallet/top-up/{reference}the same top-up view (poll)
POST /v1/wallet/redeem{ code }a confirmed top-up view

POST /v1/wallet/top-up is the ONE canonical way to add credit (#2114 — it consolidated the former /wallet/top-up + /wallet/checkout + /wallet/checkout-saved-card, which no longer exist). One call, one unified response: either status: confirmed (credit landed now) or status: pending with a checkoutUrl to complete (poll GET /v1/wallet/top-up/{reference} until confirmed). Routing by method:

method+Behaviour
cardsavedCardIdCharges that saved card in-app — credit lands immediately (confirmed).
cardReturns a hosted Stripe Checkout checkoutUrl (pending); completable headlessly.
cryptocoin?Returns a hosted CoinPayments checkoutUrl (pending) for the chosen coin.
manualCredits immediately (confirmed) — development-only (see below).
  • Card model (PCI). Raw card numbers must never touch this API, so saving a new card happens on Stripe's hosted pagemethod=card with no savedCardId returns a hosted checkoutUrl the user opens once. After a card is saved, charging it is pure API: pass its savedCardId (from GET /v1/payment-methods) and the charge happens in-app, no browser. So the model is: new card → hosted URL (once); saved card → API charge; crypto → hosted URL.
  • manual (instant credit) is development-only. It credits with no payment taken, so it is off in production/UAT (BookTranslation:Billing:ManualCreditEnabled, default = Development-only). A manual request on a production deployment is rejected validation (400) — a public caller can never mint free credit. Pay by card or crypto instead.
  • Retry-safety is automatic and backend-owned (§2). The server derives its own dedup key from the purchase (owner + amount + method + card + a short time window) and forwards it to Stripe, so a double-submit within the window can't take money twice — the caller sends no header. Hosted checkout / crypto flows are naturally single-use.
  • status is lowercase (confirmed / pending / failed / cancelled / expired); method is manual / card / crypto. method defaults to manual when omitted.
  • lowBalance is true once a charged wallet falls to/below the configured threshold (BookTranslation:Billing:LowBalanceThresholdUsd, default $1) — the cue to prompt a top-up before the next batch is blocked with insufficient_credit (402).
  • Redeem codes (POST /v1/wallet/redeem, BookTranslation:Billing:RedeemCodes) are a separate operator-issued mechanism (not consolidated): redeeming a configured code credits immediately so a 402'd batch can resume. Single-use per owner; an unknown or already-redeemed code is validation (400).
  • Errors: out-of-bounds amount / unknown method / manual in production / bad code → validation (400); unknown reference → topup_not_found (404).

7. Authentication — JWT + API keys (PBI #1426)

Every non-auth endpoint accepts either credential, both resolving to the same owner identity (the same owner-scoped 404-on-cross-tenant behavior applies to both):

  • Interactive JWT — a 12-hour bearer token from POST /v1/auth/login / /v1/auth/register, sent as Authorization: Bearer <jwt>. For the browser SPA.
  • Scoped API key — a long-lived, revocable secret for headless agent/MCP clients, sent as Authorization: Bearer kenly_sk_live_… or X-Api-Key: kenly_sk_live_…. A "smart" auth scheme picks the handler per request by the credential shape.

Key management (owner-scoped, under /v1/api-keys):

Method & routeBodyReturns
POST /v1/api-keys{ name, scopes?, expiresInDays? }{ id, name, prefix, scopes, createdAtUtc, expiresAtUtc, secret }secret is shown exactly once
GET /v1/api-keys[{ id, name, prefix, scopes, createdAtUtc, lastUsedAtUtc, expiresAtUtc, revokedAtUtc }] (never the secret)
DELETE /v1/api-keys/{id}204; revocation is immediate
  • Capability boundary — key management + identity/security are interactive-only (PBI #2117). An API key can do everything the product does for its owner (create/drive/observe projects, upload, read the wallet + buy credit, manage webhooks, read the account, edit low-risk preferences), but it can never mint another credential or change identity/security settings — the OpenAI/Stripe/Cloudflare model. Concretely, an API-key principal is rejected with 403 interactive_auth_required on: POST /v1/api-keys (mint a key), DELETE /v1/api-keys/{id} (revoke a key), DELETE /v1/me (delete the account), POST /v1/me/change-password (rotate the password, §7b), POST /v1/me/set-password (add a first password to an OAuth-only account, §7b-i), and POST /v1/me/change-email (change the login email, §7c). An interactive JWT (a human in the dashboard) is accepted on all of them. GET /v1/api-keys (observe) stays open to both. Create your first key in the web dashboard (JWT), then use it for everything else.

7b. Change password (PBI #2124)

A signed-in user rotates their own password without triggering the forgotten-password email:

Method & routeBodyReturns
POST /v1/me/change-password{ currentPassword, newPassword }200 { changed: true, token }
  • Interactive-only (§7). An API-key principal is rejected with 403 interactive_auth_required before any password check; an interactive JWT is accepted.
  • Current-password gated. The currentPassword is verified against the stored hash — a mismatch is 403 password_incorrect and nothing is persisted (mirrors the DELETE /v1/me confirmation).
  • New-password rules. newPassword must meet the minimum-strength rule and differ from the current one; either failure is 400 validation with the requirement message.
  • OAuth-only accounts. An account that signs in only through Google / Apple has no password to change → 409 password_not_set (a clear, non-leaky message — never a 500). Setting a first password on such an account is out of scope (a separate flow).
  • On success the new password is re-hashed + persisted, a best-effort security audit event is recorded (actor uid, UTC, request IP / UA — never blocks the change), and a fail-soft "password changed" alert email is sent to the account address (a send failure never fails the change).
  • Rate-limited per principal (anti-brute-force on the current-password confirmation): over the per-uid ceiling returns 429 with the uniform RFC-9457 body + Retry-After / RateLimit-* headers (§8).
  • Signs you out everywhere else (PBI #2126, §7d). The change rotates the account's security stamp, so every other session's token is revoked (401 on its next call). The response returns a fresh token for the acting (calling) session — swap your stored JWT for it so the device that changed the password stays signed in.

7b-i. Set a first password — OAuth-only accounts (PBI #2154)

A user who signed up with Sign in with Google / Apple has no password (PasswordHash is empty), so they can use neither §7b (change password) nor §7c (change email) — both re-enter a current password there is none of. This adds a password in-session as an optional backup sign-in method (the gold standard: one account, multiple methods). It is structurally §7b minus the current-password verify plus a guard that the account does not already have a password.

Method & routeBodyReturns
POST /v1/me/set-password{ newPassword }200 { changed: true, token }
  • Interactive-only (§7). An API-key principal is rejected with 403 interactive_auth_required before any work; an interactive JWT is accepted.
  • No current password. There is none to confirm — the interactive session is the authorization.
  • New-password rule. newPassword must meet the minimum-strength rule; a too-weak password is 400 validation with the requirement message.
  • Already has a password → non-leaky 409 password_already_set. An account that already has a password must use §7b (change password, which verifies the current one) — this endpoint only ever adds a first password and never silently overwrites an existing credential (so it can never remove the last sign-in method).
  • On success the password is hashed + persisted, a best-effort security audit event is recorded (account.password_set — actor uid, UTC, request IP / UA), and a fail-soft "password added" alert email is sent to the account address (a send failure never fails the change). After this the account has both the external provider and password sign-in.
  • Signs you out everywhere else (PBI #2126, §7d). Adding the password rotates the account's security stamp, so every other session's token is revoked; the response returns a fresh token for the acting session — swap your stored JWT for it so the device that added the password stays signed in. A subsequent GET /v1/me then reports hasPassword = true, so the SPA flips from the "Create a password" form to the normal change-password / change-email forms.
  • Rate-limited per principal (§8): over the per-uid ceiling returns 429 with the uniform RFC-9457 body + Retry-After / RateLimit-* headers.

7c. Change email — verify-before-flip (PBI #2125)

A signed-in user changes their login email safely. The design decision is verify-before-flip: the account email is not touched until the new address proves ownership — so a typo or a hijacked session can never strand a real user out of their account, and the change is always visible to the current owner.

Method & routeAuthBodyReturns
POST /v1/me/change-emailJWT only{ newEmail, password }202 { pending: true } — login email unchanged
POST /v1/auth/confirm-email-changeanonymous (token){ token }200 { changed: true } — email flips now
POST /v1/auth/cancel-email-changeanonymous (token){ token }200 { cancelled } — old-address kill switch
POST /v1/me/change-email/cancelJWT/key200 { cancelled } — in-app cancel
  • Initiate (POST /v1/me/change-email). Interactive-only (§7 — an API key is 403 interactive_auth_required) and current-password gated (mismatch → 403 password_incorrect, mirroring §7b / DELETE /v1/me). The new address is validated + normalized (invalid → 400 validation; equal to the current address → 400); an address already registered to another account → 409 email_taken. On success the login email is not changed — a pending change is recorded, a single-use verification link is emailed to the new address, and a "this wasn't me" cancel alert to the old address (the out-of-band takeover-detection channel, which names only the masked new address). An OAuth-only account (no password) is a non-leaky 409 password_not_set.
  • Confirm (POST /v1/auth/confirm-email-change). Anonymous — the single-use token from the new-address link is the credential (mirrors verify-email / reset-password). On a valid token the login email flips to the pending address, EmailVerified is set true (ownership just proven), the token + its paired cancel token are consumed, and an "email changed" confirmation is sent to the old address. email_taken is re-checked at confirm time — if the address was registered elsewhere in the interim, 409 email_taken and the change is not applied. An unknown / used / expired token is a non-leaky 400 invalid_email_change_token.
  • Cancel (either route). A pending change can be cancelled by the signed-in user (POST /v1/me/change-email/cancel, owner-scoped, idempotent) or by the real owner via the old-address alert's cancel link (POST /v1/auth/cancel-email-change, anonymous token). Either burns the pending change so the outstanding verify link can no longer complete. The two links carry distinct secrets — the old address gets only the cancel secret, so the owner can abort a change they didn't start without ever being able to complete it.
  • On both events a best-effort security audit event is recorded (account.email_change_requested / account.email_changed — actor uid, UTC, request IP / UA, the masked new address; never blocks the action). All emails are fail-soft (a send failure never fails the operation).
  • Rate-limited (§8): the initiate is capped per uid (anti-brute-force on the password + anti-mail-bomb on the new/old addresses); confirm + cancel are capped per IP. Over the ceiling → 429 with the uniform RFC-9457 body + Retry-After / RateLimit-* headers.
  • Signs you out everywhere (PBI #2126, §7d). Confirming the flip rotates the account's security stamp, so every existing session is revoked — the confirm is an anonymous link flow with no live JWT session to preserve, so the user simply signs in fresh with the new address.

7d. Session invalidation on credential change (PBI #2126)

Every JWT is stamped with the account's current security stamp (a stamp claim). Each authenticated request validates that claim against the stored value, and any credential change rotates the stamp — so one write invalidates every outstanding token at once. This is the gold-standard "changing your password signs you out everywhere else" behaviour, and it means a stolen or leaked token is cut off the moment the owner rotates a credential.

  • What rotates the stamp: an authenticated password change (§7b), a forgotten-password reset (POST /v1/auth/reset-password), and an email-change confirm (§7c). A token minted before the change returns 401 on its next call.

  • Acting-session continuity. The one flow with a live JWT session — change-password — returns a fresh token carrying the new stamp so the calling device stays signed in. The two link-completion flows (reset, email-change confirm) are anonymous and end at the sign-in screen, so the user authenticates fresh; there is no live session to re-issue.

  • API keys are unaffected. A kenly_sk_… principal carries no stamp and is never gated by this check — keys are a separate, independently revocable credential (revoke them under /v1/api-keys).

  • Cost + the trade-off. Validation is a short in-memory cache of each user's current stamp over a single indexed primary-key read: the common case (a matching token) is one memory hit with no database round-trip. A mismatch is always re-confirmed against the store before it 401s, so a just-re-issued token is never wrongly rejected by a stale cache. The cache can lag the database by up to ~30 s, so on a multi-instance deployment a revocation reaches instances that cached the prior value within that window (the instance that made the change evicts its own entry immediately) — an availability-over-immediacy choice that is still far tighter than ASP.NET Identity's 30-minute default. Validation is fail-safe: a lookup error admits the request rather than locking everyone out.

  • Out of scope: refresh-token rotation, a device/session-management UI, and sliding-expiration changes.

  • Storage: only a SHA-256 hash of the secret is persisted; the raw kenly_sk_live_… value is never stored or logged. The non-secret prefix is kept for display + leak scanning.

  • Scopes describe a key's intended capability and are recorded on the key and echoed back when you list it. The recognized set is projects:write (the default — read/write) and projects:read (read-only); a create request for any other token (including the retired approve scope) is rejected with validation (400). A revoked or expired key is 401 (distinct from a 401 for an unknown credential). A JWT (a human) authenticates with full capability.

    • Enforcement today. The only scope-gated route was the per-page human-approval endpoint, removed in #1455 (translation is fully automatic, review is read-only). With it gone, the approve scope had nothing left to guard and was removed in #2073; projects:read/projects:write are carried as a forward-compatible seam but are not yet enforced per route — a valid key authenticates for every owner-scoped operation regardless of its scope. Finer read-vs-write route enforcement is additive future work (Feature #1410); when it lands it will only ever tighten a read-only key, never loosen the default write key, so it is a backward-compatible change under the §4b additive-only rule.

7a. Google / Apple OAuth at the sign-up gate (PBI #1891)

A third way to sign in / up, alongside email+password (which is unchanged), on the same gate:

Method & routeBodyReturns
POST /v1/auth/oauth/{provider}{ credential }{ token, user } — the same JWT + account shape as /auth/login
  • {provider} is google or apple. credential is the OIDC identity token the provider's browser SDK returns (Google's ID token / Apple's identity token); the backend validates it against the provider's published JWKS (issuer + audience + signature + expiry) before trusting any claim.
  • Account resolution — all to the same uid model (an ExternalLogin row links (provider, subject) → an AppUser): a returning provider identity → its linked account; else a provider-verified email matching an existing account → link this provider to it (so Google and Apple for one person, or an OAuth sign-in after an email+password signup, land on one account); else a new password-less account is created and linked. Linking by email is only ever done for a provider-verified address.
  • Enablement is per-deployment + surfaced in /v1/meta (googleOAuthEnabled / appleOAuthEnabled + the public googleOAuthClientId / appleOAuthClientId), so the SPA only shows a button the backend can verify. Config: BookTranslation:Auth:OAuth:GoogleClientId / AppleClientId (public values, not secrets); a provider with no client id is off and the endpoint 400s it. BookTranslation:Auth:OAuth:UseFakeVerifier wires a deterministic in-process verifier for dev/tests (the OAuth analogue of the Stripe/CoinPayments UseFakeGateway), so the whole flow is exercisable without real provider credentials.
  • Errors: unsupported/disabled provider or missing credential → 400; an unverifiable token → 401; an existing but provider-unverified email → 409 email_taken (sign in with the password, then link).
  • Go-live with real Google/Apple credentials is gated with the rest of real-money go-live (#1491), exactly as the real payment rails are — this PBI ships the flow + the fake-verifier-verified path.

8. Rate limits (PBI #2071)

The public API is metered per authenticated principal so a runaway or hostile client can't drain GPU budget or starve other tenants. Limits are keyed by the caller's uid — so a JWT (human) and an API key both resolve to the same budget, two keys for one account share it, and two accounts are isolated.

Tiers

All tiers are fixed-window (per minute) and config-driven per environment (defaults below; nothing is hardcoded). Normal interactive use — the SPA, a well-behaved agent — is never throttled.

SurfaceKeyed byDefaultConfig key (BookTranslation:RateLimits:…)
Authenticated default (the whole /v1 authed surface)uid300 / minDefaultPerMinute
Billable processing (/projects/{id}/process, /process-next-batch)uid20 / minBillableProcessingPerMinute
Status/event poll (…/status, …/events)uid120 / minStatusPollPerMinute
Webhook management (/v1/webhooks…)uid30 / minWebhookAdminPerMinute
Anonymous estimate (/v1/anonymous-estimate)caller IP10 / minAnonymousEstimate:RateLimitPerMinute
Anonymous page image (picker thumbnails)caller IP600 / minAnonymousEstimate:ImageRateLimitPerMinute

The billable tier is deliberately the tightest: each processing call can spend real GPU money. It is the coarse request quota that complements the wallet gate (402 insufficient_credit) — the wallet refuses work an account can't afford; this bounds the request rate on top, so a $0 or abusive account can't hammer the expensive path. An endpoint's own tier overrides the group default (the SignalR hub and the anonymous payment webhooks are mapped outside the authed group, so neither is throttled).

The 429 response

Exceeding a limit returns the same RFC 9457 envelope as every other error, with the stable code rate_limited, plus the standard back-off headers so a client / the MCP server can wait exactly long enough:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 20
RateLimit-Remaining: 0
RateLimit-Reset: 12

{ "type":"https://kenly/errors/rate_limited", "title":"Too many requests",
  "status":429, "code":"rate_limited", "detail":"You have exceeded the request rate limit. …" }

Retry-After and RateLimit-Reset are seconds until the window resets.

Distribution + fail-open

Limiter state is held in Redis (the Aspire cache resource — the same Redis the idempotency store uses), so limits hold across every API instance. It fails open, mirroring the idempotency store: if Redis is unreachable the request is permitted rather than errored (rate-limit enforcement lapses for that request, availability does not), and a down Redis never blocks startup. With no Redis configured (a bare dotnet run or the tests) an in-memory counter with identical fixed-window semantics stands in.

Out of scope (here)

Full OAuth 2.0 / OIDC as a general authorization grant is a later option under #1356 — §7a covers identity-token sign-in only. This document covers the shape & safety of responses, not new transitions.