# Caprid docs > Publish and subscribe to live tracks over Media over QUIC with Relay, and manage who may do what with Switchboard. URL: https://www.caprid.com/docs/ ## Start here - [Overview](https://www.caprid.com/docs/getting-started/): How a publisher, a viewer and your backend fit together. - [Quickstart](https://www.caprid.com/docs/getting-started/quickstart/): From an API key to an object published and received, in five steps. - [Core concepts](https://www.caprid.com/docs/getting-started/concepts/): Organizations, Namespaces, credentials, tracks and relays. - [Build with AI agents](https://www.caprid.com/docs/agents/): llms.txt, Markdown for every page, and an agent skill. ## Switchboard - [Organizations and Namespaces](https://www.caprid.com/docs/guides/organizations/): Who owns what, and how to manage Namespaces, Members and Invitations. - [Credentials](https://www.caprid.com/docs/guides/credentials/): API keys, Signing keys, Client tokens, and which goes where. - [Prod and staging](https://www.caprid.com/docs/guides/prod-and-staging/): Keep staging in its own Namespace, with a key that can't reach prod. - [Self-hosted relays](https://www.caprid.com/docs/guides/self-hosted-relays/): Run moq-relay yourself and enroll it in your Organization. - [AI agents](https://www.caprid.com/docs/guides/agents/): Let an AI agent start a Switchboard trial on its own, then claim it to keep what it built. ## SDKs - [TypeScript](https://www.caprid.com/docs/sdks/typescript/): `@caprid/relay` for browsers over WebTransport, and Node over native QUIC. - [Python](https://www.caprid.com/docs/sdks/python/): `caprid-relay` for asyncio, over native QUIC. - [SDK specification](https://www.caprid.com/docs/spec/): The behavior contract both SDKs implement. ## Reference - [Account API](https://www.caprid.com/docs/reference/http/account/): Organizations, Members, Namespaces, keys, Client tokens and relays. - [Relay plane API](https://www.caprid.com/docs/reference/http/relay/): Enrollment, peer discovery and admission, as a relay calls them. - [TypeScript API](https://www.caprid.com/docs/reference/typescript/readme/): Every export of `@caprid/relay` and its companion packages. - [Python API](https://www.caprid.com/docs/reference/python/caprid_relay/): Every public name in `caprid_relay`, `moq_core` and `moq_media`. --- # Overview > What Relay and Switchboard are, and how a publisher, a viewer and your backend fit together. URL: https://www.caprid.com/docs/getting-started/ Caprid is two services and the SDKs that talk to them. - **Relay** is Mux's Media over QUIC (MoQ) relay network. Clients connect to a relay to publish tracks and to subscribe to them. A track is a sequence of objects, and the relays carry them from a publisher to every subscriber, across regions. The payloads are bytes the relay doesn't interpret, so a track can carry video, audio, captions, chat, or game state. - **Switchboard** is the control plane. It owns your Organization and its Namespaces, issues the keys your backend signs Client tokens with, and decides, for every session on every relay, whether it may start and what it may do. - **The Relay SDKs** connect your code to a relay: [`@caprid/relay`](https://www.caprid.com/docs/sdks/typescript/) for browsers and Node, and [`caprid-relay`](https://www.caprid.com/docs/sdks/python/) for Python asyncio. They share one [specification](https://www.caprid.com/docs/spec/), so both behave the same way. ## How the pieces fit ``` ┌──────────────── Switchboard ────────────────┐ your backend ──┤ API key: manage Namespaces, Signing keys │ │ Admission: may this session start? │ └──────────────────────▲──────────────────────┘ │ publisher ──── Client token ──────► relay ◄────── Client token ──── viewer (SDK) (Relay) (SDK) ``` 1. Your **backend** holds an [API key](https://www.caprid.com/docs/guides/credentials/#api-keys) and a [Signing key](https://www.caprid.com/docs/guides/credentials/#signing-keys). It never ships either to a browser or app. 2. For each publisher or viewer, your backend mints a short-lived [Client token](https://www.caprid.com/docs/guides/credentials/#client-tokens): a JWT saying which paths under your Namespace it may publish to (`put`) and subscribe to (`get`). 3. The **Client** (your web page, app, or service, using an SDK) connects to a relay with that token. The relay asks Switchboard to admit the session, then enforces the token's grant on every request. 4. Publishers announce a namespace and write objects to tracks; subscribers subscribe to tracks and receive the objects. The SDK guides call the token a "Relay JWT". It's the same Client token: the SDKs consume it, and Switchboard (or your own backend, with a Signing key) issues it. ## Pick a path - **Try it end to end.** The [Quickstart](https://www.caprid.com/docs/getting-started/quickstart/) goes from an API key to an object published and received. - **Wire up your backend.** [Credentials](https://www.caprid.com/docs/guides/credentials/) covers every key and token, and [Prod and staging](https://www.caprid.com/docs/guides/prod-and-staging/) how to keep them apart. The [Account API reference](https://www.caprid.com/docs/reference/http/account/) has every route. - **Build a client.** Start with the [TypeScript guide](https://www.caprid.com/docs/sdks/typescript/guide/) or the [Python guide](https://www.caprid.com/docs/sdks/python/guide/). - **Run your own relays.** [Running a Self-hosted relay](https://www.caprid.com/docs/guides/self-hosted-relays/) enrolls `moq-relay` on your infrastructure. - **Working with an AI agent?** [Build with AI agents](https://www.caprid.com/docs/agents/) lists the Markdown, `llms.txt` and OpenAPI entry points, and the rules an agent should follow. ## Status The SDKs are before 1.0. Install them from npm (`@caprid/relay`) and PyPI (`caprid-relay`). Each SDK's packages release together at one version, so install matching versions. Before 1.0, a minor version can break the API. The [TypeScript](https://www.caprid.com/docs/sdks/typescript/changelog/) and [Python](https://www.caprid.com/docs/sdks/python/changelog/) changelogs say what changed. --- # Quickstart > From an API key to an object published and received through Relay, in five steps. URL: https://www.caprid.com/docs/getting-started/quickstart/ This takes you from a new Organization to one object published and received through a relay. You'll use `curl` and `jq` to talk to Switchboard, and the SDK repository's example to talk to the relay. The examples use the Namespace `acme`. Use yours wherever you see it. ## 1. Get an API key Sign in to the dashboard at [app.caprid.com/app](https://app.caprid.com/app/). Signing up creates an Organization with the Namespace you choose, and shows its first API key once. Keep it in your shell, and in your secret manager after that: ```bash read -rs -p 'Switchboard API key: ' SB_API_KEY && export SB_API_KEY curl https://app.caprid.com/v1/account -H "Authorization: Bearer $SB_API_KEY" ``` The API key manages your Organization. Relays never accept it, but it can mint tokens that they do, so treat it like a password and keep it on your servers. See [API keys](https://www.caprid.com/docs/guides/credentials/#api-keys). ## 2. Create a Signing key Client tokens are signed with one of your Namespace's Signing keys. Create one: ```bash curl -X POST https://app.caprid.com/v1/namespaces/acme/keys \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"label": "backend"}' ``` The response is a JWK with `kid` `acme_backend`. Its `k` is the secret your backend will sign tokens with in production; for now, Switchboard can sign them for you. See [Signing keys](https://www.caprid.com/docs/guides/credentials/#signing-keys) and [`createSigningKey`](https://www.caprid.com/docs/reference/http/account/operations/createsigningkey/). ## 3. Mint a Client token Ask for a token rooted at `acme/quickstart` that may both publish and subscribe beneath it. An empty prefix (`""`) means everything under the root: ```bash export RELAY_TOKEN=$(curl -s -X POST https://app.caprid.com/v1/namespaces/acme/tokens \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"path": "quickstart", "publish": "", "subscribe": ""}' | jq -r .token) ``` Paths you send to the API are relative to the Namespace, so the token's `root` claim is `acme/quickstart`. It expires in an hour. See [`mintClientToken`](https://www.caprid.com/docs/reference/http/account/operations/mintclienttoken/), and check a token with [`verifyClientToken`](https://www.caprid.com/docs/reference/http/account/operations/verifyclienttoken/). ## 4. Publish and receive one object The first round trip runs the example in the [SDK repository](https://github.com/caprids/relay-sdks). You need Git and [mise](https://mise.jdx.dev/getting-started.html). The example reads `RELAY_TOKEN` from the environment, so it doesn't prompt. `--room` is your token's root. The example connects to `https://relay.mux.global/acme/quickstart`, and the namespace you pass is relative to that root. Pick a fresh namespace for every run: **TypeScript (Node)** ```bash git clone https://github.com/caprids/relay-sdks.git && cd relay-sdks mise install && mise run ts:install mise run ts:example:mux-global -- \ --room acme/quickstart \ --namespace "run/$(date +%s)" --track ticker --count 1 --timeout 30 ``` The task builds the native QUIC addon for Node from source, which needs Rust. mise brings the pinned toolchain. Installing from npm needs no Rust. **Python** ```bash git clone https://github.com/caprids/relay-sdks.git && cd relay-sdks mise install && mise run python:install mise run python:example:mux-global -- \ --room acme/quickstart \ --namespace "run/$(date +%s)" --track ticker --count 1 --timeout 30 ``` The demo publishes one object on track `ticker` and subscribes to it. The run succeeded when it prints: ```text finished published=1 received=1 ``` If it doesn't, see "If the run fails" in the [TypeScript](https://www.caprid.com/docs/sdks/typescript/guide/#if-the-run-fails) or [Python](https://www.caprid.com/docs/sdks/python/guide/#if-the-run-fails) guide. ## 5. Do it from your own code The same round trip, with the SDK in your own project. `roomEndpoint` puts the token's root in the URL of the hosted Relay, so the connection can't reach outside it, and the namespace is relative to that root. **TypeScript** Install the SDK and its native QUIC transport for Node 24 or later: ```bash npm install @caprid/relay @caprid/relay-quic ``` ```ts const url = roomEndpoint(['acme', 'quickstart']); const namespace = ['run', String(Date.now())]; const getToken = () => process.env.RELAY_TOKEN; const publisher = await connectRelayPublisher({ url, namespace, getToken }); const ticker = publisher.registerTrack({ track: 'ticker' }); await publisher.announced; const subscriber = await connectRelaySubscriber({ url, getToken }); const subscription = subscriber.subscribe( { namespace, track: 'ticker' }, { onObject: (object) => { if (object.status === 'normal') console.log(readText(object)); } }, ); await subscription.subscribed; // a fresh namespace can briefly reject this with error 16; retry it await ticker.writeText('hello from Node'); // waits for the subscriber's demand ``` In a browser, import from `@caprid/relay` instead, which connects over WebTransport and needs no `@caprid/relay-quic`. See [Install](https://www.caprid.com/docs/sdks/typescript/#install). **Python** Install the SDK: ```bash uv add caprid-relay ``` ```python from caprid_relay import ( MoqtObject, RelaySubscriptionHandlers, connect_relay_publisher, connect_relay_subscriber, read_text, room_endpoint, ) async def main() -> None: token = os.environ["RELAY_TOKEN"] url = room_endpoint(["acme", "quickstart"]) namespace = ["run", str(int(time.time()))] arrived: asyncio.Future[str] = asyncio.get_running_loop().create_future() def on_object(obj: MoqtObject) -> None: if obj.status == "normal" and not arrived.done(): arrived.set_result(read_text(obj)) async with ( await connect_relay_publisher(url, namespace=namespace, get_token=lambda: token) as publisher, await connect_relay_subscriber(url, get_token=lambda: token) as subscriber, ): ticker = publisher.register_track("ticker") await publisher.announced subscription = subscriber.subscribe(namespace, "ticker", RelaySubscriptionHandlers(on_object=on_object)) await subscription.subscribed # a fresh namespace can briefly reject this with error 16; retry it await ticker.write_text("hello from caprid-relay") # waits for the subscriber's demand print(await arrived) asyncio.run(main()) ``` Run it with `uv run your_script.py`. See [Use the local packages in your application](https://www.caprid.com/docs/sdks/python/guide/#use-the-local-packages-in-your-application). These connections never reconnect. For a live application, use the supervised connections, which reconnect with backoff and keep tracks registered and subscribed ([TypeScript](https://www.caprid.com/docs/sdks/typescript/guide/#stay-connected-supervised-connections), [Python](https://www.caprid.com/docs/sdks/python/guide/#stay-connected-supervised-connections)). ## Next steps - **Sign tokens in your backend.** Minting through the API is fine for testing. In production, sign them yourself with the Signing key, which saves a round trip per viewer and keeps working if Switchboard is unreachable: [Sign your own](https://www.caprid.com/docs/guides/credentials/#sign-your-own). - **Give each Client only what it needs.** A viewer's token should only `subscribe`, and only under its room: [Client tokens](https://www.caprid.com/docs/guides/credentials/#client-tokens). - **Keep staging apart from prod.** [Prod and staging](https://www.caprid.com/docs/guides/prod-and-staging/). - **Handle failures.** Errors have stable codes and phases: [TypeScript](https://www.caprid.com/docs/sdks/typescript/guide/#errors-and-recovery), [Python](https://www.caprid.com/docs/sdks/python/guide/#errors-and-recovery). --- # Core concepts > Organizations, Namespaces, credentials, paths, tracks and relays, on one page. URL: https://www.caprid.com/docs/getting-started/concepts/ The words below are used precisely everywhere in these docs. Each section links to where the concept is defined in full. ## Who owns what - **Organization**: A customer of Switchboard. It owns Namespaces, API keys and Self-hosted relays. People manage it as its **Members**, each signed in with their own **Account**. **Owners** can also delete Namespaces, invite people, remove Members, and change who is an Owner. - **Namespace**: The first segment of every path you can sign for, owned by one Organization for good. If your Namespace is `acme`, everything you publish lives under `acme/`. The name is never reissued, even after it's deleted. Use one per environment: `acme` for prod, `acme-staging` for staging. See [Organizations and Namespaces](https://www.caprid.com/docs/guides/organizations/) and [Prod and staging](https://www.caprid.com/docs/guides/prod-and-staging/). ## Credentials | Credential | Held by | For | |---|---|---| | **API key** (`sb_...`) | Your backend | Managing your Organization through the [Account API](https://www.caprid.com/docs/reference/http/account/) | | **Signing key** (a JWK) | Your backend | Minting Client tokens without calling Switchboard | | **Client token** (a JWT) | A publisher or viewer | Connecting to a relay | | **Relay enrollment token** (`sbe_...`) | You, briefly | Enrolling a Self-hosted relay | | **Node key** (`sbn_...`) | Your Self-hosted relay | Talking to Switchboard and its peers | Only a Client token should ever reach a browser or an app. A Signing key's **scope** caps what it can sign, and relays enforce that cap on every token. See [Credentials](https://www.caprid.com/docs/guides/credentials/). ## Paths and grants A Client token has three claims that matter: - `root`: the full path the token is anchored at, starting with your Namespace, like `acme/room/42`. - `put`: prefixes under `root` it may publish to. - `get`: prefixes under `root` it may subscribe to. `""` means everything under `root`. Prefixes match whole path segments, so `live` covers `live/cam1` but not `lively`. A Client passes the token to the relay as `?jwt=` on the URL it connects to. See [Client tokens](https://www.caprid.com/docs/guides/credentials/#client-tokens). When you call the API, paths are relative to the Namespace (`room/42`). Inside a token they're written out in full (`acme/room/42`). See [Paths in the API and in tokens](https://www.caprid.com/docs/guides/organizations/#paths-in-the-api-and-in-tokens). ## Tracks and objects What a Client publishes and subscribes to, in the SDKs' terms: - **Namespace (SDK)**: The ordered list of path fields a publisher connection serves, like `['run', '1']`. It's relative to the endpoint URL's path. Not to be confused with the Switchboard Namespace, which is only the first segment of the full path. - **Track**: A named sequence of objects within a namespace, like `ticker` or `video`. - **Object** and **group**: An object is one payload of bytes the SDK doesn't interpret. Objects are sent in groups. A plain `write` sends one object as its own group. - **Announce**: A publisher announces its namespace so the relay can route subscriptions to it. A fresh namespace can briefly reject subscriptions (request error 16) while the announce propagates. Retry them. - **Demand**: A publisher's `write` waits until the relay wants the track, which means a subscriber exists. The [SDK vocabulary](https://www.caprid.com/docs/spec/vocabulary/) has every public name in both SDKs, and the [behavior contract](https://www.caprid.com/docs/spec/behavior/) says exactly what each must do. ## Endpoints A Client connects to a relay endpoint. The SDKs' `DEFAULT_ENDPOINT` is the hosted Relay, `https://relay.mux.global`. Put your token's root in the endpoint's path (`https://relay.mux.global/acme/room/42`) and name namespaces relative to it. The connection then can't reach outside that root. `roomEndpoint(['acme', 'room', '42'])` (`room_endpoint` in Python) builds that URL. Pass an endpoint first to root another relay. A Client that spans several rooms under one grant can use the bare host instead, with namespaces that include the root. See [room-rooted endpoints](https://www.caprid.com/docs/spec/behavior/#room-rooted-endpoints). ## Relays - **Platform relay**: A relay Mux runs. It may carry tracks from any Namespace. The Mux relays carry announcements between regions, so a publisher and a subscriber can each use their nearest relay. - **Self-hosted relay**: A `moq-relay` you run and enroll in your Organization. It carries only your Organization's Namespaces. See [Running a Self-hosted relay](https://www.caprid.com/docs/guides/self-hosted-relays/). - **Admission**: Switchboard deciding, for one relay, whether a session may start and with which grant. Relays ask through the [Relay plane API](https://www.caprid.com/docs/reference/http/relay/). --- # Build with AI agents > Machine-readable entry points to these docs, a skill for coding agents, and the rules an agent should follow when integrating Relay and Switchboard. URL: https://www.caprid.com/docs/agents/ Everything in these docs is available in a form a model reads well, from the same sources as the pages you're reading. Point your agent at one of the entry points below, or install the skill. ## Entry points | URL | What it is | |---|---| | [`/llms.txt`](https://www.caprid.com/llms.txt) | The index: every page, as a link to its Markdown, with a one-line summary. Start here. | | [`/llms-small.txt`](https://www.caprid.com/llms-small.txt) | What it takes to build an integration, in one Markdown file: getting started, these rules, the Switchboard guides, both SDK guides and the Account API. | | [`/llms-full.txt`](https://www.caprid.com/llms-full.txt) | Everything except the TypeScript symbol pages in one file, adding the SDK specification, the Python reference and changelogs. Large; prefer fetching pages from `llms.txt`. | | `.md` | Any page as Markdown: drop the trailing slash and add `.md`, so `/docs/guides/credentials/` is [`/docs/guides/credentials.md`](https://www.caprid.com/docs/guides/credentials.md). Every page links to it with ``. | | [`/reference/http/account.md`](https://www.caprid.com/docs/reference/http/account.md) | The Account API as Markdown: every operation, parameter and schema. Also [`relay.md`](https://www.caprid.com/docs/reference/http/relay.md). | | [`/openapi/account.yaml`](https://www.caprid.com/openapi/account.yaml) | The Account API's OpenAPI 3.1 spec, unchanged from its source. Also as [JSON](https://www.caprid.com/openapi/account.json), and the [Relay plane API](https://www.caprid.com/openapi/relay.yaml). Use these to generate a client. | | [`/raw/spec/protocol.json`](https://www.caprid.com/raw/spec/protocol.json) | The SDKs' machine-readable protocol pins: MOQT draft, default endpoint, authentication convention, object size limit. | | [`/docs/agents/SKILL.md`](https://www.caprid.com/docs/agents/SKILL.md) | A skill for coding agents: the rules below, and where to look things up. | | [`/sources.json`](https://www.caprid.com/sources.json) | The exact commits of the source repositories this build of the docs came from. | Every page also has **Copy as Markdown**, **Open in Claude** and **Open in ChatGPT** under its title. ## Give your coding agent the docs **Claude Code, and agents that read skills.** Install the skill in your project: ```bash mkdir -p .claude/skills/caprid curl -fsSL https://www.caprid.com/docs/agents/SKILL.md -o .claude/skills/caprid/SKILL.md ``` **Any agent that reads `AGENTS.md` or `CLAUDE.md`.** Add a pointer: ```markdown ## Caprid (Relay and Switchboard) Docs index: https://www.caprid.com/llms.txt. Fetch the `.md` pages it links to before writing code against Relay, Switchboard, `@caprid/relay` or `caprid-relay`, and follow the integration rules in https://www.caprid.com/docs/agents.md. ``` **Cursor, and editors with a docs index.** Add `https://www.caprid.com/llms-small.txt` as a docs source. **Letting an agent sign itself up.** An agent doesn't need you to create an API key first. Point it at `https://www.caprid.com/auth.md` (the same steps are at `https://app.caprid.com/auth.md`): it registers through WorkOS AuthKit and starts a Trial Organization with one Namespace, for 7 days. Claim the agent to keep what it built. [AI agents](https://www.caprid.com/docs/guides/agents/) walks through it. **Generating an API client.** Feed [`/openapi/account.yaml`](https://www.caprid.com/openapi/account.yaml) to your OpenAPI generator. Every operation has a stable `operationId`, like `mintClientToken`. ## Integration rules These are the facts an agent most often gets wrong. Each links to where it's specified. 1. **Only a Client token goes to a browser or app.** API keys (`sb_...`) and Signing keys stay on the server. A leaked API key or Signing key lets anyone mint tokens. ([Credentials](https://www.caprid.com/docs/guides/credentials/)) 2. **Mint tokens on the backend, per Client, short-lived.** Grant the least you can: a viewer gets `subscribe` (`get`) only, under its own room. There is no way to revoke a single Client token; deleting the Signing key revokes every token it signed. ([Client tokens](https://www.caprid.com/docs/guides/credentials/#client-tokens)) 3. **Sign tokens yourself in production.** A Client token is an HS256 JWT with the Signing key's `kid` in its header and `root`, `put`, `get` and `exp` claims. `root` is the full path, starting with the Namespace. The API's `path` is relative to the Namespace; a token's `root` is not. ([Sign your own](https://www.caprid.com/docs/guides/credentials/#sign-your-own)) 4. **A token's grants must fit the Signing key's scope**, or a relay refuses it when a Client uses it. Check one with `POST /v1/namespaces/{namespace}/tokens/verify`. ([`verifyClientToken`](https://www.caprid.com/docs/reference/http/account/operations/verifyclienttoken/)) 5. **The token travels as `?jwt=` on the relay URL.** Browsers can log that URL on a failed handshake, so scrub `jwt` values from diagnostics and error reports. ([Browser token exposure](https://www.caprid.com/docs/sdks/typescript/guide/#browser-token-exposure)) 6. **Root the endpoint at the token's root.** Use `roomEndpoint(root)` (`room_endpoint(root)` in Python), which roots the hosted Relay, `DEFAULT_ENDPOINT`. Name namespaces relative to it. ([Room-rooted endpoints](https://www.caprid.com/docs/spec/behavior/#room-rooted-endpoints)) 7. **Retry request error 16 on a fresh namespace.** A subscription can be rejected with `unroutable` for about a round trip while the publisher's announce propagates; a publisher's `announced` says its announce reached the wire. ([Subscription lifecycle](https://www.caprid.com/docs/sdks/typescript/guide/#subscription-lifecycle)) 8. **A write waits for demand.** `write` doesn't send until a subscriber wants the track, and a completed write means the transport accepted it, not that anyone received it. ([Publishing lifecycle](https://www.caprid.com/docs/sdks/typescript/guide/#publishing-lifecycle)) 9. **Never replay a failed write automatically.** Delivery may have been partial; decide on fresh data or an explicit replay policy. ([Errors and recovery](https://www.caprid.com/docs/sdks/typescript/guide/#errors-and-recovery)) 10. **Plain connections never reconnect.** For anything long-lived use `connectSupervisedPublisher` / `connectSupervisedSubscriber` (`connect_supervised_*` in Python), which also rotate before the credential expires. ([Supervised connections](https://www.caprid.com/docs/sdks/typescript/guide/#stay-connected-supervised-connections)) 11. **Branch on `RelayError` codes, not messages.** Codes and phases are stable. ([Error identities](https://www.caprid.com/docs/spec/behavior/#error-identities-and-operation-phases)) 12. **An API key can't manage Members or Invitations, or delete a Namespace.** Those need a person signed in to the dashboard. ([Members and Owners](https://www.caprid.com/docs/guides/organizations/#members-and-owners)) 13. **Install matching versions.** The packages are `@caprid/relay` on npm (add `@caprid/relay-quic` for Node) and `caprid-relay` on PyPI. Each SDK's packages release together, so pin them to one version. Before 1.0, a minor version can break the API. ([TypeScript](https://www.caprid.com/docs/sdks/typescript/#install), [Python](https://www.caprid.com/docs/sdks/python/guide/#use-the-local-packages-in-your-application)) ## Where things are specified These docs are generated from two repositories, which stay the source of truth. Every page made from one has a **Source** link under its title. - **Switchboard** (`docs/public/`, `openapi/`): the guides and both HTTP APIs. - **Relay SDKs** (`ts/`, `python/`, `spec/`): the SDK guides, the package READMEs, the API reference (generated from the code's doc comments and docstrings) and the SDK specification. When a guide and the [behavior contract](https://www.caprid.com/docs/spec/behavior/) seem to disagree, the behavior contract says what an SDK must do. --- # Organizations and Namespaces > Who owns what on Switchboard, how API routes name it, and how to manage Namespaces, Members and Invitations. URL: https://www.caprid.com/docs/guides/organizations/ Source: https://github.com/muxinc/switchboard/blob/main/docs/public/organizations.md Your company is an Organization on Switchboard. The people who manage it are its Members. Each Member signs in with their own Account, so nobody shares a login, and one person can be a Member of several Organizations. An Organization owns its Namespaces. A Namespace is the first segment of every path you can sign Client tokens for. If your Namespace is `acme`, you can sign any path under `acme/`, which covers all your broadcasts and their tracks. The name is yours for good. Nobody else can ever have it, even after you stop using it. Signing up creates an Organization with the Namespace you choose. An AI agent can also start a trial Organization by itself, which becomes yours when you claim the agent. See [AI agents](https://www.caprid.com/docs/guides/agents/). ## Find your Organization's id API routes name what they act on. Signing keys and Client tokens belong to a Namespace, so their routes start with `/v1/namespaces/acme`. API keys and Self-hosted relays serve the whole Organization, so their routes start with `/v1/organizations/{id}`. To find the id, list your Organizations: ```bash curl https://app.caprid.com/v1/organizations \ -H "Authorization: Bearer $SB_API_KEY" ``` The response is: ```json [{"id": 4, "name": "acme", "created_at": 1790298000}] ``` The examples in these docs use Organization `4`, which owns the Namespace `acme`. ## Paths in the API and in tokens When you call the API, paths are relative to the Namespace, and Switchboard adds the Namespace for you. Ask for `room/42` and you get `acme/room/42`. Inside a Client token, the path is written out in full, Namespace included. So when you sign tokens yourself, you write `acme/room/42`. ## Create another Organization You might want a separate Organization for a customer or project that should have its own API keys and relays. Create one from **New Organization** in the dashboard. You pick its first Namespace the same way you did at signup, you become its Owner, and its page shows its first API key once. You can create 5 Organizations. Organizations you joined by invitation don't count. Creating one takes a signed-in session, so an API key can't do it. ## Create a Namespace An Organization can have more than one Namespace. A second Namespace is useful for keeping prod and staging apart, as [Prod and staging](https://www.caprid.com/docs/guides/prod-and-staging/) shows. Any Member can create one, from the Organization's page in the dashboard or with the API: ```bash curl https://app.caprid.com/v1/organizations/4/namespaces \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"namespace": "acme-staging"}' ``` The response is the new Namespace: ```json {"name": "acme-staging", "organization_id": 4, "platform_peering": false, "created_at": 1790298000} ``` Names follow the same rules as at signup. Because a name is never reused, you get a 409 for any name that a Namespace has ever had, including one that was deleted. An Organization can have 10 Namespaces at a time. Ask us if you need more. ## Delete a Namespace Only an Owner signed in to the dashboard can delete a Namespace. Use the Namespace's settings tab, or call `DELETE /v1/namespaces/acme-staging` with your session. An API key can never delete one, so a leaked key can't destroy something you can't get back. Deleting a Namespace: - deletes its signing keys, so the Client tokens they signed stop working, and relays drop its tracks within about a minute - removes it from the limit of any API key that was limited to it - leaves your Self-hosted relays, Regions and enrollments alone, because they belong to the Organization You can't undo the delete, and nobody can use the name again, you included. You can delete your last Namespace. The Organization stays, with no Namespaces, until you create another. ## Members and Owners Every Member can manage everything the Organization owns. Owners can also change who is an Owner, remove Members, and invite people. To see who belongs, open your Organization's **Members** page in the dashboard, or call the API: ```bash curl https://app.caprid.com/v1/organizations/4/members \ -H "Authorization: Bearer $SB_API_KEY" ``` The response lists each Member with their role and when they joined, in Unix seconds: ```json [ {"account_id": 7, "email": "bob@acme.example", "role": "owner", "joined_at": 1790294400}, {"account_id": 12, "email": "alice@acme.example", "role": "member", "joined_at": 1790380800} ] ``` To change someone's role, an Owner sends `PATCH /v1/organizations/{id}/members/{account_id}` with a body of `{"role": "owner"}` or `{"role": "member"}`. To remove someone, an Owner sends `DELETE` to the same path. Any Member can leave by sending `DELETE` with their own `account_id`. Changing Members takes a signed-in session. An API key can list Members but can't change them, so nobody can use a leaked key to add themselves to your Organization. An Organization always has at least one Owner. If you're the last one, you have to make someone else an Owner before you can step down or leave. When someone leaves, the API keys they made keep working, because the keys belong to the Organization. Revoke them separately if you need to. ## Invite someone To add a teammate, an Owner invites their email address from the **Members** page. Switchboard doesn't send email. You get a link, and you send it to them yourself, in Slack or wherever your team talks. The link is shown once, works once, and expires after 7 days. The dashboard creates the Invitation by sending `POST /v1/organizations/{id}/invitations` with your signed-in session and a body like `{"email": "alice@acme.example", "role": "member"}`. The response includes the link to send: ```json { "id": "3f9c1a2b7d4e", "organization_id": 4, "email": "alice@acme.example", "role": "member", "created_at": 1790294400, "expires_at": 1790899200, "secret": "sbi_mR3x9Qe2vT8wY1zA4bC7dF0gH5jK6lN2pS8uV3xZ1q", "link": "https://app.caprid.com/app/invitations/sbi_mR3x9Qe2vT8wY1zA4bC7dF0gH5jK6lN2pS8uV3xZ1q" } ``` Only an Account with the invited email can accept, so a link forwarded to the wrong person is no use to them. When Alice opens the link, she signs in, or signs up, as `alice@acme.example`, and joins with the role you picked. If she signs up from the Invitation, she joins your Organization instead of creating her own, so she doesn't need to pick a Namespace. On app.caprid.com, WorkOS verifies every email address, so Alice doesn't need the link at all. The first time she signs in, the dashboard offers to let her join Acme, next to the option to create her own Organization. Owners can see pending Invitations on the **Members** page, or by sending `GET` to the same path. To revoke one, which stops its link working, send `DELETE /v1/organizations/{id}/invitations/{id}`. As with Members, an API key can't invite anyone or see Invitations, so nobody can use a leaked key to let someone in. --- # Credentials > API keys, signing keys, Client tokens and Node keys, and which one goes where. URL: https://www.caprid.com/docs/guides/credentials/ Source: https://github.com/muxinc/switchboard/blob/main/docs/public/credentials.md Switchboard hands out five kinds of credential. Most apps need only the first three. | | Looks like | Who holds it | Used for | Lasts | |---|---|---|---|---| | API key | `sb_...` | Your backend | Managing your Organization through the Switchboard API | Until you revoke it, or an expiry you set | | Signing key | a JWK, kid `acme_backend` | Your backend | Minting Client tokens yourself | Until you delete it | | Client token | a JWT | A publisher or viewer | Connecting to a relay | An hour by default | | Relay enrollment token | `sbe_...` | You, briefly | Enrolling a Self-hosted relay | Used once, expires within a day | | Node key | `sbn_...` | Your Self-hosted relay | Talking to Switchboard and its peers | Until you delete the Node | You use the API key to manage things, such as creating signing keys and enrolling relays. You use the signing key for the job you do constantly, which is minting a Client token for each publisher and viewer. Only a Client token belongs in a browser or a mobile app. Keep the others on your servers. An AI agent can also call the API with an access token it gets from WorkOS AuthKit, without you handing it any of these. See [AI agents](https://www.caprid.com/docs/guides/agents/). The examples on this page use Organization `4` and its Namespace `acme`. See [Organizations and Namespaces](https://www.caprid.com/docs/guides/organizations/) for what those are and how API paths name them. ## API keys An API key authenticates your calls to the Switchboard API. Send it as a bearer token: ```bash curl https://app.caprid.com/v1/account \ -H "Authorization: Bearer $SB_API_KEY" ``` You get one when you sign up. To make more, use your Organization's page in the dashboard, or send `POST /v1/organizations/{id}/api-keys`. The secret is shown once, when the key is created. After that you see only its first few characters. An API key belongs to the Organization, not to the person who made it, so it keeps working when that person leaves. It reaches its own Organization and everything in it, and nothing else. You should treat an API key like a password, because it can create signing keys. Relays never accept an API key, so a leaked key can't be used to watch or publish directly. It can be used to mint tokens that can. ### Limit an API key to some Namespaces If you keep prod and staging in separate Namespaces, your staging CI should hold a key that can't read prod's signing keys. You can limit an API key to some of your Organization's Namespaces when you create it, in the dashboard or by sending `namespaces`: ```bash curl https://app.caprid.com/v1/organizations/4/api-keys \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"label": "staging-ci", "namespaces": ["acme-staging"]}' ``` The response is the new key, with its limit: ```json { "id": "3f9a1c07be42", "label": "staging-ci", "preview": "R2x0Z9", "created_at": 1767225600, "namespaces": ["acme-staging"], "secret": "sb_R2x0Z9..." } ``` This key: - can create, read and delete signing keys in `acme-staging` - can mint and verify Client tokens in `acme-staging` - can turn `acme-staging`'s `platform_peering` setting on or off - gets a 403 for anything in `acme` or any other Namespace - gets a 403 for every `/v1/organizations/{id}` route, which covers relays, enrollments and API keys Because it can't create API keys, the key can't make itself a new key without the limit. A limited key can't list your Organization's Namespaces, but it can look itself up. `GET /v1/account` returns the key as `api_key`, limit included: ```bash curl https://app.caprid.com/v1/account \ -H "Authorization: Bearer $SB_STAGING_KEY" ``` The response is: ```json { "account": { "api_key": { "id": "3f9a1c07be42", "label": "staging-ci", "preview": "R2x0Z9", "created_at": 1767225600, "last_used_at": 1767312000, "namespaces": ["acme-staging"] }, "organizations": [{"id": 4, "name": "Acme", "created_at": 1760000000}], "namespace": "acme-staging", "platform_peering": false } } ``` `namespace` and `platform_peering` appear only because this key reaches exactly one Namespace. A key with no limit has no `namespaces` in its `api_key`. You can't change a key's limit later. Create a new key instead. If you delete a Namespace, it drops out of every key's limit. A key whose Namespaces have all been deleted shows `"namespaces": []` and can't do anything, so you should revoke it. ### Routes from before Organizations Scripts written before Organizations existed use routes without an Organization or a Namespace in them: `/v1/keys`, `/v1/tokens`, `/v1/enrollments`, `/v1/nodes` and `/v1/account/api-keys`. These routes still work with an API key, and act in the key's Organization. `/v1/keys` and `/v1/tokens` work only while the key reaches exactly one Namespace, because otherwise they can't tell which Namespace you mean. That's the Organization's only Namespace, or the only one in the key's limit. A limited key gets a 403 from the other old routes, because they act on the whole Organization. Move to the new routes when you next touch those scripts. ## Signing keys A signing key mints Client tokens. You can also mint Client tokens through the API, but with a signing key, your own server-side code mints them directly, without a call to Switchboard for each one. To create a signing key: ```bash curl -X POST https://app.caprid.com/v1/namespaces/acme/keys \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"label": "backend"}' ``` The response is the key: ```json { "alg": "HS256", "key_ops": ["sign", "verify"], "kty": "oct", "k": "5mT0...", "kid": "acme_backend", "scope": {"root": "acme", "put": [""], "get": [""]} } ``` `k` is the secret. The `kid` is your Namespace and the label, joined with an underscore. `scope` is the most the key can ever sign. By default, that's your whole Namespace, for publishing (`put`) and subscribing (`get`). You can narrow the scope when you create the key, and you can't widen it afterwards: ```bash # Signs only tokens rooted under acme/customer-foo, for either role -d '{"label": "customer-foo", "path": "customer-foo"}' # Signs only tokens that publish, and only under acme/ingest -d '{"label": "ingest", "publish": "ingest"}' ``` If you name `publish` and not `subscribe`, the key can't sign tokens that subscribe. The same goes the other way round. Relays enforce the scope every time they check a token, not only when the token is signed. So someone who gets hold of a signing key can't reach outside its scope, but they can do anything inside it. You should keep signing keys on your servers. Because the scope holds no matter who has the key, you can give a narrowly scoped key to someone else. Suppose your customers run their own backends. A key scoped to `acme/customer-foo` lets `customer-foo` mint tokens for their own viewers, and they can't touch any other customer's broadcasts. They can still do anything inside that scope, so the key is theirs to protect. ## Client tokens A Client token is what a publisher or viewer presents to a relay. It's a JWT with three claims that matter: - `root`: the path the token is anchored at. It's the full path, so it starts with your Namespace. - `put`: the prefixes under `root` that the holder may publish to. - `get`: the prefixes under `root` that the holder may subscribe to. `""` in `put` or `get` means everything under `root`. Prefixes match whole path segments, so `live` covers `live/cam1` but not `lively`. The Client passes the token as `jwt=` in the URL it connects to. For Mux's relays, that's `relay.mux.global`, which sends each Client to the nearest region: ``` https://relay.mux.global/acme/room/42?jwt=eyJhbGciOiJIUzI1NiIs... ``` A Client of a Self-hosted relay uses that relay's URL instead. ### Mint one with the API Minting through the API takes one request per token. That's fine for testing or for low volume: ```bash curl -X POST https://app.caprid.com/v1/namespaces/acme/tokens \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"kid": "acme_backend", "path": "room/42", "subscribe": ""}' ``` The response has the token and the claims inside it. `root` includes your Namespace: ```json { "token": "eyJhbGciOiJIUzI1NiIs...", "kid": "acme_backend", "claims": {"root": "acme/room/42", "get": [""], "exp": 1790301600, "iat": 1790298000} } ``` A token grants nothing unless you ask for it, so you have to name `publish`, `subscribe`, or both. The token expires after an hour unless you set `expires_in`, in seconds. `0` means it never expires. While the Namespace has exactly one signing key, you can leave out `kid`. ### Sign your own In production, sign tokens in your backend. You save a round trip per viewer, and minting keeps working if Switchboard is unreachable. A Client token is an ordinary HS256 JWT. Put the signing key's `kid` in the header, and use its `k`, base64url-decoded, as the secret. Here's how with [`jose`](https://github.com/panva/jose) in Node: ```js import { SignJWT, base64url } from "jose"; const key = JSON.parse(process.env.SB_SIGNING_KEY); // the JWK from /v1/namespaces/acme/keys export function viewerToken(room) { return new SignJWT({ root: `acme/room/${room}`, get: [""] }) .setProtectedHeader({ alg: "HS256", typ: "JWT", kid: key.kid }) .setIssuedAt() .setExpirationTime("1h") .sign(base64url.decode(key.k)); } ``` A token from `viewerToken("42")`: - can subscribe to anything under `acme/room/42` - can't publish anything - expires after an hour Here, `root` starts with `acme/` because nothing adds the Namespace for you, the way the API does. The claims have to fit inside the signing key's scope. If a token asks for more, the relay refuses it when a Client uses it. To check a token while you build this, send it to `POST /v1/namespaces/acme/tokens/verify`. ### Revoke tokens You can't revoke a single Client token. Keep tokens short-lived, and issue a new one when a session needs to go on longer. Deleting a signing key revokes every token it signed. Clients using those tokens are disconnected within about a minute. To rotate a signing key without cutting anyone off: 1. Create the new key. 2. Switch your backend to the new key. 3. Wait for the tokens signed with the old key to expire. 4. Delete the old key. ## Relay enrollment tokens and Node keys You need these only if you run your own relays. A Relay enrollment token is a one-time code that you create with `POST /v1/organizations/{id}/enrollments`. Your relay exchanges it for a Node key. The relay then uses the Node key to talk to Switchboard and to the other relays it peers with. See [Running a Self-hosted relay](https://www.caprid.com/docs/guides/self-hosted-relays/). Deleting a Node deletes its Node key. Switchboard refuses the relay on its next poll, and the relay has to be enrolled again, with a new token, to come back. --- # Prod and staging > Keep staging in its own Namespace, with an API key that can't reach prod. URL: https://www.caprid.com/docs/guides/prod-and-staging/ Source: https://github.com/muxinc/switchboard/blob/main/docs/public/prod-and-staging.md You usually want staging to be a copy of prod that can't touch prod. On Switchboard, you get that with one Namespace for each, in the same Organization: `acme` for prod and `acme-staging` for staging. Each Namespace has its own signing keys, so a Client token signed for staging can't publish to or watch anything in prod, even when the two share relays. ## 1. Create the staging Namespace Your Organization already has the Namespace you signed up with. Create the second one on the Organization's page in the dashboard, or with the API: ```bash curl https://app.caprid.com/v1/organizations/4/namespaces \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"namespace": "acme-staging"}' ``` To find your Organization's id, call `GET /v1/organizations`. ## 2. Give staging its own API key Your staging deploys and CI need an API key, and it shouldn't be one that can read prod's signing keys. Create a key limited to staging: ```bash curl https://app.caprid.com/v1/organizations/4/api-keys \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"label": "staging-ci", "namespaces": ["acme-staging"]}' ``` The response has the secret, which is shown only this once. Store it where staging reads its config. This key: - can create signing keys and mint Client tokens in `acme-staging` - can't see or change anything in `acme` - can't create API keys, enroll relays, or manage the Organization For the details, see [Limit an API key to some Namespaces](https://www.caprid.com/docs/guides/credentials/#limit-an-api-key-to-some-namespaces). ## 3. Point staging at its Namespace Staging's backend should call the `/v1/namespaces/acme-staging/...` routes and sign tokens with a staging signing key. Paths in its tokens start with `acme-staging/`, for example `acme-staging/room/42`. Your players and publishers don't change. They connect to the same relays, with whatever token your backend hands them. If you run Self-hosted relays, they already carry every Namespace in your Organization, so you don't need a second fleet for staging. ## Delete staging when you're done An Owner can delete `acme-staging` from its settings tab in the dashboard. That deletes its signing keys. The API keys limited to it stop working, so you should revoke them too. The name `acme-staging` can never be used again. If you might want staging back later, leave the Namespace in place. --- # AI agents > Let an AI agent start a Switchboard trial on its own, then claim it to keep what it built. URL: https://www.caprid.com/docs/guides/agents/ Source: https://github.com/muxinc/switchboard/blob/main/docs/public/agents.md An AI agent can start using Switchboard by itself. You don't have to sign up for it, create an API key, or paste a secret into its prompt. Tell it to read `https://app.caprid.com/auth.md`, and it registers its own identity, starts a trial, and can mint Client tokens for a broadcast within a minute. If you like what it built, you claim the agent, and the trial becomes yours. On Switchboard such an agent is an Agent, and the access token it sends is an Agent token. ## How an Agent gets started `/auth.md` is a set of steps written for an Agent to follow. The first part comes from WorkOS AuthKit, which Switchboard uses for sign-in. It tells the agent how to register, how to store what it gets back, and how to exchange that for a short-lived access token. The last part tells it what to do with that token on Switchboard. You can give your Agent a prompt like this: ```text Read https://app.caprid.com/auth.md and follow it to get a Switchboard trial. Then create a signing key in the trial's Namespace and mint a Client token that can publish to room/42. ``` The Agent registers anonymously, so nobody has to approve anything yet. It sends the access token to the API as a bearer token: ```bash curl https://app.caprid.com/v1/organizations \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` Its first request creates its Trial Organization. The response is: ```json [ { "id": 31, "name": "trial-8dfa34a41340", "created_at": 1793382211, "trial_expires_at": 1793987011 } ] ``` The access token lasts about five minutes, and the Agent gets a fresh one from AuthKit whenever it needs to. Its anonymous identity lasts 3 days. For work that should outlast that, the Agent creates an API key, which the steps in `/auth.md` tell it to do. ## What a trial can do A Trial Organization has one Namespace, named `trial-` and a random tag, because nobody was there to pick a name. In it, the Agent and its API keys can: - create, list and delete signing keys, so the Agent's own code can mint Client tokens without calling Switchboard - mint and verify Client tokens for Switchboard's relays - create, list and revoke API keys for the trial - rename the Organization and change the Namespace's settings A trial can't enroll Self-hosted relays, create or delete a Namespace, create another Organization, or manage Members and Invitations. Those routes return a 403 that says to claim the Agent, and that applies to the trial's API keys too: ```json {"error": "this is a Trial Organization, which can use its Namespace's Signing keys and Client tokens and its own API keys, and nothing else until a person claims the Agent that started it"} ``` Starting a trial is rate-limited per address, like signup. An Agent that gets a 429 on its first request should wait and try again. ## When a trial ends A trial lapses 7 days after it starts, at its `trial_expires_at`. From then on its API keys get a 401, and the Agent gets a 403 saying its trial is over. Within about 10 minutes, Switchboard deletes the trial's Namespace and its signing keys, so relays stop admitting its Client tokens. Nobody can use the Namespace's name again. An Agent whose trial is over can register again to start a new one, with a new Namespace. Nothing carries over from the old trial. ## Claim an Agent to keep its trial Claiming an Agent links it to you, and its trial becomes an ordinary Organization that you own. You should claim it before the trial lapses if you want to keep the Namespace, signing keys and API keys it made. You need a Switchboard Account first. Sign up at `https://app.caprid.com/app/` if you haven't. To claim the Agent, ask it to start a claim. It follows the claim step in `/auth.md`, then gives you a link: 1. Open the link and sign in with the email you use for Switchboard. 2. The page shows a code. Give that code back to the Agent. 3. The Agent finishes the claim with the code and gets a new access token. The Agent's next request makes you the trial's Owner. The Organization stops being a trial, so it doesn't lapse and its limits lift. Everything the Agent made keeps working, and the Namespace keeps its `trial-` name, because a Namespace's name never changes. The Organization doesn't count against the five you can create yourself. It shows up in your dashboard like any other. If you claim the Agent before you've signed up, it gets a 403 that points you at signup, and the trial keeps running until it lapses. Sign up before then and the next request goes through. A trial that lapsed before the claim stays lapsed. ## What a claimed Agent can do A claimed Agent acts as your Account. It can do anything your dashboard session can, in every Organization you're a Member of. Where you're an Owner, that includes inviting and removing Members and deleting a Namespace, whose name is never reissued. You should claim only Agents you'd trust with your own sign-in. An API key can't manage Members or delete a Namespace, so if your Agent only needs to mint tokens, have it create an API key limited to the Namespaces it works in. See [Credentials](https://www.caprid.com/docs/guides/credentials/#limit-an-api-key-to-some-namespaces). Switchboard keeps no list of Agents, so it can't cut off one claimed Agent without cutting off you. To stop one, have it revoke its registration at AuthKit, which the last step of `/auth.md` covers, and revoke any API keys it created. Its current access token keeps working for up to five minutes after that. ## For OAuth clients Switchboard publishes OAuth protected resource metadata, so a client that already knows OAuth can find AuthKit without reading `/auth.md`: ```bash curl https://app.caprid.com/.well-known/oauth-protected-resource ``` The response is: ```json { "resource": "https://app.caprid.com", "authorization_servers": ["https://auth.caprid.com"] } ``` `resource` is always `https://app.caprid.com`, whichever hostname you fetched the document from. The API accepts a token AuthKit issued for Switchboard's WorkOS client, which is what `/auth.md` gets you. It also accepts a token issued for exactly that resource (RFC 8707), but AuthKit doesn't issue those: it answers `invalid_target` to a token request that names a `resource`, whatever the URL. Leave `resource` out and follow `/auth.md`. A token issued for any other resource is refused. A 401 from the API points at the same document in its `WWW-Authenticate` header, as `resource_metadata`. --- # Running a Self-hosted relay > Run moq-relay on your own infrastructure and enroll it in your Organization. URL: https://www.caprid.com/docs/guides/self-hosted-relays/ Source: https://github.com/muxinc/switchboard/blob/main/docs/public/self-hosted-relays.md A Self-hosted relay is a relay you run yourself and enroll in your Organization. It talks to Switchboard over the public internet at `https://switchboard.mux.global`, so you don't need a private network, a VPN, or a custom build. The relay is `moq-relay`, from the open-source [moq-dev/moq](https://github.com/moq-dev/moq) project, version 0.15 or later. The [moq-relay README](https://github.com/moq-dev/moq/tree/main/rs/moq-relay) covers installing it from packages or Docker, and has example configurations. The fastest way to start is the `moqdev/moq-relay` Docker image, shown in [step 3](#3-configure-the-relay). Once it's enrolled, your relay calls Switchboard for three things: | What | When | Endpoint | |---|---|---| | Enrollment | Once, when you set it up | `POST /cluster/enroll` | | Peer discovery | About once a second | `GET /cluster/connect` | | Admission | For every session, and every 30 seconds while it lasts | `POST /auth` | Switchboard answers Admission from the location nearest your relay, and keeps answering while the rest of Switchboard is down or being deployed. If a peer discovery poll fails, your relay keeps the peers it already has. A Self-hosted relay carries only your Organization's Namespaces. Whatever token a Client brings, the relay won't let it publish or subscribe outside `acme/`, or whatever your Namespaces are. ## 1. Get a Relay enrollment token You need an API key for your Organization, and the Organization's id from `GET /v1/organizations`. Get the key from the dashboard at `https://app.caprid.com/app`, or see [Credentials](https://www.caprid.com/docs/guides/credentials/). The examples below use Organization `4`, which owns the Namespace `acme`. ```bash curl -X POST https://app.caprid.com/v1/organizations/4/enrollments \ -H "Authorization: Bearer $SB_API_KEY" \ -d '{"url": "https://relay1.example.com:4443/", "region": "iad", "gateway": false}' ``` The response includes the token, which is shown only this once: ```json {"id": "7c1e...", "token": "sbe_...", "expires_at": 1790301600, ...} ``` The token fixes everything about the relay's Node, and the relay can't change any of it later: - `url` is where other relays connect to it, and where Clients connect too, unless you set `client_url`. - `region` is which of your Regions the relay joins. Relays in the same Region all connect to each other. If you leave it out, the relay joins `default`. Region names are yours alone, so your `iad` has nothing to do with anyone else's. - `gateway` is whether the relay may be picked to carry its Region's traffic to your other Regions. - `client_url` is optional. Set it when Clients reach the relay at a different address from the one its peers dial, for example when `url` is a private name. Whatever hands relay addresses to your Clients can read it from the Node. The token expires after an hour if nobody uses it. To change that, set `expires_in`, in seconds, up to a day. To revoke it early, call `DELETE /v1/organizations/4/enrollments/{id}`. ## 2. Exchange it for a Node key On the relay's host, run this once: ```bash curl -X POST https://switchboard.mux.global/cluster/enroll \ -d '{"token": "sbe_..."}' > enrollment.json jq -r .node_key enrollment.json > /etc/moq/node.key # sbn_... jq '{cluster, auth}' enrollment.json ``` The second `jq` prints the settings for `relay.toml`: ```json { "cluster": { "node": "https://relay1.example.com:4443/", "connect_api": "https://switchboard.mux.global/cluster/connect?jwt=sbn_..." }, "auth": {"url": "https://switchboard.mux.global/auth?jwt=sbn_..."} } ``` The token works only once, and this response is the only time you see the Node key, so keep it. If you lose the Node key, enroll the relay again as a new Node, and delete the old one with `DELETE /v1/organizations/4/nodes?url=...`. ## 3. Configure the relay Write `/etc/moq/relay.toml`: ```toml [listen] # Where Clients and peers connect. The certificate has to be valid for the # host in [cluster] node. bind = "[::]:4443" tls.cert = ["/etc/moq/cert.pem"] tls.key = ["/etc/moq/key.pem"] [cluster] # The values enrollment returned, unchanged. node = "https://relay1.example.com:4443/" connect_api = "https://switchboard.mux.global/cluster/connect?jwt=sbn_..." # The Node key, as a file. The relay presents it to the peers it connects to. token = "/etc/moq/node.key" [auth] # Switchboard decides every session here. url = "https://switchboard.mux.global/auth?jwt=sbn_..." ``` `relay.toml` contains the Node key twice, in those URLs. Protect it the same way you protect `node.key`. Don't set `[connect] tls.insecure`. That setting turns off certificate checks for the relay's calls to Switchboard as well as to its peers, and `switchboard.mux.global` has a publicly trusted certificate. Use it only if your relays present self-signed certificates to each other. To run the relay with Docker, put `relay.toml`, `node.key` and your certificate in `/etc/moq`, then run: ```bash docker run -d --name moq-relay --restart unless-stopped \ -p 4443:4443/udp \ -v /etc/moq:/etc/moq:ro \ moqdev/moq-relay:0.17.9 /etc/moq/relay.toml ``` The image takes the path to its config file as its only argument. QUIC runs over UDP, so open UDP port 4443, or whichever port you bound, in your firewall. ## 4. Check that it works - **Polling.** A few seconds after the relay starts, `GET /v1/organizations/4/nodes` lists the Node with `"live": true`. - **Peers.** With two or more relays in a Region, each relay's log shows it connecting to the others. - **Admission.** A Client token from `POST /v1/namespaces/acme/tokens` works through the relay. A token for another Organization's Namespace, or no token, is refused. ## Remove a relay `DELETE /v1/organizations/4/nodes?url=https://relay1.example.com:4443/` deletes the Node and its Node key. Switchboard refuses the relay on its next poll, and its peers drop it. To bring the relay back, enroll it again. ## Current limits - Your Regions can't link to Mux's relays yet. Until they can, a Namespace's `platform_peering` setting has no effect. - To move a relay to another Region, or to change whether it can be a gateway, enroll it again. --- # Switchboard Account API OpenAPI 3.1.0, version 1. Machine-readable spec: https://www.caprid.com/openapi/account.yaml. Rendered reference: https://www.caprid.com/docs/reference/http/account/ Base URL: `https://app.caprid.com` Manage your Organizations on Switchboard: their Namespaces, the Signing keys and Client tokens for each Namespace, their API keys, and the Self-hosted relays they enroll. An Organization is a customer, and a Namespace is a path root it owns, like an S3 bucket. Routes name what they act on. Everything that serves the whole Organization (API keys, Nodes, enrollments) is under `/v1/organizations/{id}`. Everything inside one Namespace (Signing keys, Client tokens, its settings) is under `/v1/namespaces/{namespace}`. That path needs no Organization, because Namespace names are unique. Every request except signup, login, logout and the health check needs a credential. Send an API key as `Authorization: Bearer sb_...`. An API key belongs to one Organization and reaches only that Organization and its Namespaces. You can limit a key to some of those Namespaces, so a staging CI key can't read prod's Signing keys. A limited key gets a 403 outside its Namespaces and on every `/v1/organizations/{id}` route. The dashboard uses a session cookie instead, which reaches every Organization the signed-in person is a Member of. Anything you can't reach is a 404, whether or not it exists. On a server that logs people in through WorkOS, such as app.caprid.com, there are no passwords. You sign in at `/app/` in a browser, and create API keys there for your scripts. On that server, `POST /v1/login` returns 410, and `POST /v1/signup` works only as the last step of signing in through the browser. An AI agent can use the API without you creating an API key for it, on a server set up for Agents. The Agent registers with WorkOS AuthKit, by following the steps at `/auth.md`, and sends the access token AuthKit gives it, its Agent token, as `Authorization: Bearer ...`. Once you claim the Agent at AuthKit, it acts as your Account, so it can do anything your dashboard session can, including creating API keys for itself. If you haven't signed up yet, the Agent gets a 403 on every route until you do. An Agent nobody has claimed can start right away. Its first request gives it a Trial Organization with one Namespace, named `trial-` and a random tag, so it can create Signing keys, mint Client tokens for Platform relays, and create API keys for itself, with no person involved. A trial has no Members. It can't enroll Self-hosted relays, create or delete a Namespace, create an Organization, or manage Members and Invitations, and those routes return 403 to the Agent and to the trial's API keys alike. Starting trials is rate-limited per address, like signup, so an Agent's first request can return 429. The trial lapses 7 days after it starts, at its `trial_expires_at`. From then on its API keys get a 401, and the Agent gets a 403 that says the trial is over. Within about 10 minutes Switchboard deletes the trial's Namespace and its Signing keys, so relays stop admitting its Client tokens, and nobody can take the name again. Claim the Agent before then to keep what it made. Its first request after you claim it makes you the trial's Owner, and the Organization stops being a trial. It no longer lapses, its limits lift, and its Namespace, Signing keys and API keys keep working. It doesn't count against the Organizations you may create. If you claim the Agent before you sign up, the trial waits for you until it lapses. A trial that lapsed before the claim stays lapsed. Paths you send (`path`, `publish`, `subscribe`) are relative to the Namespace, so `path: "room/42"` sent to `/v1/namespaces/acme/tokens` is `acme/room/42`. The routes from before Organizations (`/v1/keys`, `/v1/tokens`, `/v1/enrollments`, `/v1/nodes`, `/v1/account/api-keys`, and `PATCH /v1/account`) still work with an API key, acting in its Organization. `/v1/keys`, `/v1/tokens` and `PATCH /v1/account` work only while the key reaches exactly one Namespace: its Organization's only one, or the only one in its limit. The others act on the whole Organization, so a limited key gets a 403. With a session, they return 400 and name the route to use. Request bodies are JSON. An empty body is the same as `{}`, and unknown fields are ignored. Times are Unix seconds. Errors are always `{"error": "..."}`, with a message meant for a person to read. ## Authentication - **apiKey** (HTTP bearer): An API key, `sb_...`. Create one in the dashboard or with `POST /v1/organizations/{id}/api-keys`. - **session** (cookie `__Host-switchboard_session`): The dashboard's session cookie, set by signup and login. Only accepted on same-origin requests, so it can't be used from another site. Over HTTPS it's named `__Host-switchboard_session`, which browsers accept only from this host, so no other host under the same domain can plant one. A server you run over plain HTTP names it `switchboard_session`. - **agentToken** (HTTP bearer): The AuthKit access token an AI agent sends, on a server set up for Agents. The Agent registers with this server's WorkOS AuthKit domain and exchanges its assertion for the token, which lasts about five minutes. Until a person claims the Agent, the token acts in the Agent's Trial Organization. Once a person claims it, the token acts as that person's Account, with everything their session can do, and its first request makes them the trial's Owner. The server checks the token's signature against AuthKit's published keys, and that AuthKit issued it for this server's WorkOS client or for this server as a resource, the `resource` in `/.well-known/oauth-protected-resource`. ## Account Signing up, logging in, and who you are. ### Show a signup in progress ```http GET /v1/signup ``` operationId: `getPendingSignup` · auth: none · [reference](https://www.caprid.com/docs/reference/http/account/operations/getpendingsignup/) Only with WorkOS login. After a first sign-in, the person has proved who they are but has no Account yet. This route returns the email WorkOS verified, so the dashboard can ask for a Namespace. It also lists the pending Invitations to that email, so the dashboard can offer to join one of those Organizations instead. The login callback sets a cookie that holds the signup in progress for 15 minutes. **Responses** - **200**: The signup in progress. - `application/json`: object - **404**: No signup is in progress, or this server doesn't use WorkOS login. - `application/json`: [Error](#schema-error) ### Create an Account ```http POST /v1/signup ``` operationId: `signup` · auth: none · [reference](https://www.caprid.com/docs/reference/http/account/operations/signup/) Creates an Account. Every Account starts by doing one of two things: - **Creating an Organization.** Send `namespace`. The Organization is named after the Namespace (you can rename it later), you are its Owner, and the response carries its first API key. This is the only time that key's secret is shown, so store it now. - **Joining one.** Send `invitation` instead, and you become a Member of the Organization that invited you, with the role it invited you as. You get no API key, because the Organization already has its own. Without WorkOS login, send the secret from the Invitation's link, because nothing has verified your email. With WorkOS login, send the Invitation's `id` from `GET /v1/signup`. The response also sets a session cookie, for a signup that came from the dashboard. Password signups are limited to 20 an hour from each client address. With WorkOS login, the body needs only `namespace` or `invitation`. The email is the one WorkOS verified, there is no password, and the request has to carry the cookie the login callback set. **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `email` | string (email) | | Stored lowercased. `Alice@example.com` and `alice@example.com` are the same Account. | | `password` | string | | Length is the only rule. Counted in characters, not bytes. | | `namespace` | [NamespaceName](#schema-namespacename) | | A Namespace's name, which is the root of every path signed for it. It never changes. Use lowercase letters, digits and hyphens, and start and end with a letter or digit. Some names, like `admin` and `default`, are reserved, and so is every name that starts with `trial-`, which only Trial Organizations get. | | `invitation` | string | | Join the Organization this Invitation is for, instead of creating one. Send the secret from its link, or with WorkOS login, its `id`. Your email has to be the one invited. | ```json { "email": "dev@acme.example", "password": "correct horse battery staple", "namespace": "acme" } ``` **Responses** - **201**: The Account, and when it created an Organization, that Organization's first API key with its secret. - `application/json`: object - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: With WorkOS login, there is no signup in progress. Sign in at `/app/` first. - `application/json`: [Error](#schema-error) - **403**: The Invitation is for another email address. - `application/json`: [Error](#schema-error) - **404**: No such Invitation. - `application/json`: [Error](#schema-error) - **409**: The email or the Namespace is already taken. - `application/json`: [Error](#schema-error) - **410**: The Invitation has already been used, was revoked, or has expired. Ask for a new one. - `application/json`: [Error](#schema-error) - **429**: Rate limited. Try again later. - `application/json`: [Error](#schema-error) ### Sign in with WorkOS ```http GET /v1/login ``` operationId: `startLogin` · auth: none · [reference](https://www.caprid.com/docs/reference/http/account/operations/startlogin/) Only with WorkOS login. Open this in a browser. It redirects to the WorkOS sign-in page, which sends the browser back to `/v1/login/callback`. The dashboard's sign-in button links here. **Responses** - **302**: Redirect to WorkOS. - **404**: This server doesn't use WorkOS login. - `application/json`: [Error](#schema-error) ### Log in ```http POST /v1/login ``` operationId: `login` · auth: none · [reference](https://www.caprid.com/docs/reference/http/account/operations/login/) Exchanges an email and password for a session cookie. Sessions last 14 days. Scripts should use an API key instead. Failed logins are limited to 10 per 15 minutes, counted separately per email and per client address. **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `email` | string (email) | yes | | | `password` | string | yes | | **Responses** - **200**: Logged in. - `application/json`: [AccountEnvelope](#schema-accountenvelope) - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: The email and password don't match an Account. The response is the same whether or not the email exists. - `application/json`: [Error](#schema-error) - **410**: This server logs people in through WorkOS, and passwords no longer work. - `application/json`: [Error](#schema-error) - **429**: Rate limited. Try again later. - `application/json`: [Error](#schema-error) ### Finish signing in with WorkOS ```http GET /v1/login/callback ``` operationId: `finishLogin` · auth: none · [reference](https://www.caprid.com/docs/reference/http/account/operations/finishlogin/) WorkOS sends the browser back here. You never call it yourself. A returning person gets a session cookie. A first sign-in whose verified email matches an existing Account is linked to it, once and for good. Anyone else gets a signup in progress, and the dashboard asks them for a Namespace. Either way the browser lands on `/app/`. When signing in didn't work, `?login=` says why: `failed`, `unverified` (WorkOS hasn't verified the email), or `conflict` (the email's Account is linked to a different WorkOS user). **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `code` | query | string | | | | `state` | query | string | | | **Responses** - **302**: Redirect to the dashboard. - **404**: This server doesn't use WorkOS login. - `application/json`: [Error](#schema-error) ### Log out ```http POST /v1/logout ``` operationId: `logout` · auth: none · [reference](https://www.caprid.com/docs/reference/http/account/operations/logout/) Ends the calling session and clears its cookie. Works with an expired cookie, or none. A session that came from WorkOS also has a WorkOS session to end, which only the browser can do. The response says where to send it. **Responses** - **200**: Logged out here. Send the browser to `logout_url` to end the WorkOS session too. - `application/json`: object - **204**: Logged out. ### Get the caller ```http GET /v1/account ``` operationId: `getAccount` · [reference](https://www.caprid.com/docs/reference/http/account/operations/getaccount/) Who you are and which Organizations you can manage. With a session, that's every Organization you're a Member of. An Agent you claimed gets the same answer as your session. With an API key, it's the key's own Organization, and there's no email, because an API key belongs to the Organization rather than to a person. Instead, `api_key` describes the key itself, with the Namespaces it is limited to. **Responses** - **200**: The caller. - `application/json`: [AccountEnvelope](#schema-accountenvelope) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent on every route with the token of an Agent that has nothing to act in. An Agent whose person hasn't signed up gets this until they sign up at `/app/`, and its next request after that acts as their new Account. An Agent nobody has claimed gets this once its trial lapses, because a registration gets one trial. It can register again to start a new one, or a person can claim it so it acts as their Account. - `application/json`: [Error](#schema-error) ## Organizations The customers you manage, and their names. ### List your Organizations ```http GET /v1/organizations ``` operationId: `listOrganizations` · [reference](https://www.caprid.com/docs/reference/http/account/operations/listorganizations/) Every Organization your session is a Member of, with your role in each, oldest membership first. An API key lists only its own Organization, with no role. **Responses** - **200**: The Organizations. - `application/json`: array of [Organization](#schema-organization) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent on every route with the token of an Agent that has nothing to act in. An Agent whose person hasn't signed up gets this until they sign up at `/app/`, and its next request after that acts as their new Account. An Agent nobody has claimed gets this once its trial lapses, because a registration gets one trial. It can register again to start a new one, or a person can claim it so it acts as their Account. - `application/json`: [Error](#schema-error) ### Create an Organization ```http POST /v1/organizations ``` operationId: `createOrganization` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/createorganization/) Creates another Organization, the way signup created your first. Use one to keep a separate customer, or a project you'll hand over, apart from the rest. It has its own Namespaces, API keys, Members and relays. You become its Owner. It is named after its Namespace unless you give it a `name`, which you can change later. The response holds its first API key, and this is the only time that key's secret is shown, so store it now. This takes a signed-in session. An API key acts for its own Organization, so it can't create another. An Account may create 5 Organizations. Organizations you joined by invitation don't count. If you need more, ask Mux to raise your cap. **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `namespace` | [NamespaceName](#schema-namespacename) | yes | A Namespace's name, which is the root of every path signed for it. It never changes. Use lowercase letters, digits and hyphens, and start and end with a letter or digit. Some names, like `admin` and `default`, are reserved, and so is every name that starts with `trial-`, which only Trial Organizations get. | | `name` | string | | For display. Defaults to the Namespace. | ```json { "namespace": "acme-labs", "name": "Acme Labs" } ``` **Responses** - **201**: The new Organization, its Namespace, and its first API key. - `application/json`: object - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key, or you have already created as many Organizations as you may. The message says which. - `application/json`: [Error](#schema-error) - **409**: The Namespace is taken. Namespace names are never reused, even after one is deleted. - `application/json`: [Error](#schema-error) ### Get an Organization ```http GET /v1/organizations/{id} ``` operationId: `getOrganization` · [reference](https://www.caprid.com/docs/reference/http/account/operations/getorganization/) **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Responses** - **200**: The Organization. - `application/json`: [Organization](#schema-organization) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Rename an Organization ```http PATCH /v1/organizations/{id} ``` operationId: `updateOrganization` · [reference](https://www.caprid.com/docs/reference/http/account/operations/updateorganization/) The name is only for display, so any Member can change it, and so can the Organization's API keys. Fields you leave out are left alone. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `name` | string | | Leading and trailing spaces are trimmed. | ```json { "name": "Acme, Inc." } ``` **Responses** - **200**: The Organization, renamed. - `application/json`: [Organization](#schema-organization) - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ## Members Who belongs to an Organization. Owners manage Members, signed in with a session. Any Member can leave. ### List an Organization's Members ```http GET /v1/organizations/{id}/members ``` operationId: `listMembers` · [reference](https://www.caprid.com/docs/reference/http/account/operations/listmembers/) Everyone who belongs to the Organization, with their role and when they joined, longest-standing first. Any Member can list them, and so can the Organization's API keys. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Responses** - **200**: The Members. - `application/json`: array of [Member](#schema-member) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Remove a Member, or leave ```http DELETE /v1/organizations/{id}/members/{account} ``` operationId: `deleteMember` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/deletemember/) Owners can remove anyone, and any Member can remove themselves, which is how you leave an Organization. Either way, it takes a session. An API key can never manage Members. The last Owner can't leave or be removed. Make someone else an Owner first. API keys the Member created keep working. They belong to the Organization, so CI doesn't break when someone leaves. Revoke them separately if you need to. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | | `account` | path | integer | yes | The Member's `account_id`, from the list. | **Responses** - **204**: Removed. The Account no longer sees the Organization. - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: You aren't an Owner and this isn't you, or you sent an API key. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) - **409**: You are removing the last Owner. - `application/json`: [Error](#schema-error) ### Change a Member's role ```http PATCH /v1/organizations/{id}/members/{account} ``` operationId: `updateMember` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/updatemember/) Owners only, signed in with a session. An API key can never manage Members, so a leaked one can't be used to let its holder in. An Organization always has an Owner. To step down as the last one, make someone else an Owner first. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | | `account` | path | integer | yes | The Member's `account_id`, from the list. | **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `role` | `"owner"` \| `"member"` | yes | | ```json { "role": "owner" } ``` **Responses** - **200**: The Member, with the new role. - `application/json`: [Member](#schema-member) - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: You aren't an Owner, or you sent an API key. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) - **409**: This would leave the Organization without an Owner. - `application/json`: [Error](#schema-error) ## Invitations How people join an Organization. An Owner invites an email address and sends the link themselves. Only the person with that email can accept. ### List an Organization's pending Invitations ```http GET /v1/organizations/{id}/invitations ``` operationId: `listInvitations` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/listinvitations/) Invitations that are still pending, oldest first. A pending Invitation hasn't been accepted, revoked or expired. Their links aren't listed, because a link's secret is shown only once, when you create the Invitation. Owners only, signed in with a session. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Responses** - **200**: The pending Invitations. - `application/json`: array of [Invitation](#schema-invitation) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: You aren't an Owner of this Organization, or you sent an API key. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Invite someone ```http POST /v1/organizations/{id}/invitations ``` operationId: `createInvitation` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/createinvitation/) Invites one email address into the Organization, and returns a link for that person. Switchboard sends no email, so copy the link and send it yourself. Only the person with that email can use the link, so it's safe if someone forwards it by mistake. The link works once, and expires after 7 days. Its secret is shown only in this response. Owners only, signed in with a session. An API key can never invite anyone, so a leaked one can't be used to let its holder in. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `email` | string (email) | yes | Matched without regard to case. | | `role` | `"owner"` \| `"member"` | | The role they'll have once they accept. | ```json { "email": "alice@acme.example", "role": "member" } ``` **Responses** - **201**: The Invitation, with its link. - `application/json`: [NewInvitation](#schema-newinvitation) - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: You aren't an Owner of this Organization, or you sent an API key. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) - **409**: That email already belongs to a Member. - `application/json`: [Error](#schema-error) ### Revoke an Invitation ```http DELETE /v1/organizations/{id}/invitations/{invitation} ``` operationId: `revokeInvitation` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/revokeinvitation/) Its link stops working. Owners only, signed in with a session. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | | `invitation` | path | string | yes | The Invitation's `id`, from the list. | **Responses** - **204**: Revoked. - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: You aren't an Owner of this Organization, or you sent an API key. - `application/json`: [Error](#schema-error) - **404**: No such pending Invitation. One already accepted, revoked or expired is a 404 too. - `application/json`: [Error](#schema-error) ### List the Invitations to your email ```http GET /v1/invitations ``` operationId: `listMyInvitations` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/listmyinvitations/) Only with WorkOS login, which has verified your email. Lists the pending Invitations to it, from every Organization, so you can accept one without its link. The dashboard shows them when you sign up. **Responses** - **200**: The pending Invitations to your email. - `application/json`: array of [Invitation](#schema-invitation) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key, or with a session whose email WorkOS hasn't verified. - `application/json`: [Error](#schema-error) - **404**: This server doesn't use WorkOS login. - `application/json`: [Error](#schema-error) ### Accept an Invitation ```http POST /v1/invitations/accept ``` operationId: `acceptInvitation` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/acceptinvitation/) Makes you a Member of the Organization, with the role the Invitation names. Your email has to be the one invited, even if you have the link. Each Invitation can be accepted once. Send `secret`, the last part of the link (`/app/invitations/{secret}`). With WorkOS login, which has verified your email, you can send the Invitation's `id` instead. Without WorkOS, nothing has verified your email, so only the link works. This takes a session. An API key belongs to an Organization, not a person. **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `secret` | string | | The secret from the Invitation's link. | | `id` | string | | The Invitation's id. Only with WorkOS login. | ```json { "secret": "sbi_mR3x9Qe2vT8wY1zA4bC7dF0gH5jK6lN2pS8uV3xZ1qW" } ``` **Responses** - **200**: The Organization you joined. - `application/json`: [Organization](#schema-organization) - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: The Invitation is for another email address, you sent an `id` on a server that hasn't verified your email, or you sent an API key. - `application/json`: [Error](#schema-error) - **404**: No such Invitation. - `application/json`: [Error](#schema-error) - **409**: You're already a Member of that Organization. - `application/json`: [Error](#schema-error) - **410**: The Invitation has already been used, was revoked, or has expired. Ask for a new one. - `application/json`: [Error](#schema-error) ## Namespaces The path roots an Organization owns, and their settings. ### List an Organization's Namespaces ```http GET /v1/organizations/{id}/namespaces ``` operationId: `listNamespaces` · [reference](https://www.caprid.com/docs/reference/http/account/operations/listnamespaces/) Oldest first. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Responses** - **200**: The Namespaces. - `application/json`: array of [Namespace](#schema-namespace) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Create a Namespace ```http POST /v1/organizations/{id}/namespaces ``` operationId: `createNamespace` · [reference](https://www.caprid.com/docs/reference/http/account/operations/createnamespace/) Gives the Organization another Namespace, such as `acme-staging` beside `acme`, so that staging's Signing keys and Client tokens can't reach prod. Any Member can create one, and so can an API key that isn't limited to some Namespaces. Names follow the same rules as at signup, and are unique across Switchboard for good. A name that any Namespace has ever had, including a deleted one, is never issued again. An Organization can have 10 live Namespaces. Deleted ones don't count. If you need more, ask Mux to raise the cap. A new Namespace starts with `platform_peering` off. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `namespace` | [NamespaceName](#schema-namespacename) | yes | A Namespace's name, which is the root of every path signed for it. It never changes. Use lowercase letters, digits and hyphens, and start and end with a letter or digit. Some names, like `admin` and `default`, are reserved, and so is every name that starts with `trial-`, which only Trial Organizations get. | ```json { "namespace": "acme-staging" } ``` **Responses** - **201**: The new Namespace. - `application/json`: [Namespace](#schema-namespace) - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: The Organization already has as many Namespaces as it may, or you sent an API key limited to some Namespaces. The message says which. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) - **409**: The name is taken, or was once. - `application/json`: [Error](#schema-error) ### Get a Namespace ```http GET /v1/namespaces/{namespace} ``` operationId: `getNamespace` · [reference](https://www.caprid.com/docs/reference/http/account/operations/getnamespace/) **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `namespace` | path | [NamespaceName](#schema-namespacename) | yes | | **Responses** - **200**: The Namespace. - `application/json`: [Namespace](#schema-namespace) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Delete a Namespace ```http DELETE /v1/namespaces/{namespace} ``` operationId: `deleteNamespace` · auth: `session` · [reference](https://www.caprid.com/docs/reference/http/account/operations/deletenamespace/) Deletes the Namespace and its Signing keys. Client tokens they signed stop working, and relays stop carrying the Namespace's tracks when they next check a session, within about a minute. This can't be undone, and the name can never be used again, by you or anyone else. Owners only, signed in with a session. An API key can never delete a Namespace, so a leaked one can't destroy something you can't get back. Your Nodes, Regions and enrollments belong to the Organization and stay as they are. You can delete the Organization's last Namespace. The Organization stays, with no Namespaces, until you create another. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `namespace` | path | [NamespaceName](#schema-namespacename) | yes | | **Responses** - **204**: Deleted. - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: You aren't an Owner, or you sent an API key. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Change a Namespace's settings ```http PATCH /v1/namespaces/{namespace} ``` operationId: `updateNamespace` · [reference](https://www.caprid.com/docs/reference/http/account/operations/updatenamespace/) Fields you leave out are left alone. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `namespace` | path | [NamespaceName](#schema-namespacename) | yes | | **Request body** Body `application/json`: [NamespaceSettings](#schema-namespacesettings) ```json { "platform_peering": true } ``` **Responses** - **200**: The Namespace, with the change applied. - `application/json`: [Namespace](#schema-namespace) - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ## API keys `sb_...` keys that authenticate calls to this API. A relay never accepts one. A key can be limited to some of its Organization's Namespaces when it is created. ### List API keys ```http GET /v1/organizations/{id}/api-keys ``` operationId: `listAPIKeys` · [reference](https://www.caprid.com/docs/reference/http/account/operations/listapikeys/) Oldest first. Secrets are never listed, only a preview of each. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Responses** - **200**: The Organization's API keys, expired ones included. - `application/json`: array of [APIKey](#schema-apikey) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Create an API key ```http POST /v1/organizations/{id}/api-keys ``` operationId: `createAPIKey` · [reference](https://www.caprid.com/docs/reference/http/account/operations/createapikey/) The response is the only time the secret is shown. Switchboard keeps only a hash of it. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `label` | [Label](#schema-label) | | A name to tell the key apart from others. Defaults to `api`. | | `expires_in` | integer | | Seconds until the key stops working. `0` means never. | | `namespaces` | array of [NamespaceName](#schema-namespacename) | | Limits the key to these Namespaces, each a live Namespace of the Organization. Leave it out for a key that reaches the whole Organization. A limited key manages only the Signing keys, Client tokens and settings of its Namespaces, and can't create API keys. You can't change the limit later. | ```json { "label": "staging-ci", "expires_in": 7776000, "namespaces": [ "acme-staging" ] } ``` **Responses** - **201**: The new API key, with its secret. - `application/json`: [NewAPIKey](#schema-newapikey) - **400**: The request is malformed or a field is invalid. The message says which. - `application/json`: [Error](#schema-error) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Revoke an API key ```http DELETE /v1/organizations/{id}/api-keys/{key} ``` operationId: `deleteAPIKey` · [reference](https://www.caprid.com/docs/reference/http/account/operations/deleteapikey/) The key stops working on the next request that presents it. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `id` | path | integer | yes | The Organization's `id`, from `GET /v1/organizations`. | | `key` | path | string | yes | The key's `id` from the list. Not its secret. | **Responses** - **204**: Revoked. - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ## Signing keys Organization signing keys, which sign Client tokens. You can hold the secret and sign Client tokens in your own backend without calling Switchboard. ### List Signing keys ```http GET /v1/namespaces/{namespace}/keys ``` operationId: `listSigningKeys` · [reference](https://www.caprid.com/docs/reference/http/account/operations/listsigningkeys/) Oldest first, without their secrets. Get a key by its `kid` to see its secret. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `namespace` | path | [NamespaceName](#schema-namespacename) | yes | | **Responses** - **200**: The Namespace's Signing keys. - `application/json`: array of [SigningKeySummary](#schema-signingkeysummary) - **401**: No valid credential. Missing, expired and revoked credentials all get the same response, and so does an API key whose trial has lapsed. An Agent token from before a claim gets a 401 too, once the claim has made the trial a person's, with a message saying to get a new token. - `application/json`: [Error](#schema-error) - **403**: Sent with an API key limited to some Namespaces, to a Namespace outside its limit, or to a route that acts on the Organization as a whole. A limited key can never create API keys. - `application/json`: [Error](#schema-error) - **404**: No such thing, or not yours. Another customer's Organization or Namespace is a 404 too, so nobody can tell which ones exist. - `application/json`: [Error](#schema-error) ### Create a Signing key ```http POST /v1/namespaces/{namespace}/keys ``` operationId: `createSigningKey` · [reference](https://www.caprid.com/docs/reference/http/account/operations/createsigningkey/) Creates an Organization signing key in this Namespace and returns it as a JWK, secret included. Keep the secret in your backend to sign Client tokens there without calling Switchboard. The key's scope is fixed when you create it and can't be widened later. By default, a key covers your whole Namespace for both publishing and subscribing. Use `path` to root it lower down, and `publish` or `subscribe` to limit what the tokens it signs may grant. If you send only one of `publish` and `subscribe`, the key can't sign tokens for the other role. Relays look keys up by `kid` through Switchboard, never from the Client, so a relay accepts a token signed with this key only within its scope. **Parameters** | Name | In | Type | Required | Description | |---|---|---|---|---| | `namespace` | path | [NamespaceName](#schema-namespacename) | yes | | **Request body** Body `application/json`: object | Field | Type | Required | Description | |---|---|---|---| | `label` | [Label](#schema-label) | | Becomes part of the `kid`, which is `_