HTTP API
All endpoints except /healthz require a bearer token that satisfies the token contract. Request and response bodies are JSON with camelCase field names. Room ids are UUIDs.
Endpoints
| Method and path | Who can call it | Rate bucket | Node role |
|---|---|---|---|
GET /healthz | Anyone | — | All roles |
POST /rooms/{roomId}/messages | Room members | send | All, Api |
GET /rooms/{roomId}/messages | Room members | read | All, Api |
GET /rooms/{roomId}/participants | Room members | read | All, Api |
GET /rooms/{roomId}/events | Room members | read | All, Gateway |
/admin/... | admin | admin | All, Api |
/audit/... | auditor | read | All, Api |
"Room members" means the role cross-check passes and the caller is in room_members for that room. A room id that isn't a UUID returns 404 on member routes and 400 on admin routes.
Errors
Typed errors have this body:
{ "error": "<code>", "reason": "<optional text>" }
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_content | Content is empty or longer than Dotwire:MaxContentBytes UTF-8 bytes, including after a presend rewrite. |
| 400 | invalid_user_id | A user or sender id is empty, longer than 256 characters, or contains control characters. |
| 400 | invalid_request | A request field is out of range. reason says which. |
| 422 | presend_rejected | The presend webhook rejected the message. reason is the webhook's reason. |
| 429 | rate_limited | A rate bucket is empty. The Retry-After header gives the wait in seconds. |
| 503 | presend_unavailable | The presend webhook failed and its fail policy is Closed. |
| 503 | stream_unavailable | JetStream didn't accept the message. |
| 503 | audit_unavailable | dotwire couldn't write the audit event for this call. |
401, 403, and 404 responses have no body. Some admin validation failures return a bare 400.
Messages
Send a message
POST /rooms/{roomId}/messages
{ "content": "<message text>" }
Returns 202 Accepted once JetStream has stored the message:
{ "seq": 42, "time": "2026-11-06T12:00:00.123456+00:00" }
The sender is the token's sub. If a presend webhook is configured, it runs first. See Write path.
Errors: invalid_content, invalid_user_id, presend_rejected, presend_unavailable, stream_unavailable, rate_limited.
Read history
GET /rooms/{roomId}/messages
| Query parameter | Description |
|---|---|
afterSeq | Return messages with seq greater than this, oldest first. Use it for gap-fill. |
beforeSeq | Return the page of messages just before this seq. Use it to page backwards. |
limit | Page size, 1 to 100. Default 50. |
Without afterSeq or beforeSeq, you get the newest page. Messages in every page are in ascending seq order.
{
"roomId": "a0000000-0000-0000-0000-000000000001",
"messages": [
{ "seq": 41, "time": "2026-11-06T12:00:00.000000+00:00", "senderId": "alice", "content": "<message text>" }
],
"hasMore": true
}
hasMore is true when another page exists in the direction you're reading. Redacted messages never appear.
List participants
GET /rooms/{roomId}/participants
Returns a JSON array of member user ids, sorted:
["alice", "bob"]
It contains no profile data and isn't a presence snapshot. See Participants and presence.
Stream room events
GET /rooms/{roomId}/events streams text/event-stream. You can pass the token as an access_token query parameter instead of the Authorization header.
To resume, send the Last-Event-ID header, or pass afterSeq as a query parameter. dotwire replays history after that seq in pages of Dotwire:Sse:ReplayPageSize, then switches to live delivery without gaps or duplicates. Without either, the stream starts with live events only.
Events:
event: | id: | data: |
|---|---|---|
message | The message seq | { roomId, seq, senderId, time, content, replayed }. replayed is true for history replay. |
typing | — | { roomId, userId, isTyping } |
presence | — | { roomId, joined, left } |
retracted | — | { roomId, seq } for a redacted message |
revoked | — | { roomId }. Your membership was revoked and the stream ends. |
dotwire also sends a : ping comment every Dotwire:Sse:KeepAliveSeconds seconds (15 by default). The stream ends when the token expires, when your membership is revoked, or when you fall more than Dotwire:Sse:BufferSize events behind. Reconnect with Last-Event-ID to resume.
If Dotwire:Sse:Enabled is false, this endpoint returns 404.
Admin
Every /admin route needs a token whose dw:role is admin and a matching row in user_roles. Every mutation writes an audit event and returns 503 audit_unavailable if it can't.
Roles
| Request | Body | Response |
|---|---|---|
PUT /admin/users/{userId}/role | { "role": "member" }, with member, auditor, or admin | 200 { userId, role }. A bare 400 for any other role. |
GET /admin/users/{userId}/role | — | 200 { userId, role }, or 404 if the user has no role |
DELETE /admin/users/{userId}/role | — | 204. Also removes every room membership the user has and revokes their live connections. |
GET /admin/users/{userId}/rooms | — | 200 { userId, roomIds } |
Membership
| Request | Body | Response |
|---|---|---|
POST /admin/rooms/{roomId}/members | { "userIds": ["alice"], "ensureMemberRole": false } | 200 { roomId, members }, listing the ids you sent. A bare 400 if userIds is empty. |
DELETE /admin/rooms/{roomId}/members/{userId} | — | 204. Revokes the user's live connections to the room. |
GET /admin/rooms/{roomId}/members | — | 200 { roomId, members }, sorted |
ensureMemberRole is optional and defaults to false. When true, users without a role get member; existing roles are left alone.
Inject a message
POST /admin/rooms/{roomId}/messages
{ "content": "<message text>", "senderId": "system" }
senderId is optional and defaults to system. Returns 202 with { seq, time }. The admin doesn't need to be a room member. Presend and postsend run only when their IncludeAdminSends setting is on.
dotwire writes the audit event after JetStream accepts the message and before live fanout. If the audit event can't be written, dotwire erases the message from JetStream and Postgres and returns 503 audit_unavailable. No client receives the message, so retrying doesn't create a duplicate.
Redact a message
DELETE /admin/rooms/{roomId}/messages/{seq}
{ "roomId": "a0000000-0000-0000-0000-000000000001", "seq": 42, "removedFromHistory": true, "removedFromStream": true }
Returns 404 if seq belongs to a different room, and 503 if JetStream can't be reached. Redaction is idempotent: retrying after success returns 200 with both flags false. See Redaction.
Retention
| Request | Body | Response |
|---|---|---|
GET /admin/retention | — | 200 { "messagesDays": 90 }. 0 means indefinite. |
PUT /admin/retention | { "messagesDays": 90 }, from 0 to 36,500 | 200 { messagesDays }, or 400 invalid_request if out of range |
Export a user's data
GET /admin/users/{userId}/export streams one JSON document:
{
"userId": "dave",
"exportedAt": "2026-11-06T12:00:00+00:00",
"role": "member",
"roomIds": ["a0000000-0000-0000-0000-000000000001"],
"messages": [
{ "roomId": "a0000000-0000-0000-0000-000000000001", "seq": 42, "time": "2026-11-06T11:59:00+00:00", "content": "<message text>" }
],
"auditEvents": [
{ "id": 17, "eventType": "member.added", "roomId": "a0000000-0000-0000-0000-000000000001", "messageSeq": null, "actorId": "host-admin", "subjectId": "dave", "value": null, "time": "2026-11-06T11:00:00+00:00" }
]
}
messages holds every message the user sent, decrypted. A message that can't be decrypted has "content": null and "undecryptable": true. role is null if the user has none.
Audit
Every /audit route needs a token whose dw:role is auditor and a matching row in user_roles. admin is not enough. Every call writes an audit.read event.
Read the log
GET /audit?afterId=0&limit=100
afterId defaults to 0. limit is 1 to 500, default 100.
{
"events": [
{
"id": 1,
"eventType": "role.set.member",
"roomId": null,
"messageSeq": null,
"actorId": "host-admin",
"subjectId": "user-42",
"value": null,
"time": "2026-11-06T12:00:00.000000+00:00",
"hash": "<lowercase-hex-sha256>"
}
],
"hasMore": false
}
Verify the chain
GET /audit/verify?full=false
Returns { ok, fromId, toId, checked, firstInvalidId, anchor }. See Read and verify.
List checkpoints
GET /audit/checkpoints?limit=30
limit is 1 to 365, default 30. Newest first.
{
"checkpoints": [
{ "day": "2026-11-05", "lastId": 1200, "hash": "<lowercase-hex-sha256>", "createdAt": "2026-11-06T00:00:01+00:00" }
]
}