Skip to content

Authentication

Every /v1 route that touches network data requires a credential — including reads. /v1/meta/*, /health and / are open and leak nothing that /openapi.json does not already publish.

There are two credential types. There will never be a third.

API keyStream ticket
Looks likerelay_…relay_st_…
Sent asAuthorization: Bearer <key>?ticket= on /v1/stream only
Lifetimeuntil revoked300 s
Can publishyesno
Can read /v1/eventsyesno
Where it belongsyour servera browser URL
Issued byan org owner, in the Console (TOTP step-up if enrolled)POST /v1/stream/tickets, from your server

The API key

curl -H "Authorization: Bearer $RELAY_KEY" "$RELAY/v1/events?lat=48.87&lon=2.31&radius=1000"

This is the credential for every route and every integrator. An embedded C++ head unit is the stated integrator profile: it can set a header, and it must never be pushed through a browser-shaped workaround.

Keys are stored as a SHA-256 hash. We never hold the plaintext after issuance, exactly like a password — a database dump must not hand an attacker the ability to publish as you.

resolve_org maps the key to your org_id server-side. Your organisation is never read from a payload, which is what makes the trust model unforgeable.

Getting one

Sign in to the Relay Console, create or join an organisation, and issue the key yourself. Issuance is an owner act, every other owner is emailed within seconds, and the raw key is shown once — only its hash is stored, so it has to reach you rather than a log. If your own account has enrolled a TOTP authenticator, issuance asks for a code at that moment — a step-up, not the sign-in from thirty days ago. The check is per-account, not per- organisation: an owner who has not enrolled is not asked, whatever their colleagues have set up. If it has not, issuance is protected by the session alone — enrolment is not required before your first key, and enrolling upgrades every future issuance to a step-up. Revocation is self-service from the same page. Full detail is in Getting started. An operator CLI still covers the keys that have no human owner, such as the system organisation behind ingested feeds.

Live keys are capped per plan — the Free plan allows one — and issuing past the cap is refused with a 409 at issuance: revoke a key or change plan. Sandbox keys are uncapped, deliberately — nothing should ration testing. This is the one plan limit enforced by refusal, and it is because it bounds credentials rather than traffic: an unbounded population of forever-lived keys is a blast radius nobody audits.

Failure modes

Both carry WWW-Authenticate: Bearer, and a detail string that says which of the two it was:

// 401 — no Authorization header
{"detail": "missing API key — send `Authorization: Bearer <key>`"}
 
// 401 — unknown or revoked key
{"detail": "invalid or revoked API key"}

The stream ticket

EventSource cannot set request headers. That is not a gap in the browser API, it is the API — the constructor takes a URL and nothing else. So a browser subscriber cannot send Authorization: Bearer, and something has to travel in the URL.

What travels is a ticket, not your key.

your server (holds the key)              the browser
──────────────────────────────           ─────────────────────────────────────
POST /v1/stream/tickets
  Authorization: Bearer <key>
  201 {ticket, expires_at,
       expires_in: 300}       ────────>  new EventSource(
                                           `${RELAY}/v1/stream?lat=…&ticket=${ticket}`)

Minting requires the long-lived key, which is exactly why this call belongs on your server. The key stays there. Only the ticket reaches the browser.

curl -sX POST "$RELAY/v1/stream/tickets" -H "Authorization: Bearer $RELAY_KEY"
{
  "ticket": "relay_st_yT2SnauGLXUFgcc7Yu9ABfVljADFSXJy",
  "expires_at": "2026-08-03T13:22:36.026993Z",
  "expires_in": 300
}

expires_in is the simpler field to use. expires_at is serialised exactly like every event timestamp — UTC, Z suffix. (It briefly carried a +00:00 offset instead; that divergence was removed before anyone could pin a parser to it, which is why a real RFC 3339 parser beats a fixed format string.)

Never put your API key in a URL

The obvious alternative is ?key=<api key>. That writes a permanent, publish-capable credential into request logs, log sinks, proxy logs, browser history and Referer headers — and it forces your web application to ship that credential to the browser in the first place. The worst a leaked ticket URL yields is five minutes of read-only streaming access. Note what that does not say: a ticket binds the organisation and the environment, not the disclat/lon/radius are the connector's own query parameters, so a leaked ticket serves whatever area its holder asks for until it expires.

The four ticket rules

They are on the wire, so they are permanent.

  1. It authorises the connect, not the connection. Expiry is checked once, when the request arrives. An open stream is never cut mid-flight because its ticket aged out — a connection lives up to 55 minutes on a 300-second ticket.
  2. The header wins. If Authorization is present it decides, and a bad header is a 401 even when a valid ?ticket= is also in the URL. Authentication that silently repairs itself from a second credential is authentication nobody can reason about.
  3. It is not single-use. A reconnect inside the 300-second window is legitimate.
  4. bye means mint again. This is the one that costs you code — see below.

The reconnect trap, for browsers only

We close every stream on schedule (max_seconds, currently 3300). By then the ticket that opened it is long expired. EventSource retries the same URL on its own, gets a 401, and — per the SSE spec, where a non-200 is a fatal error rather than a retryable one — stops for good. No exception, no further error events, no reconnect. Your map goes quiet and stays quiet.

So a browser client must handle error (and the bye frame) by fetching a new ticket and constructing a new EventSource:

async function connect(lat, lon, radius) {
  const { ticket } = await fetch("/api/relay-ticket", { method: "POST" }).then(r => r.json());
  const url = new URL(`${RELAY}/v1/stream`);
  url.search = new URLSearchParams({ lat, lon, radius, ticket });
 
  const es = new EventSource(url);
 
  // A non-200 is fatal to EventSource. Tear it down and mint a fresh ticket.
  es.onerror = () => { es.close(); setTimeout(() => connect(lat, lon, radius), 2000); };
  es.addEventListener("bye", () => { es.close(); connect(lat, lon, radius); });
 
  return es;
}

/api/relay-ticket above is your endpoint, on your server, holding your key and calling POST /v1/stream/tickets.

A bearer-authenticated client (anything that is not a browser) does not have this problem: its credential does not expire, so SSE's own automatic reconnect works. We keep the asymmetry on purpose — renewing a read credential from inside the stream would make revocation inoperative, and the trip back to the key holder every 55 minutes is exactly where a revoked key stops reading.

CORS

The API sends permissive CORS headers by default (*), and allows GET, POST and OPTIONS. It is configurable per deployment, so confirm against the host you were given rather than assuming.

The boundaries of the credential model

Three properties to design around:

  • Streams and tickets are unrationed per key. One key can open streams and mint tickets in a loop — usage is metered, not refused; see below.
  • A sandbox key can READ live events. That is the design — an isolated sandbox with no traffic in it is one you cannot test a filter against — but it means environment isolation is a write boundary, not a confidentiality one. It stops sandbox events reaching live consumers, and it stops a sandbox key voting on a live event (403). It does not hide live data from anyone holding any key.
  • Consumption is metered, allowances are settled commercially. Every read, publish, signal and stream connect is counted per organisation, per environment, per day — the seam the bill hangs off. No request is refused for being over a plan: there are no quotas, and the one 429 on /v1 is the hourly per-organisation signal ceiling — a guard on the trust model, not a plan limit. See Errors and limits.