Docs menu

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 pathWho can call itRate bucketNode role
GET /healthzAnyone—All roles
POST /rooms/{roomId}/messagesRoom memberssendAll, Api
GET /rooms/{roomId}/messagesRoom membersreadAll, Api
GET /rooms/{roomId}/participantsRoom membersreadAll, Api
GET /rooms/{roomId}/eventsRoom membersreadAll, Gateway
/admin/...adminadminAll, Api
/audit/...auditorreadAll, 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>" }
StatuserrorMeaning
400invalid_contentContent is empty or longer than Dotwire:MaxContentBytes UTF-8 bytes, including after a presend rewrite.
400invalid_user_idA user or sender id is empty, longer than 256 characters, or contains control characters.
400invalid_requestA request field is out of range. reason says which.
422presend_rejectedThe presend webhook rejected the message. reason is the webhook's reason.
429rate_limitedA rate bucket is empty. The Retry-After header gives the wait in seconds.
503presend_unavailableThe presend webhook failed and its fail policy is Closed.
503stream_unavailableJetStream didn't accept the message.
503audit_unavailabledotwire 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 parameterDescription
afterSeqReturn messages with seq greater than this, oldest first. Use it for gap-fill.
beforeSeqReturn the page of messages just before this seq. Use it to page backwards.
limitPage 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:
messageThe 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

RequestBodyResponse
PUT /admin/users/{userId}/role{ "role": "member" }, with member, auditor, or admin200 { 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

RequestBodyResponse
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

RequestBodyResponse
GET /admin/retention—200 { "messagesDays": 90 }. 0 means indefinite.
PUT /admin/retention{ "messagesDays": 90 }, from 0 to 36,500200 { 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" }
  ]
}