Browse documentation
Authentication
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.jsonGET /mcp/tools.jsonGET /llms.txtGET /guides/{slug}GET /describe— the live capability catalog for this deploymentGET /healthz,GET /readyz,GET /metrics
Everything else needs a key.
Failure modes worth distinguishing
| Status | What actually happened |
|---|---|
401 | No key, an unknown key, a revoked key — or a key so new it has not propagated yet |
402 | The account is suspended for billing |
429 | Over the per-minute rate, or past a hard-capped monthly quota |
408 | The request exceeded the server timeout |
401 deliberately does not distinguish “wrong key” from “revoked key”, so probing tells an
attacker nothing.
429 covering two unrelated conditions is worth internalising: one is retryable in a
second, the other will not change until next month. Rate limits and
quotas 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
- Rate limits and quotas
- Errors — every status, and what to do about it
- Retries and backoff