message.comDevelopers

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

Production base
https://app.message.com/api/v1

All 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

HTTP headers
Authorization: Bearer <token>
Content-Type: application/json
Accept: application/json

The 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.

Paginated request
GET /api/v1/conversations?cursor=2026-05-13T15:30:00Z|01HRX0...&limit=50
Paginated response
{
  "conversations": [ /* up to 50 items */ ],
  "nextCursor": "2026-05-13T15:25:00Z|01HRX0..."
}

Rules

  • limit default is 50, maximum is 200. Above 200 returns 400 invalid_query.
  • Pass no cursor on the first request.
  • When nextCursor is null, you have reached the end of the list.
  • Cursors do not expire, but they are scoped to the exact filter combination. Changing status mid-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.

Idempotent request
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 conflict with error: "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.

Versioned mutation
PATCH /api/v1/conversations/abc/status
Authorization: Bearer <token>
Content-Type: application/json

{ "status": "solved", "expectedVersion": 4 }
409 stale_version
{
  "statusCode": 409,
  "error": "stale_version",
  "message": "Conversation version is 5; expected 4",
  "detail": { "currentVersion": 5 }
}

Standard retry loop

  1. GET the resource. Read version.
  2. Build the mutation, set expectedVersion: <that version>.
  3. PATCH. On success, you are done.
  4. 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:

  • 2xx success.
  • 4xx your request needs to change.
  • 5xx we 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 nextCursor as 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 return 400 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.