REST API conventions
Every REST endpoint in the message.com API follows the same conventions. Read this once and the per-endpoint reference pages become very thin. Base URL, headers, pagination, idempotency, and optimistic locking are all uniform.
Base URL
https://app.message.com/api/v1All endpoints listed in these docs are relative to that base. There is no separate sandbox host: test traffic uses msg_test_* API keys (planned) routed against the same base URL. We never break URL paths inside the v1 surface (see Versioning).
Required headers
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/jsonThe Authorization header carries a workspace JWT (today) or an API key (planned). The other two are standard and we reject requests missing them with 400 invalid_body rather than silently coercing.
Optional but recommended: include an Idempotency-Key on any POST that creates a resource or sends a message. See the idempotency section below.
Cursor pagination
List endpoints return a fixed slice plus a nextCursor. Pass that cursor as the cursor query parameter on the next request to fetch the previous page (we paginate backward chronologically by default). Cursors are opaque strings, but their format is stable: <ISO-timestamp>|<uuid> so you can decode for debugging.
GET /api/v1/conversations?cursor=2026-05-13T15:30:00Z|01HRX0...&limit=50{
"conversations": [ /* up to 50 items */ ],
"nextCursor": "2026-05-13T15:25:00Z|01HRX0..."
}Rules
limitdefault is 50, maximum is 200. Above 200 returns400 invalid_query.- Pass no cursor on the first request.
- When
nextCursorisnull, you have reached the end of the list. - Cursors do not expire, but they are scoped to the exact filter combination. Changing
statusmid-pagination invalidates the cursor. - We never use offset-based pagination. Skip-and-take falls apart at scale and we prefer to keep one consistent model across the API.
Idempotency
For destructive POSTs (creating conversations, sending messages, processing payments) include an Idempotency-Key header with any unique string per intent. We dedupe within a 24-hour window: identical key + identical request body returns the cached response without performing the work twice.
POST /api/v1/conversations/abc/messages
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: 4b9d2f4c-a3a1-4eaa-9c2f-2c2b9d3f7c01
{ "body": "Hi, thanks for reaching out." }Rules
- UUIDs (v4 or v7) are ideal idempotency keys. Any string up to 255 chars is accepted.
- Same key + same body within 24h returns the original response (cached).
- Same key + different body within 24h returns
409 conflictwitherror: "idempotency_key_reused". - GETs ignore
Idempotency-Key. They are idempotent by HTTP definition. - Retries (e.g., after network timeout) should reuse the original key. This is the single most reliable way to avoid duplicate messages and double-charges.
Webhook handlers should generate the idempotency key from the upstream event ID, not from a fresh UUID. See Webhook retries and idempotency.
Optimistic locking
Resources that support concurrent edits (conversations, departments, sites) include a version integer that bumps on every mutation. PATCH and POST mutations require an expectedVersion body field; on mismatch we return 409 stale_version and refuse the write.
PATCH /api/v1/conversations/abc/status
Authorization: Bearer <token>
Content-Type: application/json
{ "status": "solved", "expectedVersion": 4 }{
"statusCode": 409,
"error": "stale_version",
"message": "Conversation version is 5; expected 4",
"detail": { "currentVersion": 5 }
}Standard retry loop
- GET the resource. Read
version. - Build the mutation, set
expectedVersion: <that version>. - PATCH. On success, you are done.
- On
409 stale_version, GET again, merge your intent with the latest state, retry. Cap retries at three.
This is how the dashboard handles two agents touching the same conversation in the same second. It is also how you should write any backend service that mutates shared state. Fowler's pattern if you want the long form.
IDs
All resource IDs are UUIDs (v4 or v7). They are URL-safe, globally unique, and never reused. We do not expose numeric autoincrement IDs anywhere.
Timestamps
Every timestamp in the API is ISO 8601 UTC with milliseconds (2026-05-13T15:30:00.123Z). We never return naked epoch seconds. If you need epoch, parse the ISO string client-side.
Status codes
See Errors, rate limits, versioning for the full table. The short version:
2xxsuccess.4xxyour request needs to change.5xxwe have a problem. Retry with backoff.
Content negotiation
JSON only. We do not serve XML, msgpack, or protobuf on the public API. Upload endpoints accept multipart/form-data for binary payloads.
CORS
The API allows browser requests from any origin so long as a valid bearer token is present. For unauthenticated widget config the allowed origins list is workspace-configurable. We never echo arbitrary Origin headers without validation.
Common pitfalls
- Polling lists for new items. Use the Socket.io agent namespace instead. The list endpoints exist for backfill and audit, not for live updates.
- Treating
nextCursoras an offset. Cursors are opaque. Do not parse, do not arithmetic. - Reusing an idempotency key with a different body. That is a 409 by design, not a bug.
- Skipping
expectedVersion. Endpoints that require it return400 invalid_body; do not retry blindly. - Hardcoding
app.message.com. Read the base URL from config so we can change CDN edges without your code shipping.