golemkit
← All posts

June 24, 2026 · 5 min read

Selling your AI as an API: metered keys in one module

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 user

No 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 key
  • 429 — rate limit exceeded (30 requests/minute per user)
  • 402 — out of credits, before any money is spent on the model
  • 413 — 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.