Integrations & API · v1

Build with the relaii API

Use the relaii API to post notifications, update channel widgets or connect an application to your Space. Start with the quickstart below, then use the endpoint reference for the requests you need.

Replace https://chat.example.no in examples with your Space address. Use /api/integrations/v1 with an API key, /api/client/v1 with a user session, or your secret /hooks/:secret URL to post a webhook message.

Using an AI agent? Point it to api.md, a Markdown edition of this reference written for agents.

Before using an endpoint, request GET /.well-known/relaii to check the API versions, URLs and capabilities your Space supports. The installed relaii release and the API version are separate: use the advertised API version when choosing an endpoint.

Choose an integration

Choose the smallest API that fits the job. A webhook posts notifications to one channel. A URL widget embeds a cached HTML page. A scoped API key posts bot messages and updates block widgets. The client API works with a user’s session.

Send your first request

Use the address of your own Space in every example. There is no central relaii API account or API host.

TaskBase pathCredential
Post a notification/hooks/:secretThe secret URL
Build an integration/api/integrations/v1A scoped rak_… key
Build a client/api/client/v1A user session
Connect servers/api/federation/v1Peer signing keys

1. Check the Space

curl --fail-with-body --silent --show-error \
  https://chat.example.no/.well-known/relaii

This public request needs no credential. The response advertises the API URLs, protocol versions and supported capabilities.

2. Create an integration key

Open Admin → API keys in your Space. Name a bot, grant it post_messages, and copy the key — the permission applies to every channel on the Space, so any channel’s ID works below. Set RELAII_API_KEY in your shell or secret manager and replace ROOM_ID with that channel’s ID.

3. Post a message

curl --fail-with-body --silent --show-error \
  https://chat.example.no/api/integrations/v1/rooms/ROOM_ID/messages \
  -H "Authorization: Bearer $RELAII_API_KEY" \
  -H 'Content-Type: application/json' \
  --data '{"text":"Hello from my integration","client_message_id":"019c1234-5678-7000-8000-123456789abc"}'

Expect 201 with the message inside data. Generate a new UUID for each new message; reuse the same UUID when retrying that message. A successful retry returns 200.

A rak_… key works only on the integration API and only within the permissions it was granted — which apply to every local channel on the Space, not a selected subset. For a user’s rooms, messages and sync, follow client authentication.

If a request returns 401 or 403, check the API base path, credential type, key expiry and permissions.

Conventions

  • Request and response bodies are JSON, UTF-8, unless noted.
  • Timestamps are RFC 3339 with microseconds, always UTC: 2026-08-30T13:04:05.123456Z.
  • Identifiers are UUIDv7 strings.
  • Sequence numbers and cursors are JSON numbers (64-bit integers).
  • Unknown fields in a response must be ignored. Fields are never removed or repurposed within v1.

Authentication

Two interchangeable mechanisms, both accepted on every authenticated client-API endpoint:

  • Cookies (the web client). relaii_at and relaii_rt are HttpOnly, SameSite=Lax, and Secure on HTTPS. Unsafe methods must also send the CSRF token from relaii_csrf in the X-Relaii-CSRF header.
  • Bearer (scripts and native clients). Authorization: Bearer <access_token>. No CSRF header is required.

Access tokens last 15 minutes. Refresh tokens last 30 days and rotate on every use: POST /auth/refresh returns a new refresh token and the presented one stops working.

Incoming webhooks do not use either mechanism. The secret in the path is the credential.

Errors

Every error uses the same envelope:

{
  "error": {
    "code": "forbidden",
    "message": "You do not have permission to post in this room.",
    "details": {}
  }
}

code is stable. message is English and may change. details is present only where noted.

HTTPcode
400invalid_request, invalid_cursor
401unauthenticated, invalid_credentials, token_expired, invalid_refresh_token
403forbidden, account_disabled, setup_required, federation_disabled, quota_exceeded
404not_found
409conflict, already_exists, owner_exists, api_key_limit_reached, widget_limit_reached, link_limit_reached
413payload_too_large
422validation_faileddetails is a field → messages map
429rate_limiteddetails.retry_after_seconds and a Retry-After header
503storage_unavailable, not_ready

Pagination

To fetch a list in pages, read { "data": [], "next_cursor": "…", "has_more": true }. When has_more is true, send the returned next_cursor as ?cursor=… in your next request. Treat cursors as opaque strings.

For message history, use before or after with a room_sequence value instead. See Messages for the response format and limits.

Discovery

Check your Space’s address, supported features and availability with these public endpoints. They do not require a session; use each full path shown below.

MethodPathWhat it does
GET /.well-known/relaii Product, versions, URLs, keys, capabilities. Served even before the first account exists.
GET /health/live Process is up.
GET /health/ready Ready to take traffic (database reachable).
GET /api/client/v1/server Public Space identity and policy. Available after initial setup; no session required.
curl -s https://chat.example.no/.well-known/relaii

The document names client_api_url, federation_api_url, websocket_url, client_api_versions, federation_versions and a capabilities map (webhooks, search, federation, file_uploads, websocket, email, password_reset, push).

Incoming webhooks

Use a webhook to send notifications to one channel. Create it in that channel or through the management API, then copy its secret URL. Messages appear under the webhook’s bot name and support mentions, unread indicators and delivery to connected Spaces.

Webhooks can post messages in basic or blocks format. To create channels or manage members, use the client API with a user session. Neither webhook message format renders HTML.

Webhook playground

Build your next message.

Compose, preview and copy a payload for your Space.

Message blocks

Add a block

Live preview

Preview

Bold and inline code are supported here. Full Markdown depends on the client. Choose an image to load it.

Send a test message Connect your webhook

Send to your webhook

The URL is a secret. It stays in this page's memory and is sent only to its destination when you press Send. It is excluded from downloads and the curl template. This page has no analytics or third-party scripts.

An unchanged retry reuses its delivery ID to prevent duplicate messages. Set external_id in JSON to choose your own; change it when you want another identical message.

Allow browser sending on your server

Sending goes directly to your server. It needs to allow requests from https://relaii.app on the webhook endpoint. This does not grant access to account or administration APIs. If your installation does not have this rule yet, add the following inside its Caddy site block and validate and reload Caddy:

@webhook_builder {
    path /hooks/*
    method POST OPTIONS
    header Origin https://relaii.app
}
header @webhook_builder {
    Access-Control-Allow-Origin https://relaii.app
    Access-Control-Allow-Methods POST
    Access-Control-Allow-Headers Content-Type
    Access-Control-Max-Age 600
    +Vary Origin
}
@webhook_builder_preflight {
    path /hooks/*
    method OPTIONS
    header Origin https://relaii.app
    header Access-Control-Request-Method POST
}
handle @webhook_builder_preflight {
    respond 204
}

No cookies or account tokens are sent. The webhook secret is still required. If a browser request cannot be confirmed, check the room before retrying, or use the curl template from your terminal.

Post a message

POST /hooks/:secret

Basic messages can be JSON or application/x-www-form-urlencoded. Block messages must be JSON because blocks is an array. The complete request body is capped at 64 KiB.

FieldRequiredMeaning
formatnobasic (the default) or blocks.
textbasic: yes
blocks: no
Plain-text/Markdown message, at most 16,000 characters. For blocks it is the fallback; when omitted or empty, the server derives one from the blocks.
namenoDisplay name for this message, at most 80 characters. It does not rename the bot or impersonate an account.
sendernoLegacy alias for name. If both are present, name wins.
avatar_urlnoAbsolute HTTP(S) avatar URL for this message, at most 2,048 characters.
external_idnoIdempotency key, at most 255 characters. Repeating one returns the original message_id and writes nothing. Omit it only when two identical posts should become two messages.

Basic messages

format defaults to basic, so the smallest valid body is { "text": "…" }.

curl -X POST https://chat.example.no/hooks/relaii_hook_… \
  -H 'content-type: application/json' \
  -d '{
    "format": "basic",
    "text": "Deploy **finished**",
    "name": "CI",
    "avatar_url": "https://ci.example.no/avatar.png",
    "external_id": "build-112"
  }'

Block messages

Set format to blocks and send 1–20 blocks in the order you want them displayed. Use the JSON fields in the table below. The validated block array must fit within 60 KiB.

{
  "format": "blocks",
  "name": "Release bot",
  "avatar_url": "https://ci.example.no/avatar.png",
  "blocks": [
    {
      "type": "title",
      "text": "Deployment complete"
    },
    {
      "type": "text",
      "text": "Build **412** passed all checks."
    },
    {
      "type": "image",
      "url": "https://ci.example.no/build-412.png",
      "alt": "Deployment summary"
    },
    {
      "type": "table",
      "columns": ["Service", "Status", "Version"],
      "rows": [
        ["API", "Healthy", "1.0.8"],
        ["Web", "Healthy", "1.0.8"]
      ]
    },
    {
      "type": "actions",
      "buttons": [
        {
          "text": "Open build",
          "url": "https://ci.example.no/builds/412"
        },
        {
          "text": "View logs",
          "url": "https://ci.example.no/builds/412/logs"
        }
      ]
    }
  ],
  "external_id": "build-412"
}
BlockShape and limits
title{ "type": "title", "text": "…" }. Required plain text, at most 200 characters.
text{ "type": "text", "text": "…" }. Required Markdown text, at most 16,000 characters. HTML is shown as text.
imageRequired absolute HTTP(S) url; optional alt, at most 300 characters.
tableOne to four non-empty columns (120 characters each), and one to 50 rows. Every row must have exactly one string cell per column; a cell may contain at most 500 characters.
actionsOne to four link buttons. Each needs text (at most 80 characters) and an absolute HTTP(S) url.
Fallback text. text stays available for search, notifications, accessibility and clients that do not render blocks yet. If you omit it, relaii derives it from titles, text, image alt text, table cells and button labels, up to 16,000 characters.

Use absolute http:// or https:// URLs for images, avatars and actions. Each URL can contain at most 2,048 characters and must have no whitespace or embedded credentials. A successful webhook request confirms the message was accepted; it does not confirm that linked images or pages are reachable.

{ "ok": true, "message_id": "019c…" }
HTTPWhen
200Posted, or the same external_id already landed.
400 invalid_requestMissing basic text, an unknown format, invalid blocks or URL, or an unusable external_id.
404 not_foundCheck that you copied the complete webhook URL and that the webhook is enabled. If it was revoked or rotated, obtain a new URL from your channel manager.
409 conflictThe room is archived, or a replay could not be resolved.
413 payload_too_largeThe body is larger than 64 KiB.
429 rate_limitedOver the webhook’s per-minute budget (default 60, configurable up to 6000).

Use the authenticated management endpoints to create, list, rotate or revoke webhooks. Save the secret URL when you create or rotate a webhook; you cannot retrieve it later. To receive message events in an application, use sync or WebSocket.

Embed a URL widget in a channel

URL widgets require relaii 1.0.14 or later. Open Admin → Widgets, select a local channel and add a widget. Enter its HTTPS URL, set any bearer token it requires, then choose its cache duration, lazy loading, height and enabled state. Channel managers can also use the channel’s Widgets view.

Layout controls depend on your installed version. If your Space offers an aspect ratio or maximum dimensions, use them to fit the embedded page to the channel. You need channel management permission and any separate widget permission required by your Space.

Host the widget as an HTML page at a public HTTPS address. relaii fetches and caches that page for channel members. If the page requires authentication, supply its bearer token; relaii sends it only to the configured URL. The token and cached page are encrypted on your Space.

Download the single-file PHP widget demo. Upload widget.php to your HTTPS web host and use its address as the widget URL. Try ?scenario=normal, ?scenario=busy or ?scenario=incident. The generation time and fetch ID make cache behavior visible.

SettingBehavior
URLDirect public HTTPS URL returning HTTP 200 and UTF-8 text/html, at most 512 KiB. Redirects and private-network targets are rejected.
Bearer tokenOptional secret sent by the server. Never returned to channel members. Leave unchanged when editing, or explicitly remove it.
Cache duration0–86400 seconds; default 300. Shared between channel members. 0 fetches on each open/reload without periodic refresh.
Lazy loadingOn by default: fetch when the widget becomes visible. Visible widgets with a positive TTL refresh at that interval, with a 30-second minimum.
Height / enabled160–1200 pixels; default 360. Disabled widgets do not fetch content.

Make the widget a self-contained HTML document: include its CSS and JavaScript inline, and embed images and fonts with data: URLs. The widget can run inline scripts but cannot access relaii, browser storage or external resources, submit forms or open popups. A page that depends on cookie login or additional network requests will not work. Use an Apple or Android app version that supports URL widgets to view it on those platforms.

Normal authenticated client sessions manage widgets at /api/client/v1:

MethodEndpointPurpose
GET/admin/widgetsAdministrator channel picker.
GET/rooms/:id/contentWidget metadata and shared links. Never includes a bearer token.
POST/rooms/:id/widgetsCreate a widget in a channel you manage.
PATCH/rooms/:id/widgets/:widget_idEdit settings; URL, token, cache or enablement changes invalidate the cached snapshot.
DELETE/rooms/:id/widgets/:widget_idRemove a widget.
GET/rooms/:id/widgets/:widget_id/contentAuthorized snapshot as JSON for native clients.
GET/rooms/:id/widgets/:widget_id/renderAuthorized HTML with sandbox headers for web embedding.
POST /api/client/v1/rooms/:id/widgets
Content-Type: application/json

{
  "title": "Operations",
  "source_url": "https://dashboard.example.com/widget.php",
  "bearer_token": "optional-upstream-secret",
  "cache_seconds": 300,
  "lazy_load": true,
  "height": 420,
  "enabled": true
}

Cookie-authenticated writes require the usual CSRF header. On update, omit bearer_token to keep it or send an empty string to remove it. Responses include source_type: "url" and has_bearer_token. Both snapshot endpoints recheck channel access, even when content is cached. Expired snapshots are not served after a failed refresh.

API keys and block widgets

To create a key, open Admin → API keys in the web app. Choose a name, a bot name, an expiry and one or more permissions, then copy the secret into your integration’s secret store. It is shown only once. Use the key list to check last use, edit its permissions, or revoke a key you no longer need. Revoking deactivates the bot; its earlier messages keep it as their sender.

Machine requests use Authorization: Bearer rak_… and the separate base path /api/integrations/v1. Each permission — create_channels, edit_channels, close_channels, manage_members, create_webhooks, manage_widgets and post_messages — applies to every local channel on the Space, not a selected subset; the key’s bot is seated in a channel automatically the first time it acts there. A channel shared with other Spaces is reachable only by keys on the Space that owns it — never a guest Space’s key. Keys cannot sign in as a user or access administration. A key has its own bot identity.

MethodIntegration endpointPurpose
POST/rooms/:id/messagesPost a bot message. Send text and a UUID client_message_id; retain that UUID for retries. Requires post_messages.
GET/rooms/:id/widgetsRead the current widget list and revisions.
POST/rooms/:id/widgetsCreate a block widget. Requires manage_widgets.
PUT/rooms/:id/widgets/:widget_idUpdate an existing widget in place. Requires manage_widgets.
DELETE/rooms/:id/widgets/:widget_idDelete a widget. Requires manage_widgets.

A key with manage_widgets can create the block widget itself, or you can create one from a user session at POST /api/client/v1/rooms/:id/widgets with channel management permission. Either way, save the returned widget ID; your integration can then update it in place:

PUT /api/integrations/v1/rooms/:id/widgets/:widget_id
Authorization: Bearer <integration-key>
Content-Type: application/json

{
  "revision": 1,
  "stale_after_seconds": 300,
  "blocks": [
    {"type": "metric", "label": "Availability", "value": "99.98%", "status": "good"},
    {"type": "chart", "label": "Requests", "unit": "req/min", "points": [
      {"label": "12:00", "value": 125},
      {"label": "12:01", "value": 148}
    ]}
  ]
}

Increase the integer revision for each update. Repeating the same revision and content is safe; stale or conflicting revisions return 409. Read the latest revision before retrying a conflict. Widgets also support the existing text, title, image, table and action blocks. There are at most 20 blocks, 120 chart points and 60 KiB of normalized block data. Block payloads do not accept HTML or scripts; use URL widgets to embed a self-contained HTML document.

Each update replaces the widget’s displayed data and refreshes it for people viewing the channel. Set stale_after_seconds to show when that data is overdue for an update. Widget updates do not create chat messages, unread counts or push notifications. Access follows the channel’s permissions; send updates to the Space that owns the channel.

To manage keys programmatically, use an administrator’s session with GET, POST, PATCH or DELETE on /api/client/v1/admin/api-keys[/:id]. PATCH renames a key, renames its bot, or changes its permissions. After revoking a key, requests using it will no longer be accepted.

Every key in those responses includes bot_avatar_url: the address of the key’s bot avatar, or null for the default. To give the bot an avatar, use the same administrator session with the endpoints below, relative to /api/client/v1. Avatar changes take effect immediately; they are not part of PATCH.

MethodClient endpointPurpose
POST/admin/api-keys/:id/bot-avatar/uploadsReserve an upload. Send filename, mime_type and byte_size; JPEG, PNG, GIF, WebP and AVIF images up to 5 MB are accepted. Returns 201 with profile_image_upload_id and upload. PUT the image to upload.url with upload.headers.
POST/admin/api-keys/:id/bot-avatar/uploads/:upload_id/finalizeUse the uploaded image. Returns { "avatar_url" }.
POST/admin/api-keys/:id/bot-avatar/urlUse an image URL instead. Send url: an absolute http:// or https:// URL of at most 255 characters, otherwise 422. Returns { "avatar_url" }.
DELETE/admin/api-keys/:id/bot-avatarRemove the avatar. Returns { "avatar_url": null }.

Client API

Send client requests to https://YOUR_SPACE/api/client/v1 using a user session. Paths in the following client tables are relative to that base. Replace placeholders such as :id with the relevant ID from an earlier response. Before initial setup is complete, protected endpoints return 403 setup_required; use /setup/status to check and /setup/admin to create the owner account.

Authentication and session

MethodPathAuthWhat it does
GET /setup/status no { "setup_required", "server_id", "product_version" }. Use this to check whether you need to create the owner account.
POST /setup/admin no Creates the first user, who becomes owner and admin. Succeeds once; afterwards 409 owner_exists.
POST /auth/login no { "login", "password" }. Returns the user plus access_token, refresh_token, expires_at, and sets the cookies.
POST /auth/refresh no Rotates the refresh token. Body { "refresh_token" } or the relaii_rt cookie.
POST /auth/register no Open registration when the server allows it.
POST /auth/password-reset no { "email" }. Always 200, whether the address exists or not.
POST /auth/password-reset/confirm no { "token", "password" }. Revokes every session.
POST /auth/logout yes Revokes this session. 204.
GET /auth/me yes The signed-in user, including email, timezone and notification_preference.
PATCH /auth/me yes Profile and notification_preference (all | mentions | dm_and_mentions | nothing).
POST /auth/password yes { "current_password", "password" }. Drops every other session.
POST /auth/socket-token yes A one-minute, socket-only token for the WebSocket query string. Do not put a full access token in a URL.
GET /auth/sessions yes This account’s live sessions.
DELETE /auth/sessions/:id yes Revoke one session. 204.

Successful login, setup and accepting an invitation return the same session shape. If login returns data.mfa_required: true, first exchange data.mfa_token and a TOTP or recovery code at POST /auth/mfa to receive the session. A browser uses the cookies; a script uses the tokens in the body. The server always sends both.

curl -s https://chat.example.no/api/client/v1/auth/login \
  -H 'content-type: application/json' \
  -d '{"login": "chris", "password": "…"}'

If the Space requires MFA enrollment, protected endpoints return 403 mfa_enrollment_required until the user completes setup. Profile, logout and MFA enrollment endpoints remain available.

MethodPathPurpose
POST/auth/mfaComplete login with mfa_token and code; no session required.
GET/auth/mfaRead enrollment status for the signed-in user.
POST/auth/mfa/setupBegin enrollment.
POST/auth/mfa/enableConfirm enrollment with a code.

Rooms and channels

Use room endpoints to create and manage conversations. Set kind to channel for a channel, direct for a one-to-one conversation, or group_direct for a group conversation. These requests require a user session.

MethodPathWhat it does
GET /rooms Rooms the caller can see. ?kind=channel or a comma list; ?include_archived=true to keep archived ones in.
POST /rooms Create a channel, open a DM, or start a group. 201.
GET /rooms/:id One room, same shape as the list.
PATCH /rooms/:id Rename, re-topic, change visibility, archive or restore. Needs manage, and only the homeserver may change it.
DELETE /rooms/:id Soft-delete. The room disappears from listings and refuses writes. Messages stay. Needs delete. 204.

Create a channel

POST /api/client/v1/rooms
{
  "kind": "channel",
  "name": "prosjekt-x",
  "topic": "Kundeprosjekt",
  "visibility": "private",
  "member_ids": ["019c…"]
}

name is required, 1–80 characters. The slug is derived from it (prosjekt-x) unless you send slug yourself — lowercase letters, digits and dashes. visibility is private (default) or public. A public channel is listed for everyone on the server. The creator becomes the room owner. Named members are added afterwards; a failed invite does not fail the creation.

Your account must have permission to create channels (create_channels). If the requested slug is already in use, the response is 422 with a validation error for slug; choose another slug and retry.

To open a direct conversation, send { "kind": "direct", "user_id": "…" }. If the conversation already exists, you receive that room. For a group, send kind: "group_direct" and at least two other people in member_ids or qualified_ids.

Rename, archive, restore

PATCH /api/client/v1/rooms/:id
{ "name": "prosjekt-y", "topic": "…", "visibility": "public", "archived": true }

To archive a room, send archived: true or an explicit archived_at timestamp. Its history remains readable, but new messages and webhook posts are rejected. Send archived: false to restore the room.

Delete

DELETE /api/client/v1/rooms/:id

Deleting a room sets deleted_at, removes it from room listings and prevents new writes. This action does not erase its stored message history.

Members

MethodPathWhat it does
GET /rooms/:id/members Who is in the room. Needs read.
POST /rooms/:id/members Add people. 201 with the memberships produced.
PATCH /rooms/:id/members/:member_id Change role, muted, or notification_preference.
DELETE /rooms/:id/members/:member_id Remove a member. Returns 204. Use the membership id returned by the member list for member_id.
POST /api/client/v1/rooms/:id/members
{ "member_ids": ["019c…"], "qualified_ids": ["alex@chat.partner.no"], "role": "member" }

Identify the people to add with user_id, member_ids or qualified_ids (name@server). For a single person in an array field, send a one-item array. Choose owner, moderator, member or guest. Unless you are an administrator, you cannot grant a role above your own. Repeating an add request does not duplicate membership. Send membership changes to the Space that owns the room.

notification_preference on a membership is the room override: default, all, mentions or muted.

Messages

MethodPathWhat it does
GET /rooms/:id/messages ?before=<seq>&after=<seq>&limit=<n>. Newest first. Limit defaults to 50, capped at 200. Returns { "messages", "has_more" }.
POST /rooms/:id/messages Post. 201 if written, 200 if the same client_message_id already exists.
PATCH /messages/:id Edit text (and optional mentions_everyone).
DELETE /messages/:id Tombstone the message. The sequence stays; text becomes "" and deleted_at is set.
POST /rooms/:id/read Advance the caller’s read position. { "sequence": n }.
POST /rooms/:id/typing Best-effort typing signal.
POST /api/client/v1/rooms/:id/messages
{
  "text": "Hei",
  "client_message_id": "b3f1…",
  "attachment_ids": []
}

Generate a UUID for client_message_id for each new message. Reuse that UUID when retrying so the response returns the original message without posting a duplicate. Treat text as plain text when displaying it, even if it contains HTML. For shared rooms, relaii forwards the request to the owning Space. If it returns 503 because that Space is unavailable, retry later with the same UUID.

An archived room takes no new messages (client API or webhook).

Files

Three steps, identical whether storage is local disk or S3. Quota is charged at step 1.

  1. POST /rooms/:id/uploads with { "filename", "mime_type", "byte_size" } → attachment plus { "method": "PUT", "url", "headers", "expires_at" }.
  2. PUT the bytes to upload.url with upload.headers. That request does not carry relaii credentials — the URL is the credential. Local storage uses /files/v1/:id/content; S3 uses the bucket directly.
  3. POST /uploads/:id/finalize with an optional { "checksum" }. Then put attachment.id in attachment_ids when posting the message.
MethodPathWhat it does
GET /rooms/:id/attachments Keyset page of the room’s files, newest first. Default limit 50, cap 200.
GET /attachments/:id One attachment.
GET /attachments/:id/download 302 to a short-lived URL, or { "url", "expires_at" } with Accept: application/json.
DELETE /attachments/:id Delete the file.

Attachment status is pending, uploaded, scanning, ready, blocked or deleted. Only ready is downloadable. In a channel homed elsewhere the three steps are forwarded; the quota charged is the homeserver’s.

GET /search?q=…&limit=…&cursor=…

Combine search text with from:username, in:#channel, before:YYYY-MM-DD, after:YYYY-MM-DD and has:file. URL-encode the complete query. Results include only rooms the signed-in user can read.

Sync and WebSocket

GET /sync?since=<cursor>&limit=<n>

{
  "events": [],
  "cursor": 18460,
  "has_more": false,
  "rooms": [],
  "reset": false
}

since=0 (or a missing since) is a full bootstrap: the current room list and cursor, no events. Load history per room afterwards. reset: true means the cursor is older than retained history — discard the cache and bootstrap again.

Store the last cursor you have processed and send it as since when requesting further events. Process returned events in order before advancing your saved cursor.

WebSocket: wss://<server>/socket/v1/websocket?token=<socket_token>. Phoenix channels:

  • user:<user_id> — every durable event for rooms the user belongs to, plus presence.changed.
  • room:<room_id> — the same durable events for that room, plus typing.*.

Both topics deliver the Event shape under the message name event. Join the user topic with { "since": <cursor> }; the reply includes cursor and missed. If missed is non-zero, call /sync to catch up. Use WebSocket for live updates and /sync to recover missed events after reconnecting.

Client-to-server on a room topic: typing ({ "typing": true|false }) and read ({ "sequence": n }). Both are best-effort.

Durable event types:

typepayload
message.created{ "message" }
message.updated{ "message" }
message.deleted{ "message_id", "deleted_at" }
room.created{ "room" }
room.updated{ "room" }
room.deleted{ "room_id" }
member.joined{ "member" }
member.left{ "member_id", "user_id" }
member.updated{ "member" }
attachment.created{ "attachment" }
attachment.ready{ "attachment" }
attachment.deleted{ "attachment_id" }

Ephemeral, WebSocket only, never persisted: typing.started, typing.stopped, presence.changed. They have no cursor.

Managing webhooks

Use a user session with manage permission in the room. Creating a webhook also requires the Space’s create_webhooks permission. Create and manage webhooks on the Space that owns the channel.

MethodPathWhat it does
GET /rooms/:id/webhooks Live webhooks for the room. The secret is never in this list — only secret_prefix.
POST /rooms/:id/webhooks Create. Body { "name", "rate_limit_per_minute" }. name is required, 1–80 characters. Rate limit defaults to 60.
PATCH /webhooks/:id name, enabled, rate_limit_per_minute (1–6000).
POST /webhooks/:id/rotate Issue a new secret and invalidate the old one immediately. There is no window where both work.
DELETE /webhooks/:id Revoke the webhook immediately, deactivating its bot. Existing messages keep their bot sender. Returns 204.
POST /webhooks/:id/avatar/uploads Reserve a bot avatar upload. Body { "filename", "mime_type", "byte_size" }; JPEG, PNG, GIF, WebP or AVIF up to 5 MB. Returns 201 { "profile_image_upload_id", "upload" }. PUT the image to upload.url with upload.headers.
POST /webhooks/:id/avatar/uploads/:upload_id/finalize Use the uploaded image as the webhook’s bot avatar. Returns { "avatar_url" }.
POST /webhooks/:id/avatar/url Use an image URL instead. Body { "url" }: an absolute HTTP(S) URL of at most 255 characters, otherwise 422. Returns { "avatar_url" }.
DELETE /webhooks/:id/avatar Remove the bot avatar. Returns { "avatar_url": null }.

Bot avatar changes take effect immediately. For a revoked or unknown webhook, the avatar endpoints return 404.

Create and rotate return the secret once, with Cache-Control: no-store:

{
  "webhook": {
    "id": "019c…",
    "room_id": "019c…",
    "name": "Deploy",
    "secret_prefix": "relaii_h",
    "bot_user_id": "019c…",
    "bot_avatar_url": null,
    "enabled": true,
    "rate_limit_per_minute": 60,
    "last_used_at": null,
    "created_at": "2026-08-30T13:04:05.123456Z"
  },
  "secret": "relaii_hook_…",
  "url": "https://chat.example.no/hooks/relaii_hook_…"
}

Each webhook posts under its own bot account in the channel. If you lose the secret URL, rotate it and update your integration with the new URL. Rotation invalidates the old URL immediately.

Every webhook object — in lists, in these responses and from the integration API — includes bot_avatar_url, the bot account’s avatar or null. A per-message avatar_url in a webhook request still overrides it for that message.

Channel sharing

Send sharing requests to the Space that owns the channel. You need share permission, federation must be enabled, and the destination Space must already be paired. See the federation guide to set up a connection.

MethodPathWhat it does
GET /rooms/:id/shares Shares granted on this room.
POST /rooms/:id/shares { "peer_server_id", "permissions" }. 201.
PATCH /rooms/:id/shares/:share_id Send the complete permissions set to replace the current permissions. Include every permission you want to keep.
DELETE /rooms/:id/shares/:share_id Revoke the share and stop further event delivery. Returns 204.

Users, invitations, devices

MethodPathAuthWhat it does
GET /users yes Directory of active users. ?q= and ?limit= (default 50, cap 200). No email addresses.
GET /users/:id yes One user, same public shape.
GET /invitations/:token no Preview an invitation: email, role, server name, who sent it, expiry.
POST /invitations/:token/accept no Create the account and sign in. 201 with a session.
GET /onboarding owner First-run wizard state.
POST /onboarding owner Save wizard answers.
POST /setup/complete owner Finish onboarding, optionally creating the first channel.
GET /devices yes Native push devices for this account.
POST /devices yes { "device_id", "push_device_id", "platform" }. Registers the device, or updates it if this account has already registered the same device_id. Platform is ios, macos, apple or android.
DELETE /devices/:id yes Disable this device. 204.

Native clients register an opaque push_device_id from notifications.relaii.app. The platform device token never appears on these endpoints. Push is optional: chat and federation work without it.

Admin API

Use an administrator’s session. The paths below include /admin; append them to /api/client/v1. For example, request GET /api/client/v1/admin/settings. Key rotation and ownership transfer require the Space owner’s account.

MethodPathWhat it does
GET /admin/settings Server settings.
PATCH /admin/settings Update settings, including push_enabled.
GET /admin/status Health of the running install.
GET /admin/storage Usage, limits, and why an upload would be refused.
GET /admin/users Every local account, including email.
POST /admin/users Create a user.
PATCH /admin/users/:id Update, disable, or change admin standing.
DELETE /admin/users/:id Delete an account that has never posted; otherwise disable it.
GET /admin/invitations Outstanding invitations.
POST /admin/invitations Create an invitation.
DELETE /admin/invitations/:id Revoke an invitation.
GET /admin/audit Administrative audit log. Never contains a secret.
GET /admin/federation/identity This server’s federation identity and fingerprint.
GET /admin/federation/peers Paired and pending peers.
POST /admin/federation/peers/bootstrap Start an outbound pairing.
POST /admin/federation/peers/connect Continue a pairing with the one-time key.
GET /admin/federation/peers/:id One peer.
POST /admin/federation/peers/:id/approve Approve an inbound request after comparing fingerprints.
POST /admin/federation/peers/:id/revoke Revoke a peer.
DELETE /admin/federation/peers/:id Remove the saved peer connection.
GET /admin/federation/outbox Pending federation deliveries.
GET /admin/federation/invitations Inbound channel-share invitations.
POST /admin/federation/invitations/:id/accept Accept a shared channel.
POST /admin/federation/invitations/:id/reject Reject a shared channel.
POST /admin/federation/keys/rotate Owner only. Rotate the signing key.
POST /admin/transfer-ownership Owner only. Transfer ownership of the Space to another local administrator.

Federation API

Use /api/federation/v1 for communication between paired Spaces. To post bot messages, use the integration API. Pairing requires approval from both Space administrators. After pairing, sign requests with the peer’s Ed25519 key; the public ping, key discovery and initial pairing endpoints are used to establish that connection.

For a shared room, send changes to its owning Space, also called its homeserver. It returns the room_sequence for each accepted message and manages membership and files. Other Spaces forward their users’ requests to it and receive the resulting events.

MethodPathWhat it does
GET /ping Reachability during bootstrap.
GET /keys Published signing keys.
POST /peers/request Inbound pairing request.
POST /peers/challenge Challenge step of the handshake.
POST /peers/confirm Final handshake step (signed, peer still verifying).
POST /peers/revoke Peer-initiated revoke.
POST /keys/rotate Announce a new key.
POST /rooms/invites Offer a shared channel.
POST /rooms/:id/invites/:invite_id/accept Guest accepts the offer.
POST /rooms/:id/invites/:invite_id/reject Guest rejects the offer.
GET /rooms/:id/state Authoritative room state from the homeserver.
GET /rooms/:id/events Replicate the event log.
POST /rooms/:id/messages Guest submits a message; homeserver assigns the sequence.
PATCH /rooms/:id/messages/:message_id Guest submits an edit.
DELETE /rooms/:id/messages/:message_id Guest submits a delete.
POST /rooms/:id/uploads Start an upload on the homeserver’s storage.
POST /rooms/:id/uploads/:attachment_id/finalize Finish that upload.
GET /rooms/:id/attachments/:attachment_id/download Short-lived download for a shared file.
POST /rooms/:id/members Join a member on the homeserver.
POST /rooms/:id/events/ack Acknowledge replicated events.
GET /sync Federation catch-up.

If you are connecting two existing relaii Spaces, use Admin → Federation and follow the pairing guide; relaii handles request signing. The optional push gateway uses a separate endpoint, POST /api/push/v1/challenge, with the challenge prefix RELAII-PUSH-CHALLENGE. Use that prefix only for push gateway challenges.

Entities

Use these response shapes when reading lists, sync events and WebSocket messages. IDs and nested objects are abbreviated in the examples; use complete values returned by your Space in requests.

User

{
  "id": "019c…",
  "username": "chris",
  "display_name": "Chris",
  "qualified_id": "chris@chat.example.no",
  "server_id": "chat.example.no",
  "is_local": true,
  "is_bot": false,
  "is_admin": true,
  "is_owner": true,
  "status": "active",
  "avatar_url": null,
  "last_seen_at": "2026-08-30T13:00:00.000000Z"
}

A remote user has is_local: false and is_admin / is_owner always false. GET /auth/me additionally returns email, timezone, notification_preference and onboarding_completed_at. No endpoint returns another user’s email to a non-admin.

A bot account (is_bot: true) can have an avatar_url on another host, set by whoever manages its API key or webhook. Treat it as an external image: loading it shares the reader’s IP address with that host.

Room

{
  "id": "019c…",
  "kind": "channel",
  "name": "prosjekt-x",
  "slug": "prosjekt-x",
  "topic": "Kundeprosjekt",
  "visibility": "private",
  "owner_server_id": "chat.example.no",
  "is_local": true,
  "is_shared": true,
  "shared_with": ["chat.partner.no"],
  "last_sequence": 733,
  "member_count": 12,
  "archived_at": null,
  "created_at": "2026-08-01T09:00:00.000000Z",
  "my_membership": {
    "role": "owner",
    "joined_at": "2026-08-01T09:00:00.000000Z",
    "muted": false,
    "notification": "default"
  },
  "unread": { "count": 3, "mentions": 1, "last_read_sequence": 730 }
}

For direct and group_direct, name is null — clients render the participants. shared_with is present only for rooms this server owns.

Member

{
  "id": "019c…",
  "role": "member",
  "joined_at": "2026-08-02T10:00:00.000000Z",
  "muted": false,
  "user": { }
}

Message

{
  "id": "019c…",
  "room_id": "019c…",
  "room_sequence": 733,
  "text": "Hei",
  "sender": { },
  "sender_override": null,
  "client_message_id": "b3f1…",
  "mentions": ["019c…"],
  "mentions_everyone": false,
  "attachments": [],
  "edited_at": null,
  "deleted_at": null,
  "created_at": "2026-08-30T13:04:05.123456Z"
}

When deleted_at is set, display the message as deleted and retain its sequence for synchronization. sender_override contains the sender supplied for an individual webhook message.

Attachment

{
  "id": "019c…",
  "room_id": "019c…",
  "message_id": "019c…",
  "filename": "rapport.pdf",
  "mime_type": "application/pdf",
  "byte_size": 184320,
  "checksum": "sha256:9f86d0…",
  "status": "ready",
  "created_at": "2026-08-30T13:04:00.000000Z"
}

Download URLs are never embedded. Fetch them from GET /attachments/:id/download.

Event

{
  "type": "message.created",
  "event_id": "019c…",
  "cursor": 18421,
  "room_id": "019c…",
  "room_sequence": 733,
  "created_at": "2026-08-30T13:04:05.123456Z",
  "payload": {}
}

Running a server

Start in the Docs page for installation, configuration, backups and troubleshooting. See updates and backups before changing a running Space, or the federation guide to connect two Spaces.

For authentication failures, start with the quickstart checks. For missed messages or reconnects, follow Sync and WebSocket. For server health and access problems, use the troubleshooting guide.

Security

Keep webhook URLs, API keys and session tokens secret. Use HTTPS and grant integration keys only the permissions they need — a permission applies to every channel on the Space. See authentication conventions for cookies and bearer tokens, and the security guide for the server’s encryption model.