unlob Docs
Browse documentation

Authentication: unlob API keys and headers

One key, two header spellings, and the minute after you mint it.

Keys

A key identifies a tenant, not a person. Create, rotate and revoke them in the console.

The format is ulb_ followed by 48 hex characters. Only a hash is stored, so the plaintext is shown exactly once, at creation. A key you did not copy cannot be recovered — rotate it and take the new one.

Label every key. The label and the last four characters (ulb_…a91f) are the only things distinguishing two keys in a listing, and a listing can never show you a usable credential.

Both header spellings work

Everywhere, on REST and on MCP:

-H "x-api-key: ulb_…"
-H "Authorization: Bearer ulb_…"

x-api-key wins if you send both. The Bearer prefix is case-sensitive and needs its trailing space — bearer ulb_… is not accepted.

A new key is not live immediately

This is the one behaviour that will confuse you at least once.

The serving fleet authenticates against an in-memory snapshot of the key registry, refreshed on a short cycle — the database is in the reload path, not the request path, which is what keeps authentication off the critical path of every search. The consequence is that a key created seconds ago can answer 401 until the next refresh, typically well under a minute.

So: if a key that has never worked returns 401, wait and retry before concluding it is wrong. If a key that worked yesterday starts returning 401, that is a different problem — check whether it was revoked or the account was suspended.

The console’s own playground retries 401 for up to forty seconds after minting a key, for exactly this reason.

MCP

The same key, presented the same way, on every request:

curl https://api.unlob.com/mcp \
  -H "x-api-key: $UNLOB_API_KEY" \
  -H "content-type: application/json" \
  -H "accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize", …}'

An MCP session never pins an authorization. The transport is stateless, so each request re-presents its key and any replica can serve it — there is no “logged-in session” to expire, and no sticky routing to arrange.

Over stdio, there are no headers. That server is trusted by whoever spawned it; a self-hosted deployment can point it at a key store and set UNLOB_API_KEY in the environment instead.

What needs no key at all

The descriptions of the API are open, because an agent has to be able to learn what a service does before deciding whether to sign up for it:

  • GET /openapi.json
  • GET /mcp/tools.json
  • GET /llms.txt
  • GET /guides/{slug}
  • GET /describe — the live capability catalog for this deployment
  • GET /skill.md — the Agent Skill
  • GET /healthz, GET /readyz

Everything else needs a key.

Open does not mean unlimited. These cost no credits, but a flood of unauthenticated requests from one address is refused with a 429 and a retry-after — and so is a run of calls presenting a key that does not authenticate, since a failed authentication cannot be charged to an account. Reading the descriptions once at startup is nowhere near it.

Failure modes worth distinguishing

StatusWhat actually happened
401No key, an unknown key, a revoked key — or a key so new it has not propagated yet
402The account is suspended for billing
429Over the per-minute rate, a hard-capped key without the credits the call costs, or too many unauthenticated calls from your address
408The request exceeded the server timeout

401 deliberately does not distinguish “wrong key” from “revoked key”, so probing tells an attacker nothing.

429 covering unrelated conditions is worth internalising: the rate-limit one is retryable after the number of seconds its retry-after names, and the out-of-credits one will not change until next month. Rate limits and credits explains how to tell them apart.

Rotating

Rotation issues a new key and revokes the old one, so there is no window where both work. Plan for the gap: put the new key in place, wait out the propagation cycle, then cut over.

Next