Skip to content

Directory and relay API

This is the developer-facing reference for the FOSS directory + relay API: the HTTP + WebSocket surface that lets you build a custom client that joins an end-to-end-encrypted (MLS / RFC 9420) channel, where the relay never sees plaintext. It is the Stage-0 keystone of the open-core split: this surface is FOSS (protocol / relay / directory + welcome), and only the rich layer (roles beyond owner-approve, org policy, billing, the hosted-relay account gating) is commercial.

The 8-operation surface below already ships end-to-end in skytale. You do not need to build any crypto: the MLS engine (key packages, Welcomes, group operations, ciphertext) lives in the SDK; this document is about the directory + transport the relay and API expose around it.

For the FIRM contract (the locked op table the FE and orchestrator integrate against), see docs/specs/2026-06-18-foss-directory-relay-api-design.md in the skytale repo. This reference documents the actually-shipped endpoint shapes; where it adds detail beyond the firm contract (e.g. exact JSON field names, status codes), the shipped code is authoritative.

A channel client talks to two services:

Host Default URL Role
API https://api.skytale.sh The directory + welcome plane. Channel registration, invite tokens, the key-package directory, and the Welcome handoff. Postgres-backed; auth = your account API key (sk_...) or a minted Bearer JWT.
Relay https://relay.skytale.sh The message plane. The zero-knowledge transport: a WebSocket for live publish/subscribe, plus two HTTP catch-up endpoints (GET /v1/messages, GET /v1/sync). Holds only ciphertext; auth = a Bearer JWT.

Every relay request and every directory request is authenticated with a Bearer JWT minted by the API account layer.

  • The JWT is an HS256 token with iss: skytale-api, aud: skytale-api, a near-future exp (the API caps it at 300s), and sub = the account UUID. Your account API key (sk_live_…) is exchanged for one via the API (POST /v1/tokens{ token }); the SDK does this for you. (The relay’s validator accepts either skytale-api or skytale-relay as the audience and requires iss: skytale-api.)
  • Relay auth bar (/v1/messages, /v1/sync, the WebSocket): a valid JWT + a well-formed org/ns/svc channel name is sufficient to read or write any channel. This deliberately mirrors the relay’s existing gRPC Subscribe self-grant: the relay has no account-to-channel table, so it does not check channel ownership; that gate lives in the directory plane (the API) and in the MLS group itself (you cannot decrypt a channel you were never add_member’d to). The relay additionally honors revocation: if your did:skytale:{sub} has been revoked, relay endpoints return 403 Forbidden.
  • Directory auth bar (/v1/channels/*): the API’s AuthContext (your account API key or session). Owner-only operations (GET /pending, POST /welcome) additionally verify you own the channel.

Pass the token as Authorization: Bearer <JWT> on HTTP requests; on the WebSocket, send it in the first frame (see Messages).

Channel identity: names, group-ids, and credentials

Section titled “Channel identity: names, group-ids, and credentials”

Channel name is always a 3-component, non-empty org/namespace/service string (e.g. acme/payments/agent-v1). The API validates: max 256 bytes, only [A-Za-z0-9/_.-], exactly 3 non-empty parts.

Group-id = SHA-256("org/ns/svc"). The hash is taken over the literal bytes org, /, namespace, /, service concatenated. This is computed identically by:

  • the relay (NameRegistry::hash_name), and
  • the SDK (channel_id_from_name, sdk/src/channel.rs).

The creator sets this group-id when creating the MLS group; the joiner does NOT derive it independently: it arrives inside the Welcome (join_from_welcome). Convergence is therefore guaranteed by construction. If you build your own client, derive the transport channel-id with the same SHA-256("org/ns/svc") and pass it to your create_group(group_id) on the create path; on the join path, take the group-id from the Welcome and do not re-derive.

Identity is a did: string passed raw. You hand the same string to both the /v1/channels/join request (the identity field, opaque TEXT, 1 to 256 chars) and the MLS engine constructor. The engine detects the did: prefix and wraps it into the MLS credential as CBOR for you (build_identity, crates/skytale-mls-flutter/src/api/mls.rs):

  • did:key:… → a DidKey credential,
  • any other did:… → a DidWeb credential,
  • anything not starting with did: → a raw 32-byte key credential.

Do not hand-roll the credential bytes. For roster matching, the identity string you send to /join must be byte-identical to the credential URI the engine builds (i.e. the same raw did: string). MLS authenticates by the credential’s bound signature key, not by these identifier bytes, so the embedded pubkey is a deterministic per-device HKDF value; the identifier is for the roster, not for authentication.

All paths below are on https://api.skytale.sh. All bodies are JSON; all key_package / welcome fields are base64-encoded MLS bytes.

POST /v1/channels: owner registers a channel (idempotent upsert per account).

// Request
{ "name": "acme/payments/agent-v1" }
// 201 Created
{ "id": "<uuid>", "name": "acme/payments/agent-v1" }

POST /v1/channels/invites: owner-only (must own the channel). Mints the skt_inv_… token a joiner uses to publish a key package.

// Request
{ "channel": "acme/payments/agent-v1", "max_uses": 1, "ttl_seconds": 3600 }
// 201 Created
{ "token": "skt_inv_…", "expires_at": "<rfc3339>" }

max_uses defaults to 1 (range 1 to 100); ttl_seconds defaults to 3600 (range 60 to 604800, i.e. 1 minute to 7 days).

Publish a key package (op 1, publishKeyPackage)

Section titled “Publish a key package (op 1, publishKeyPackage)”

POST /v1/channels/join: a would-be member submits its MLS key package + identity, gated by an invite token. Cross-organization joins pass a federation_token instead of (or in addition to) the standard invite path.

// Request
{
"channel": "acme/payments/agent-v1",
"token": "skt_inv_…", // standard invite token
"key_package": "<base64 MLS key package>",
"identity": "did:web:agent.example.com",
"federation_token": "skt_fed_…" // OPTIONAL: cross-org join (bypasses normal invite validation)
}
// 201 Created
{ "request_id": "<uuid>" }

Validation: key package ≤ 64 KB; identity 1 to 256 chars; the channel must have fewer than 1000 pending requests. When federation_token is present, the API validates it against the federation_invites table (consuming one use, org-matched) and proceeds through the same downstream flow.

Fetch pending key packages (op 2, fetchKeyPackages)

Section titled “Fetch pending key packages (op 2, fetchKeyPackages)”

GET /v1/channels/pending?channel=acme/payments/agent-v1: owner-only (must own the channel). Returns the queue of join requests awaiting approval.

// 200 OK - NOTE: an object with a `requests` array, NOT a bare array
{
"requests": [
{
"id": "<uuid>", // the request_id to pass to /welcome
"identity": "did:web:agent.example.com",
"key_package": "<base64 MLS key package>",
"created_at": "<rfc3339>"
}
]
}

Ordered ascending by created_at. An empty queue is { "requests": [] }.

POST /v1/channels/welcome: owner-only. After the owner runs add_member with a pending key package, it uploads the resulting MLS Welcome, which marks the request completed.

// Request
{ "request_id": "<uuid>", "welcome": "<base64 MLS Welcome>" }
// 200 OK
{ "status": "completed", "welcome": null }

Welcome ≤ 256 KB. Returns 404 Not Found if the request is not pending or the caller does not own the channel.

GET /v1/channels/welcome/{request_id}: the joiner polls its own request (scoped to the caller’s account) until the Welcome is ready.

// 200 OK while waiting
{ "status": "pending" }
// 200 OK once the owner has submitted
{ "status": "completed", "welcome": "<base64 MLS Welcome>" }

welcome is omitted until status == "completed". 404 Not Found if the request id does not belong to the caller.

Acknowledge + GC a completed Welcome (op 5, ackWelcome)

Section titled “Acknowledge + GC a completed Welcome (op 5, ackWelcome)”

POST /v1/channels/welcome/{request_id}/ack: the joiner, after it has fetched its Welcome and joined the group, drops the now-useless request row.

// 200 OK
{ "acked": true }

Scoped to the caller’s own account and to status = 'completed', so it can never delete another account’s request or an in-flight one. Idempotent: a second call (or a request that no longer exists) returns { "acked": false }. A background sweep on the API also GCs completed/expired requests after 24h, so calling ack is an optimization, not a correctness requirement.

All message I/O is on https://relay.skytale.sh. Payloads are always base64-encoded MLS ciphertext; the relay cannot read them.

Live transport: the WebSocket (op 6 postMessage + op 7 fetchMessages, live)

Section titled “Live transport: the WebSocket (op 6 postMessage + op 7 fetchMessages, live)”

Connect to wss://relay.skytale.sh/ws. The protocol is JSON frames, tagged by a type field:

  1. Auth (first frame, required):

    { "type": "auth", "token": "<JWT>" }

    The server replies { "type": "ok", "message": "authenticated" } on success, or { "type": "error", "message": "…" } and closes on failure. You have 10s to send auth; the first frame MUST be auth.

  2. Subscribe (required before you can publish to a channel):

    { "type": "subscribe", "channel": "acme/payments/agent-v1" }

    { "type": "ok", "message": "subscribed to acme/payments/agent-v1" }. Max 50 channels per connection.

  3. Publish (op 6, postMessage):

    { "type": "publish", "channel": "acme/payments/agent-v1", "payload": "<base64 ciphertext>" }

    You must be subscribed to the channel first. Payload ≤ 1 MB (decoded). → { "type": "ok", "message": "published" }.

  4. Server push (op 7, fetchMessages, live): the relay pushes every message on a subscribed channel as:

    { "type": "message", "channel": "acme/payments/agent-v1", "payload": "<base64 ciphertext>" }
  5. Unsubscribe: { "type": "unsubscribe", "channel": "…" }.

The WebSocket is live-only: it does NOT replay history. A client that was offline uses the two HTTP catch-up endpoints below, then resumes the WebSocket.

Cold-open backfill (op 7, fetchMessages, history): GET /v1/messages

Section titled “Cold-open backfill (op 7, fetchMessages, history): GET /v1/messages”

GET /v1/messages?channel=acme/payments/agent-v1&since_seq=<u64>&limit=<usize>: forward (ascending) scan of app messages only (entry_type 0) from the archive. This is the cold-open history path: a brand-new client with no local state replays the channel’s decryptable ciphertext.

// 200 OK - NOTE: an object with `messages` + `has_more`, NOT a bare array
{
"messages": [
{ "seq": 1, "payload": "<base64 ciphertext>", "ts": 1718000000000 }
],
"has_more": false
}
  • seq is the per-channel monotonic sequence number, the pagination cursor. ts is the Unix-millisecond archive time.
  • since_seq (default 0) returns only entries with sequence > since_seq. since_seq=0 is a full cold-open from the start of retained history.
  • limit defaults to 100, hard-capped at 500.
  • Paging: re-request with since_seq = messages.last().seq while has_more == true. has_more is computed server-side; do not infer pagination from messages.len() == limit. (That inference is broken by the limit cap: a client requesting more than 500 gets a full-but-capped page whose length never equals the request, and would wrongly conclude it was caught up.)
  • “Full history” is bounded by the per-channel archive ring-buffer cap + TTL; evicted messages are gone.

GET /v1/sync?channel=acme/payments/agent-v1&since_seq=<u64>&limit=<usize>: forward scan of the FULL log, ALL entry types, including MLS commits and Welcomes. An offline member uses this on reconnect to advance its MLS epoch: /v1/messages (app-only) cannot, because it filters out the commits the member missed.

// 200 OK
{
"entries": [
{ "seq": 1, "entry_type": 0, "payload": "<base64>", "ts": 1718000000000 },
{ "seq": 2, "entry_type": 1, "payload": "<base64>", "ts": 1718000000001 },
{ "seq": 3, "entry_type": 2, "payload": "<base64>", "ts": 1718000000002 }
],
"has_more": false
}
  • entry_type: 0 = app message, 1 = commit, 2 = Welcome.
  • On reconnect, feed every entry_type == 1 (commit) entry to your MLS engine (process_message) to advance the epoch, and treat entry_type == 0 entries as chat. (Entry-type 2 Welcomes are present for completeness; a member already in the group does not consume them.)
  • Same since_seq / limit (default 100, cap 500) / server-computed has_more semantics as /v1/messages, and the same paging rule (since_seq = entries.last().seq while has_more).
  • Same auth bar (valid JWT + 3-part channel + revocation check).

There are three roles a client plays. The Python SDK SkytaleChannelManager (sdk/python/skytale_sdk/channels.py) and the Rust SkytaleClient (sdk/src/client.rs) are the reference implementations; port or wrap their patterns.

The owner must be online to admit joiners (it is the only party that can run add_member and produce Welcomes).

  1. create_channel("org/ns/svc") → builds the MLS group with group-id SHA-256("org/ns/svc"), subscribes on the relay.
  2. POST /v1/channels to register the channel in the directory.
  3. Run the add-loop (the SDK does this on a background thread, _join_poll_loop):
    • GET /v1/channels/pending?channel=…
    • take one request per cycle (rate-limits MLS epoch advancement), base64-decode its key_package, call add_member(key_package) → produces a Welcome (and a commit that fans out to existing members over the relay) →
    • POST /v1/channels/welcome { request_id, welcome }.

The reference loop processes at most one join per channel per poll cycle, exactly so that N joiners produce N sequential single-add commits rather than a burst.

Joiner: generate KP → publish → poll → join

Section titled “Joiner: generate KP → publish → poll → join”
  1. generate_key_package() → an MLS key package; base64-encode it.
  2. POST /v1/channels/join { channel, token, key_package, identity }{ request_id }. (Use federation_token for a cross-org join.)
  3. Poll GET /v1/channels/welcome/{request_id} until status == "completed"; base64-decode welcome.
  4. join_from_welcome(welcome) (the SDK’s join_channel(name, welcome)) → joins the MLS group (group-id arrives in the Welcome, do not re-derive), subscribes on the relay.
  5. Optionally POST /v1/channels/welcome/{request_id}/ack to GC the request.

Reconnect (returning member): sync, then resume

Section titled “Reconnect (returning member): sync, then resume”

A member with local MLS state that was offline:

  1. GET /v1/sync?channel=…&since_seq=<last-seq-seen> → feed entry_type == 1 commit entries to the MLS engine (epoch advance) and entry_type == 0 entries to chat; page until has_more == false.
  2. Resume the WebSocket (subscribe + live publish/message).
  3. For cold-open chat history UX (a fresh client with no local state), GET /v1/messages instead.

Adding N members is sequential single adds, not a batched commit: each joiner publishes its own join request; the owner’s add-loop admits them one per cycle; the relay fans out each Add-commit so existing members stay in epoch sync. This is the same shape the live dev mesh runs (one MLS group, 5 members). The skytale test suite includes an N>2 group-convergence + fan-out integration test (sdk/tests/group_convergence.rs) that exercises owner-creates → N-joiners-add-loop → owner-sends → all N receive.

# Operation Surface
1 publishKeyPackage POST /v1/channels/join {channel, token, key_package, identity, federation_token?}{request_id} (API)
2 fetchKeyPackages GET /v1/channels/pending?channel=…{requests:[{id, identity, key_package, created_at}]} (API, owner-only)
3 publishWelcome POST /v1/channels/welcome {request_id, welcome}{status, welcome} (API, owner-only)
4 fetchWelcomes GET /v1/channels/welcome/{request_id}{status, welcome?} (API, joiner)
5 ackWelcome POST /v1/channels/welcome/{request_id}/ack{acked} (API, joiner; GC)
6 postMessage relay WebSocket {type:"publish", channel, payload}
7 fetchMessages live: relay WebSocket server-push {type:"message", channel, payload} · cold-open backfill: GET /v1/messages?channel=&since_seq=&limit={messages:[{seq, payload, ts}], has_more} (relay, app-only)
8 syncLog GET /v1/sync?channel=&since_seq=&limit={entries:[{seq, entry_type, payload, ts}], has_more} (relay, full log incl. commits, epoch-catchup)
  • The FIRM contract: docs/specs/2026-06-18-foss-directory-relay-api-design.md in the skytale repo.
  • Reference clients: sdk/python/skytale_sdk/channels.py (Python SkytaleChannelManager), sdk/src/client.rs + sdk/src/channel.rs (Rust SkytaleClient).
  • Shipped endpoint code: api/src/routes/channels.rs (directory + welcome), relay/src/main.rs (/v1/messages, /v1/sync), relay/src/websocket.rs (the WebSocket).