unlob Docs
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.json
  • GET /mcp/tools.json
  • GET /llms.txt
  • GET /guides/{slug}
  • GET /describe — the live capability catalog for this deployment
  • GET /healthz, GET /readyz, GET /metrics

Everything else needs a key.

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, or past a hard-capped monthly quota
408The 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