---
title: "publica.la API authentication"
description: "The anonymous tier needs no credentials; the keyed tier uses bearer keys issued by a person. Discover, register, claim, use, errors, revocation."
canonical: "https://publica.la/auth.md"
last-updated: "2026-08-27"
---

# Authentication

There are two tiers on this site and only one of them has credentials.

- **Anonymous.** The MCP server at https://publica.la/mcp and the five
  `GET /api/v1/site/*` routes. No key, no header, no account, no OAuth — 60
  requests per minute per IP on the MCP server, 300 on the site API. This is the
  tier an agent can use with no human in the loop, and it covers everything
  public on this website: posts, changelog, plans, platform overview.
- **Keyed.** The rest of `/api/v1` — publishing blog posts, newsletter campaigns,
  subscribers, suppressions, booking pages. A bearer key, issued by a person.

**There is no OAuth server, no dynamic client registration and no self-serve key
endpoint.** Anything that says otherwise is wrong. The sections below are honest
about which steps a machine can take and which need an email.

## Discover

Nothing has to be known in advance but the hostname.

```
curl -sS https://publica.la/.well-known/api-catalog     # RFC 9727 linkset
curl -sS https://publica.la/api/v1                      # discovery document
curl -sS https://publica.la/openapi.json                # OpenAPI 3.1
curl -sS https://publica.la/.well-known/ard.json         # agentic resources
curl -sS https://publica.la/.well-known/mcp.json         # MCP manifest
```

The discovery document lists the anonymous endpoints, the MCP server, the
idempotency contract and every agent document. Every successful GET on this site
also carries `Link` headers with `rel="service-desc"`, `rel="api-catalog"`,
`rel="ard"`, `rel="llms-txt"` and `rel="sitemap"`, so a client that only ever
looks at headers still finds all of it.

No credential is needed for any of the above, and none is needed for the
anonymous tier at all. If the anonymous tier answers your question, stop here.

## Register

Registration is a message to a person. Write to **hello@publica.la** from an
address you control and say:

- who the caller is (the product or service name, and the organisation behind it);
- what it will call — the operations, by `operationId` from
  https://publica.la/openapi.json;
- which scopes you are asking for and why;
- where the key will be stored.

The scopes that exist: `content.read`, `content.write`, `newsletter.read`,
`newsletter.write`, `newsletter.send`, `subscribers.write`, `suppressions.write`.
A key carries exactly the scopes it was minted with. Two of them —
`newsletter.send` and `suppressions.write` — are irreversible in effect and are
never granted as part of a bundle; they have to be asked for by name.

**There is no endpoint that does this.** No `POST /register`, no client_id, no
client_secret, no device flow. A key is minted by the publica.la team with an
internal command and handed back to you.

## Claim

The key arrives from the publica.la team as a single string with an `rnd_` prefix.

- **It is shown once.** Only a sha256 of it is stored here, so nobody at
  publica.la can read it back to you. A lost key is replaced, not recovered.
- Store it as a secret in your environment, never in a repository or a prompt.
- There is nothing to activate and nothing to exchange: the string you were sent
  *is* the credential. No token endpoint, no refresh token, no expiry.

Prove it works with a read before you write anything:

```
curl -sS -i https://publica.la/api/v1/newsletter/stats \
  -H 'Authorization: Bearer rnd_…'
```

A 200 means the key is live. A 403 means the key is live but was not given that
scope — which is an email, not a code change.

## Use

```
Authorization: Bearer rnd_…
Content-Type: application/json
Accept: application/json
```

- Base URL: `https://publica.la/api/v1`. HTTPS only.
- 300 requests per minute per key. Responses carry `RateLimit-Policy` and
  `RateLimit`; a 429 carries `Retry-After`.
- Writes (`POST`, `PUT`, `PATCH`, `DELETE`) accept an `Idempotency-Key` header:
  1–255 characters, your choice, one per logical operation. Repeat the same key
  with the same body within 24 hours and the first response is replayed with
  `Idempotency-Replayed: true`. Reuse it with a *different* body and the answer is
  422 `idempotency_key_reused` — which is the guard working: a fresh key on a
  retry is how a delivered write becomes two.
- Never send the key to the anonymous routes or the MCP server. They ignore it,
  and a credential sent where it is not needed is a credential in one more log.

## Errors

Every `/api/*` failure is JSON, whatever the request asked for: a top-level
`message` plus the RFC 9457 members `type`, `title`, `status`, `detail`, `code`,
`hint` and `docs`. **Branch on `code`** — a title can be reworded, a code cannot.

| Status | `code` | What it means | What to do |
|---|---|---|---|
| 400 | `bad_request` | The request does not match the description | Re-read https://publica.la/openapi.json |
| 401 | `unauthenticated` | No key, or a key this site does not know | Check the `Authorization` header; a 401 also carries `WWW-Authenticate: Bearer` |
| 403 | `forbidden` | The key is valid and lacks the scope | Ask for the scope by email. Do not retry |
| 404 | `not_found` | No such path or no such record | Check the path against the OpenAPI description |
| 405 | `method_not_allowed` | Wrong verb | Use one from the `Allow` header |
| 422 | `validation_failed` | Body rejected | Read `errors` for the failing fields |
| 422 | `idempotency_key_reused` | Same `Idempotency-Key`, different body | Mint a new key for a genuinely new operation |
| 429 | `rate_limited` | Over the limit | Sleep `Retry-After` seconds, then retry |
| 500 | `server_error` | Ours | Exponential backoff; then support@publica.la with the timestamp |
| 503 | `service_unavailable` | Ours, briefly | Retry after a pause; https://status.publica.la/ says whether it is us |

A 401 and a 403 mean different things on purpose. A refusal for a missing scope is
**never** a 401: the bearer is fine, the answer is "not for this", and sending an
integrator off to check credentials that work wastes a day.

## Revocation

- **You cannot revoke your own key over HTTP. There is no endpoint for it, and
  this document will not pretend there is.** Write to hello@publica.la — or, if
  the key may have leaked, **security@publica.la** — naming the key's consumer
  name. Say "revoke" for a stop and "rotate" to keep the scopes with a new
  string.
- Revocation is immediate on the next request: the key stops authenticating and
  every call answers 401 `unauthenticated`.
- **The record survives the key.** A revoked key keeps its row, its scopes and its
  `last_used_at`, because the question an incident asks is "what could this reach,
  and when did it last do so" — and a deleted row answers nothing.
- Rotating replaces the string and keeps the scopes. The old string stops working
  the moment the new one is minted, so deploy the new value first.
- Nothing here expires on its own. A key you have stopped using is a key to
  revoke, not one to wait out.
