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.
| Task | Base path | Credential |
|---|---|---|
| Post a notification | /hooks/:secret | The secret URL |
| Build an integration | /api/integrations/v1 | A scoped rak_… key |
| Build a client | /api/client/v1 | A user session |
| Connect servers | /api/federation/v1 | Peer 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_atandrelaii_rtareHttpOnly,SameSite=Lax, andSecureon HTTPS. Unsafe methods must also send the CSRF token fromrelaii_csrfin theX-Relaii-CSRFheader. -
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.
| HTTP | code |
|---|---|
| 400 | invalid_request, invalid_cursor |
| 401 | unauthenticated, invalid_credentials, token_expired, invalid_refresh_token |
| 403 | forbidden, account_disabled, setup_required, federation_disabled, quota_exceeded |
| 404 | not_found |
| 409 | conflict, already_exists, owner_exists, api_key_limit_reached, widget_limit_reached, link_limit_reached |
| 413 | payload_too_large |
| 422 | validation_failed — details is a field → messages map |
| 429 | rate_limited — details.retry_after_seconds and a Retry-After header |
| 503 | storage_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.
| Method | Path | What 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
Paste or edit JSON, then apply it to update your blocks. Validation runs in your browser.
Live preview
PreviewBold 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
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.
| Field | Required | Meaning |
|---|---|---|
format | no | basic (the default) or blocks. |
text | basic: 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. |
name | no | Display name for this message, at most 80 characters. It does not rename the bot or impersonate an account. |
sender | no | Legacy alias for name. If both are present, name wins. |
avatar_url | no | Absolute HTTP(S) avatar URL for this message, at most 2,048 characters. |
external_id | no | Idempotency 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"
}
| Block | Shape 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. |
image | Required absolute HTTP(S) url; optional alt, at most 300 characters. |
table | One 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. |
actions | One to four link buttons. Each needs text (at most 80 characters) and an absolute HTTP(S) url. |
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…" }
| HTTP | When |
|---|---|
| 200 | Posted, or the same external_id already landed. |
400 invalid_request | Missing basic text, an unknown format, invalid blocks or URL, or an unusable external_id. |
404 not_found | Check 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 conflict | The room is archived, or a replay could not be resolved. |
413 payload_too_large | The body is larger than 64 KiB. |
429 rate_limited | Over 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.
| Setting | Behavior |
|---|---|
| URL | Direct public HTTPS URL returning HTTP 200 and UTF-8 text/html, at most 512 KiB. Redirects and private-network targets are rejected. |
| Bearer token | Optional secret sent by the server. Never returned to channel members. Leave unchanged when editing, or explicitly remove it. |
| Cache duration | 0–86400 seconds; default 300. Shared between channel members. 0 fetches on each open/reload without periodic refresh. |
| Lazy loading | On by default: fetch when the widget becomes visible. Visible widgets with a positive TTL refresh at that interval, with a 30-second minimum. |
| Height / enabled | 160–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:
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /admin/widgets | Administrator channel picker. |
| GET | /rooms/:id/content | Widget metadata and shared links. Never includes a bearer token. |
| POST | /rooms/:id/widgets | Create a widget in a channel you manage. |
| PATCH | /rooms/:id/widgets/:widget_id | Edit settings; URL, token, cache or enablement changes invalidate the cached snapshot. |
| DELETE | /rooms/:id/widgets/:widget_id | Remove a widget. |
| GET | /rooms/:id/widgets/:widget_id/content | Authorized snapshot as JSON for native clients. |
| GET | /rooms/:id/widgets/:widget_id/render | Authorized 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.
| Method | Integration endpoint | Purpose |
|---|---|---|
| POST | /rooms/:id/messages | Post a bot message. Send text and a UUID client_message_id; retain that UUID for retries. Requires post_messages. |
| GET | /rooms/:id/widgets | Read the current widget list and revisions. |
| POST | /rooms/:id/widgets | Create a block widget. Requires manage_widgets. |
| PUT | /rooms/:id/widgets/:widget_id | Update an existing widget in place. Requires manage_widgets. |
| DELETE | /rooms/:id/widgets/:widget_id | Delete 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.
| Method | Client endpoint | Purpose |
|---|---|---|
| POST | /admin/api-keys/:id/bot-avatar/uploads | Reserve 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/finalize | Use the uploaded image. Returns { "avatar_url" }. |
| POST | /admin/api-keys/:id/bot-avatar/url | Use 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-avatar | Remove 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
| Method | Path | Auth | What 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.
| Method | Path | Purpose |
|---|---|---|
| POST | /auth/mfa | Complete login with mfa_token and code; no session required. |
| GET | /auth/mfa | Read enrollment status for the signed-in user. |
| POST | /auth/mfa/setup | Begin enrollment. |
| POST | /auth/mfa/enable | Confirm 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.
| Method | Path | What 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
| Method | Path | What 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
| Method | Path | What 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.
-
POST
/rooms/:id/uploadswith{ "filename", "mime_type", "byte_size" }→ attachment plus{ "method": "PUT", "url", "headers", "expires_at" }. -
PUT the bytes to
upload.urlwithupload.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. -
POST
/uploads/:id/finalizewith an optional{ "checksum" }. Then putattachment.idinattachment_idswhen posting the message.
| Method | Path | What 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.
Search
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, pluspresence.changed.room:<room_id>— the same durable events for that room, plustyping.*.
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:
| type | payload |
|---|---|
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.
| Method | Path | What 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.
| Method | Path | What 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
| Method | Path | Auth | What 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.
| Method | Path | What 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.
| Method | Path | What 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.