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 →
/v2with ≥ 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..." }
limitdefault 50, max 200.nextCursoris opaque;nullat 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
clientMsgIdin 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.