REST API overview

Base URL: https://api.byotalk.com/v1. JSON over HTTPS. Every response carries a Request-Id header; include it when you contact support.

Conventions

Authentication

Caller Header Scope
Customer server Authorization: Bearer sk_live_... Everything in that environment
Client app Authorization: Bearer <user JWT> Client subset, acting as that user
Dashboard Session cookie Control plane for the user's organizations

The environment is derived from the credential. Client endpoints may name the environment with ?env= or the Byotalk-Env header (required for dev tokens); it must match the environment of the key that signed the token.

Versioning

  • Major version in the path (/v1). Only additive changes within v1 (new endpoints, new optional fields, new enum values clients must tolerate, new event types).
  • Breaking changes → /v2 with ≥ 12 months of overlap and deprecation headers (Deprecation, Sunset).

Identifiers and formats

Prefix Resource
env_ Environment
conv_ Conversation (conv_dm_... for direct)
msg_ Message
att_ Attachment
wh_ / whd_ Webhook endpoint / delivery
evt_ Event
req_ Request
  • User IDs are the customer's strings: 1–128 chars of [A-Za-z0-9_\-@.:].
  • JSON bodies use camelCase. Timestamps ISO 8601 UTC (2026-10-04T10:00:00.123Z).
  • Unknown request fields → 400 unknown_field (strict). Responses may gain fields; clients must ignore unknown ones.

Pagination

GET /v1/conversations?limit=20&cursor=eyJ0IjoiMjAyNi0x...
{ "data": [ ... ], "nextCursor": "eyJ0IjoiMjAyNi0x..." }
  • limit default 50, max 200. nextCursor is opaque; null at the end.
  • Messages and events also accept before / after (seq) because seq is meaningful.

Idempotency

  • Idempotency-Key: <uuid> accepted on every POST, PATCH, DELETE; kept 24 h per (environment, key, route).
  • Replay with same body → stored status + body, header Idempotent-Replayed: true.
  • Same key, different body → 422 idempotency_mismatch.
  • Message sends also accept clientMsgId in the body (shared dedup with the socket path, 48 h).

Request IDs

Every response includes Request-Id: req_.... It appears in error bodies, dashboard logs and webhook delivery logs.

Errors

{
  "error": {
    "type": "invalid_request",
    "code": "text_too_long",
    "message": "text must be at most 5000 characters",
    "param": "text",
    "requestId": "req_01J9ZK..."
  }
}

This is the single error catalogue for REST and the realtime socket. Socket errors arrive as {"t":"error","re":...,"code":...} frames or as close codes (see Realtime protocol).

Code Type HTTP Socket Retryable SDK behaviour
invalid_frame invalid_request — error frame No Logs; frame dropped
unknown_field invalid_request 400 error frame No Rejects promise
text_too_long, metadata_too_large invalid_request 422 error frame No Message → failed
attachment_not_ready invalid_request 422 error frame No Message → failed
idempotency_mismatch invalid_request 422 — No Rejects promise
token_expired authentication 401 close 4001 After refresh Calls tokenProvider once, retries
token_invalid, key_revoked authentication 401 close 4001 No State failed, emits error
forbidden, not_member permission 403 error frame No Rejects; message → failed
user_deactivated, dev_tokens_disabled permission 403 close 4003 No State failed
not_found not_found 404 error frame No Rejects (also used for other tenants' IDs)
version_conflict conflict 409 error frame After re-read Reverts optimistic edit
already_member conflict 409 — No Rejects
resync_required conflict 410 resync_required frame Yes (reload) Drops window, loads latest page
body_too_large, file_too_large payload_too_large 413 close 1009 No Rejects
rate_limited rate_limited 429 error frame / close 4008 After retryAfterMs Waits, retries sends
plan_limit_reached rate_limited 429 close 4008 After retryAfterMs Emits error
too_many_connections rate_limited — close 4009 No Oldest socket closed, no auto-reconnect
history_unavailable unavailable 503 — Yes HistoryUnavailableError; loaded pages kept
storage_unavailable unavailable 503 — Yes Rejects upload; retry button
internal internal 500 error frame Yes (idempotent calls) Retries with backoff

Every error body carries requestId; retryable errors carry retryAfterMs.

Rate limits

Headers on every response: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset. 429 adds Retry-After (seconds).

Caller Default
Secret key 100 req/s per environment, burst 200
User token 20 req/s per user
Message send 10/s per user (burst 20), 300/min
Uploads 30/min per user
Cold history 50 qps per environment

Paid plans raise the secret-key limit.

Limits

Field Limit
text 5,000 characters
metadata 4 KB serialized JSON, depth ≤ 5
Request body 64 KB
Attachments per message 10
Group members 500
Conversation name 200 characters

Resources

Tokens

POST /v1/tokens — server. Mint a user token for backends without our SDK (the Node SDK signs locally instead).

// request
{ "userId": "user_123", "expiresIn": 3600 }
// response 201
{ "token": "eyJhbGciOiJIUzI1NiIsImtpZCI6ImtleV8wMUo5In0...", "expiresAt": "2026-10-04T11:00:00Z" }

Users

Method & path Caller Notes
PUT /v1/users/{id} Server Upsert {name, imageUrl, metadata}
GET /v1/users/{id} Server; client for users sharing a conversation
GET /v1/users Server Paginated
POST /v1/users/{id}/deactivate / .../reactivate Server Deactivation rejects tokens, closes sockets
POST /v1/users/{id}/revoke-tokens Server Tokens with iat < now rejected; sockets close 4001
DELETE /v1/users/{id}?messages=anonymize|delete Server GDPR erasure; propagated to customer DB
// PUT /v1/users/user_123
{ "name": "Asha Rao", "imageUrl": "https://cdn.example.com/a.png", "metadata": { "plan": "pro" } }
// 200
{ "id": "user_123", "name": "Asha Rao", "imageUrl": "https://cdn.example.com/a.png",
  "metadata": { "plan": "pro" }, "createdAt": "2026-10-04T09:00:00Z" }

Conversations

Method & path Caller Notes
POST /v1/conversations Both (client per clientConversationCreation) type: direct|group
GET /v1/conversations Both Client: own list; server: ?userId= required
GET /v1/conversations/{id} Members, server
PATCH /v1/conversations/{id} Owner, server name, metadata
DELETE /v1/conversations/{id} Server Soft delete; conversation.deleted
PUT /v1/conversations/{id}/mute / DELETE .../mute Member Per-member mute
// POST /v1/conversations  (direct; idempotent by pair)
{ "type": "direct", "members": ["user_123", "user_456"] }
// POST /v1/conversations  (group)
{ "type": "group", "name": "Order #8812", "members": ["user_123", "user_456", "user_789"],
  "metadata": { "orderId": "8812" } }
// 201
{ "id": "conv_01J9ZK...", "type": "group", "name": "Order #8812", "metadata": { "orderId": "8812" },
  "lastSeq": 3, "createdAt": "2026-10-04T10:00:00Z",
  "members": [ { "userId": "user_123", "role": "owner" }, { "userId": "user_456", "role": "member" },
               { "userId": "user_789", "role": "member" } ] }

Client creating a group becomes owner. A server-created group sets ownerId explicitly or has no owner (server-only management). New members see full history in Phase 1.

// GET /v1/conversations?limit=2  (client)
{ "data": [
    { "id": "conv_01J9ZK...", "type": "group", "name": "Order #8812", "lastSeq": 812,
      "unreadCount": 3, "lastReadSeq": 809, "muted": false,
      "lastMessage": { "id": "msg_01J9...", "seq": 812, "senderId": "user_456", "text": "Shipped!",
                       "createdAt": "2026-10-04T10:05:00Z" } }
  ],
  "nextCursor": "eyJ..." }

Members

Method & path Caller Notes
GET /v1/conversations/{id}/members Members, server Includes lastReadSeq, lastDeliveredSeq
POST /v1/conversations/{id}/members Owner, server { "userIds": [...] }; groups only
DELETE /v1/conversations/{id}/members/{userId} Owner, self (leave), server
PATCH /v1/conversations/{id}/members/{userId} Server role

Messages

Method & path Caller Notes
POST /v1/conversations/{id}/messages Members, server Server may set senderId
GET /v1/conversations/{id}/messages Members, server before, after, limit (≤ 200)
GET /v1/messages/{id} Members, server
PATCH /v1/messages/{id} Author, server {text?, metadata?, expectedVersion}
DELETE /v1/messages/{id} Author, owner, server Soft; ?hard=true server only
// POST /v1/conversations/conv_01J9ZK.../messages
{ "clientMsgId": "0192f0c4-7b1e-7c3a-9a55-3f1d2c0e9b11",
  "text": "Can you share the invoice?",
  "replyTo": "msg_01J9ZJ...",
  "attachments": ["att_01J9ZM..."],
  "metadata": { "intent": "invoice_request" } }
// 201
{ "id": "msg_01J9ZN...", "conversationId": "conv_01J9ZK...", "seq": 813, "senderId": "user_123",
  "text": "Can you share the invoice?", "replyTo": "msg_01J9ZJ...",
  "attachments": [ { "id": "att_01J9ZM...", "name": "photo.jpg", "size": 482113,
                     "mimeType": "image/jpeg", "width": 1200, "height": 900 } ],
  "metadata": { "intent": "invoice_request" }, "version": 1,
  "createdAt": "2026-10-04T10:06:00.120Z", "editedAt": null, "deletedAt": null }
// GET /v1/conversations/conv_01J9ZK.../messages?before=813&limit=50
{ "data": [ { "id": "msg_...", "seq": 812, ... }, ... ],   // descending seq
  "hasMore": true }
// 503 when part of the page lives in an unavailable customer DB
{ "error": { "type": "unavailable", "code": "history_unavailable",
             "message": "Older history is temporarily unavailable", "retryAfterMs": 30000,
             "requestId": "req_..." } }

Deleted messages are returned as tombstones: { id, seq, senderId, deletedAt, text: null, attachments: [] }.

Sync and events

Method & path Caller Notes
POST /v1/sync Client { "cursor": "..." | null }
GET /v1/conversations/{id}/events Members after (seq), limit ≤ 200
// POST /v1/sync
{ "cursor": "c_1791100000000" }
// 200
{ "conversations": [ { "id": "conv_01J9ZK...", "lastSeq": 815, "unreadCount": 2 } ],
  "removed": [ "conv_01J8AA..." ],
  "cursor": "c_1791100420000",
  "fullReload": false }
// GET /v1/conversations/conv_01J9ZK.../events?after=812
{ "data": [
    { "seq": 813, "type": "message.new", "message": { ... } },
    { "seq": 814, "type": "message.updated", "message": { "id": "msg_...", "version": 2, ... } },
    { "seq": 815, "type": "member.added", "userId": "user_999", "actorId": "user_123" } ],
  "hasMore": false }
// 410 when after < buffer floor or gap > 1000
{ "error": { "type": "conflict", "code": "resync_required", ... } }

Read state

POST /v1/conversations/{id}/read — client. { "seq": 815 }. Monotonic (lower values ignored). Emits receipt to members and coalesced message.read webhook.

Uploads and attachments

Files go straight from the client to the environment's media storage (your own Cloudinary, S3-compatible bucket or Azure Blob container when connected, otherwise ours) and never transit our servers. See Media storage.

Method & path Caller Notes
POST /v1/uploads Members, server Membership, MIME allowlist (SVG, HTML, JavaScript, executables blocked), size ≤ 25 MB; returns a signed upload ticket for the media provider (valid 10 min): a multipart POST with fields for Cloudinary, or a PUT to a pre-signed url with headers for S3 and Azure
POST /v1/uploads/{id}/complete Uploader, server Looks the object up in the media storage (bytes, detected format, dimensions); ready
GET /v1/attachments/{id}/url?expiresIn= Members, server Expiring private download URL; expiresIn 60–3600 s, default 300; attachment disposition for non-images
// POST /v1/uploads
{ "conversationId": "conv_01J9ZK...", "filename": "photo.jpg", "size": 482113,
  "contentType": "image/jpeg", "width": 1200, "height": 900 }
// 201
{ "attachmentId": "att_01J9ZM...",
  "upload": { "method": "POST", "url": "https://api.cloudinary.com/v1_1/<cloud>/image/upload",
              "fields": { "public_id": "byotalk/env_.../conv_01J9ZK.../att_01J9ZM...", "timestamp": "1791100000",
                          "type": "authenticated", "api_key": "...", "signature": "..." } },
  "expiresAt": "2026-10-04T10:16:00Z" }
// client: multipart POST to upload.url with every field plus "file"
// POST /v1/uploads/att_01J9ZM.../complete
{ "id": "att_01J9ZM...", "name": "photo.jpg", "size": 482113, "mimeType": "image/jpeg",
  "width": 1200, "height": 900, "status": "ready" }
// GET /v1/attachments/att_01J9ZM.../url
{ "url": "https://api.cloudinary.com/v1_1/<cloud>/image/download?public_id=...&expires_at=...&signature=...",
  "expiresAt": "2026-10-04T10:11:00Z" }

For Cloudinary, the upload url path segment is image, video (also used for audio) or raw (documents and other files). Errors: 413 file_too_large, 400 invalid_request (type not allowed, or not a valid image/video), 422 attachment_not_ready (complete called before the upload finished), 503 storage_unavailable (no media storage configured or the provider is unreachable).

Push devices

Method & path Caller Notes
POST /v1/push/devices Client { "provider": "fcm|apns|expo", "token": "..." }
DELETE /v1/push/devices/{token} Client On logout

Webhooks

Method & path Caller
POST/GET/PATCH/DELETE /v1/webhooks[/{id}] Server, dashboard
POST /v1/webhooks/{id}/rotate-secret Server, dashboard
POST /v1/webhooks/{id}/test Server, dashboard
GET /v1/webhooks/{id}/deliveries Server, dashboard
POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay Server, dashboard
// POST /v1/webhooks
{ "url": "https://api.example.com/chat-webhooks",
  "events": ["message.created", "message.deleted", "member.added", "sink.failing"] }
// 201 (secret shown once)
{ "id": "wh_01J9...", "url": "...", "events": [...], "status": "enabled", "secret": "whsec_MfKQ9r8G..." }

Storage (dashboard session)

Session cookie plus Byotalk-Env; changes need a sign-in within 15 minutes (403 reauth_required) and are audited.

Method & path Notes
GET /v1/storage Mode (managed or byo_db), buffer retention, database slots (masked URLs, engine, last test), sink state and lag, media, egress IP, supported.databases
PATCH /v1/storage { "mode": "managed|byo_db", "bufferRetention": "24h|72h|7d|30d" } (byo_pg is accepted as an alias of byo_db)
PUT /v1/storage/database/{slot} { "url", "type"?: "postgres|mysql|mongodb|http", "schema"?, "replicaUrl"? }; slot a|b. For http the response includes the signing secret once
POST /v1/storage/database/{slot}/rotate-secret HTTP endpoints: new signing secret, shown once
POST /v1/storage/database/test { "slot"? } → { ok, type, version, role, tls, schemaVersion, grants, errors[], warnings[], rttMs, egressIp }
POST /v1/storage/database/migrate { "adminUrl", "type"?, "schema"? } (Postgres, MySQL, MongoDB): creates tables and a writer, saves its URL to slot A; admin URL never stored. Returns runtimeUrl: null plus notes when the user must be created by hand
POST /v1/storage/database/promote/{slot} Credential rotation
POST /v1/storage/database/resync Re-write buffer contents
PUT /v1/storage/media One of {provider: "cloudinary", cloudinaryUrl, folder?}, {provider: "s3", preset?, endpoint?, region, bucket, accessKeyId, secretAccessKey, prefix?, forcePathStyle?}, {provider: "azure", account, accountKey, container, prefix?, endpoint?}, each with optional origin for the CORS check. Returns the test result
POST /v1/storage/media/test { "origin"? } → { ok, error, checks: [{name: upload|read|delete|cors, ok, detail?}], provider, at }
DELETE /v1/storage/media Back to Managed media

Calls

POST /v1/calls, GET /v1/calls, GET /v1/calls/{id}, POST /v1/calls/{id}/join|decline|leave (client), and POST /v1/calls/{id}/end, .../participants/{userId}/remove|mute (server, dashboard). Request bodies, the call object and errors: Voice and video calls.

Analytics

GET /v1/analytics?from=2026-10-01&to=2026-10-04&granularity=day — server key or dashboard. granularity=hour covers at most 7 days, day at most 366. Add format=csv for the time series as CSV. Counts only; no message text is read. Totals include calls, videoCalls, callsAnswered, callsMissed, callAnswerRate, callMinutes (participant minutes) and avgCallSeconds; series rows include calls, callsAnswered, callsMissed and callMinutes.

{ "range": { "from": "...", "to": "...", "granularity": "day", "timezone": "UTC" },
  "totals": { "messages": 1520, "activeUsers": 64, "newUsers": 12, "conversations": 30, "messagesPerActiveUser": 23.8,
              "mauThisMonth": 80, "peakConnections": 21, "webhookSuccessRate": 0.998, "avgAckMs": 14, "avgFanoutMs": 9, "...": "..." },
  "series": [ { "t": "2026-10-01T00:00:00.000Z", "messages": 410, "activeUsers": 30, "...": "..." } ],
  "topConversations": [ { "id": "conv_...", "name": "Order #8812", "type": "group", "messages": 120, "senders": 3, "lastAt": "..." } ],
  "topSenders": [ { "userId": "user_123", "name": "Asha", "messages": 210 } ],
  "heatmap": [[0, 0, "... 24 values per weekday, Monday first, UTC hours"]],
  "live": { "connections": 12 },
  "plan": { "name": "starter", "mauLimit": 2500, "connectionLimit": 250 },
  "storage": { "mode": "byo_db", "sink": { "status": "healthy", "lagSeconds": 2 } } }

Usage

GET /v1/usage?from=2026-10-01&to=2026-10-04&granularity=day — server, dashboard.

{ "data": [ { "date": "2026-10-01", "mau": 812, "peakConnections": 63, "messages": 14092,
              "apiRequests": 30111, "webhookDeliveries": 14800, "pushSent": 2203 } ],
  "plan": { "name": "starter", "mauLimit": 2500, "connectionLimit": 250 } }

Health

Method & path Auth Returns
GET /healthz None 200 if the process is up
GET /readyz None 200 if Postgres and Redis are reachable and migrations are current; 503 otherwise

The gateway exposes the same two paths on rt.byotalk.com.

Server SDK mapping

SDK call (byotalk/server) Endpoint
chatServer.createToken(userId, opts) Local HS256 signing
chatServer.users.upsert(user) PUT /v1/users/{id}
chatServer.conversations.create(...) POST /v1/conversations
chatServer.messages.send(convId, {senderId, text}) POST /v1/conversations/{id}/messages
chatServer.webhooks.verify(rawBody, headers) Local HMAC verification

Webhook events and signature verification: see Webhooks.