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 complementsdocs/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"
}codeis the contract. It is a stable, machine-readable string clients switch on.title/detailare human-facing and may be reworded;codeandtype(which ishttps://kenly/errors/{code}) will not change for a given condition.- The shape is produced in exactly one place — the
ApiProblemfactory insrc/BookTranslation.Api/ApiContract.cs, plus a registeredIExceptionHandlerthat maps an uncaughtInvalidProjectStateExceptionthrough the same factory — so it cannot drift per endpoint. - Domain extras ride along as typed extension members, not a different top-level shape: a
402carries the affordabilityquote.
Stable codes
code | Status | Meaning |
|---|---|---|
validation | 400 | A field was missing, malformed, too large, or otherwise invalid. |
invalid_credentials | 401 | /auth/login credentials did not match. |
insufficient_credit | 402 | The wallet can't cover the next batch. Carries quote (a BatchQuote). |
project_not_found | 404 | No 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_found | 404 | The named resource was not found. |
topup_not_found | 404 | No top-up/redeem with that reference exists for the caller (GET /v1/wallet/top-up/{reference}; owner-scoped). |
email_taken | 409 | Registration: the email already has an account. |
project_wrong_state | 409 | A pipeline transition was invoked from a state its guard forbids. |
no_failed_pages | 409 | POST /projects/{id}/retry when the project has no failed pages to retry. |
queue_changed | 409 | The queue changed under a reorder (an item started/finished). |
idempotency_in_flight | 409 | An identical request for the same operation is already being processed (server-side dedup, §2); retry shortly. |
rate_limited | 429 | The 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 forretry— 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
cacheresource) viaIIdempotencyStore; if Redis is unreachable the store returnsBypassedand the endpoint proceeds (retry protection is lost for that request, availability is not). With no Redis configured (a baredotnet runor the tests) an in-memory store with identical semantics stands in. SeeCLAUDE.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 transitionevent(create|normalize|processBatch|retry| autoAdvance|cancel|finalizeCancel).waitingOn=creditis reserved — published for forward-compat but never emitted (an out-of-credit book is blocked, not waiting; seebook-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, theProjectStatusenum) 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) andOpenApiContractTests(the doc declares exactly those enums). - The SPA generates its types from the doc: a focused slice
src/BookTranslation.Web/openapi/status-contract.jsonis committed andnpm run codegenturns it intosrc/api/generated/status-contract.ts(re-exported fromsrc/api/types.ts).StatusContractSpecTestsguards 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 theaccess_tokenquery 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 authenticateduid, never client input). Server → client methodstatusChangeddelivers the envelope; the client methodCatchUp(projectId, afterEventId)replays missed events from the last id it saw, so a reconnect never drops a terminal state. A Redis backplane (the Aspirecache) makes it work across multiple API instances. - Signed webhooks (headless agents / MCP) — register per-owner endpoints under
/v1/webhooks(POSTto register — the signing secret is returned once;GETto list without secrets;DELETEto remove). Each delivery is a JSONPOSTof the envelope, HMAC-SHA256 signed with the subscription secret: headerX-Kenly-Signature: t=<unixSeconds>,v1=<hex>over"{t}.{body}"(Stripe-style — thetmakes it replay-checkable), plusX-Kenly-Event-Idfor idempotent receivers. Delivery retries with exponential backoff; an exhausted delivery is recoverable via theGET …/eventsreplay 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/discoveryendpoint — 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 (ExportFormatenum), and the destination languages —targetLanguageCodeis constrained to the supported ISO-639 set (published once as the reusableTargetLanguageCodecomponent 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/metastays 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) — seeLanguageContractDocumentTransformer+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))"
}stateisready|starting(a real backend that is cold/unreachable) |unconfigured(no backend configured);readyis the convenience boolean (state == "ready").isRealsurfacesModelBackendInfo.IsReal(true only for RunPod).- The readiness check runs the worker's
healthop through the sameRunPodClienttransport that serves real OCR/translation, so it is pod/serverless-agnostic — identical behaviour on the dev persistent pod and a production serverless endpoint (onlyBaseUrl/EndpointIddiffer; seedocs/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 & route | Body | Returns |
|---|---|---|
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 |
|---|---|---|
card | savedCardId | Charges that saved card in-app — credit lands immediately (confirmed). |
card | — | Returns a hosted Stripe Checkout checkoutUrl (pending); completable headlessly. |
crypto | coin? | Returns a hosted CoinPayments checkoutUrl (pending) for the chosen coin. |
manual | — | Credits 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 page —
method=cardwith nosavedCardIdreturns a hostedcheckoutUrlthe user opens once. After a card is saved, charging it is pure API: pass itssavedCardId(fromGET /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). Amanualrequest on a production deployment is rejectedvalidation(400) — a public caller can never mint free credit. Pay bycardorcryptoinstead.- 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.
statusis lowercase (confirmed/pending/failed/cancelled/expired);methodismanual/card/crypto.methoddefaults tomanualwhen omitted.lowBalanceistrueonce 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 withinsufficient_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 a402'd batch can resume. Single-use per owner; an unknown or already-redeemed code isvalidation(400). - Errors: out-of-bounds amount / unknown method /
manualin 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 asAuthorization: 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_…orX-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 & route | Body | Returns |
|---|---|---|
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_requiredon: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), andPOST /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 & route | Body | Returns |
|---|---|---|
POST /v1/me/change-password | { currentPassword, newPassword } | 200 { changed: true, token } |
- Interactive-only (§7). An API-key principal is rejected with
403 interactive_auth_requiredbefore any password check; an interactive JWT is accepted. - Current-password gated. The
currentPasswordis verified against the stored hash — a mismatch is403 password_incorrectand nothing is persisted (mirrors theDELETE /v1/meconfirmation). - New-password rules.
newPasswordmust meet the minimum-strength rule and differ from the current one; either failure is400 validationwith 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
429with 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
tokenfor 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 & route | Body | Returns |
|---|---|---|
POST /v1/me/set-password | { newPassword } | 200 { changed: true, token } |
- Interactive-only (§7). An API-key principal is rejected with
403 interactive_auth_requiredbefore any work; an interactive JWT is accepted. - No current password. There is none to confirm — the interactive session is the authorization.
- New-password rule.
newPasswordmust meet the minimum-strength rule; a too-weak password is400 validationwith 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
tokenfor the acting session — swap your stored JWT for it so the device that added the password stays signed in. A subsequentGET /v1/methen reportshasPassword = 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
429with 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 & route | Auth | Body | Returns |
|---|---|---|---|
POST /v1/me/change-email | JWT only | { newEmail, password } | 202 { pending: true } — login email unchanged |
POST /v1/auth/confirm-email-change | anonymous (token) | { token } | 200 { changed: true } — email flips now |
POST /v1/auth/cancel-email-change | anonymous (token) | { token } | 200 { cancelled } — old-address kill switch |
POST /v1/me/change-email/cancel | JWT/key | — | 200 { cancelled } — in-app cancel |
- Initiate (
POST /v1/me/change-email). Interactive-only (§7 — an API key is403 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-leaky409 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,EmailVerifiedis 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_takenis re-checked at confirm time — if the address was registered elsewhere in the interim,409 email_takenand the change is not applied. An unknown / used / expired token is a non-leaky400 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 →
429with 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 returns401on its next call.Acting-session continuity. The one flow with a live JWT session — change-password — returns a fresh
tokencarrying 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 nostampand 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-secretprefixis 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) andprojects:read(read-only); a create request for any other token (including the retiredapprovescope) is rejected withvalidation(400). A revoked or expired key is401(distinct from a401for 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
approvescope had nothing left to guard and was removed in #2073;projects:read/projects:writeare 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.
- 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
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 & route | Body | Returns |
|---|---|---|
POST /v1/auth/oauth/{provider} | { credential } | { token, user } — the same JWT + account shape as /auth/login |
{provider}isgoogleorapple.credentialis 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
uidmodel (anExternalLoginrow links(provider, subject)→ anAppUser): 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 publicgoogleOAuthClientId/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 endpoint400s it.BookTranslation:Auth:OAuth:UseFakeVerifierwires a deterministic in-process verifier for dev/tests (the OAuth analogue of the Stripe/CoinPaymentsUseFakeGateway), 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.
| Surface | Keyed by | Default | Config key (BookTranslation:RateLimits:…) |
|---|---|---|---|
Authenticated default (the whole /v1 authed surface) | uid | 300 / min | DefaultPerMinute |
Billable processing (/projects/{id}/process, /process-next-batch) | uid | 20 / min | BillableProcessingPerMinute |
Status/event poll (…/status, …/events) | uid | 120 / min | StatusPollPerMinute |
Webhook management (/v1/webhooks…) | uid | 30 / min | WebhookAdminPerMinute |
Anonymous estimate (/v1/anonymous-estimate) | caller IP | 10 / min | AnonymousEstimate:RateLimitPerMinute |
| Anonymous page image (picker thumbnails) | caller IP | 600 / min | AnonymousEstimate: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.