If your product summarizes, extracts, or answers questions well, some of your users will want to call it from their own code — a script, a backend, another SaaS. That’s a second revenue stream sitting behind the feature you already built. The only thing between the two is authentication: dashboards have sessions, APIs have keys. This boilerplate ships the key half as one module, and the result is that any AI feature becomes a resellable API in an afternoon.
Keys you can’t leak from the database
A key looks like sk_ followed by 48 hex characters — 24 bytes of randomness. The plaintext exists exactly once, in the response that creates it; after that the database holds only a SHA-256 hash and an 11-character display prefix (sk_a1b2c3d4…) so users can tell their keys apart. A database leak leaks hashes, not keys.
Deliberately, the hash is plain unsalted SHA-256 — and that’s not an oversight. Salting and slow hashing exist to protect low-entropy secrets like passwords from being brute-forced. A 24-byte random key can’t be brute-forced from its hash, and a deterministic hash is what makes authentication a single indexed lookup:
hash(incoming key) → one WHERE clause → the owning userNo table scan, no bcrypt comparison loop over every stored key — O(1) auth on every API request.
Revoke, don’t delete
Revoking a key sets a revokedAt timestamp instead of deleting the row. The lookup filters revoked keys out, so a revoked key stops working instantly — but the row stays as an audit trail, and a lastUsedAt column (updated after the response, never blocking the request) tells users which keys are actually dead before they revoke them. A cap of 20 active keys per user keeps the table bounded.
The route: swap one step of the recipe
Every billable endpoint in this codebase follows the same five steps: authenticate, rate limit, pre-check credits, call the model, meter actual usage. The public API at /api/v1/summarize is that exact recipe with step 1 swapped — instead of reading a session cookie, it resolves Authorization: Bearer sk_... through the apikeys module and gets back a user id. Everything downstream (credits, rate limits, the ledger) already works per-user, so it doesn’t care where the user id came from.
curl -X POST https://your-app/api/v1/summarize \
-H "Authorization: Bearer sk_..." \
-H "Content-Type: application/json" \
-d '{"text":"..."}'The status codes are the consumer contract, and they mean exactly one thing each:
401— invalid or missing key429— rate limit exceeded (30 requests/minute per user)402— out of credits, before any money is spent on the model413— input too large for the metering model to safely absorb
The success response returns the summary plus creditsSpent and the remaining balance — so consumers can meter themselves instead of discovering a 402 the hard way.
Why this took an afternoon
The route calls the same summarizeText function the dashboard uses. Because the logic lives in a module and the routes are thin, exposing it publicly was a wiring change: one auth swap, one new route file. That’s the real argument for the architecture — point your agent at /api/v1/summarize and ask it to expose your RAG or extraction feature the same way, and the diff will be just as small.