REST API conventions
Credentials, scopes, response headers, cursor pagination, idempotency, strict validation, versioning and rate limits — the things that are true of every /v1 endpoint.
https://api.cosend.app/v1/…. JSON in, JSON out, snake_case throughout — the same casing Meta
uses, so the passthrough and /v1 read alike.
This page is what holds for every endpoint. The endpoint-by-endpoint reference is generated from the same contracts package the API validates against, so it cannot drift from the API; it lands with the API rather than being hand-written here, because a hand-written copy would be a second source of truth and it would be wrong within a week.
Credentials
One header, Authorization: Bearer <credential>, and four kinds of credential.
| Credential | Grants | Where it works |
|---|---|---|
Connection key csk_… | Exactly one connection | Runtime API and /v1 |
Organization key cok_… | Every connection in one organization | /v1 only |
| OAuth access token | One organization, consent-scoped | /v1, through the MCP server |
| Dashboard token | One organization, one member, that member’s role. Fifteen minutes | /v1, from the browser |
Every key is {prefix}_{live|test}_{32 characters}. The full string is shown once at creation and
stored hashed — losing one means rotating it, not recovering it.
Test mode is the credential. A csk_test_ or cok_test_ key resolves to the same
organization but is accepted only against connections marked as test, and its traffic is excluded
from usage. A test key against a live connection returns
test_key_on_live_connection. There is no test-mode
header, so there is none to forget in production.
Scopes
Organization keys and OAuth tokens carry scopes; a connection key carries the connection-scoped
set by default and can be narrowed at creation. They are the obvious pairs —
messages:read / messages:write, and the same shape for contacts, templates, notifications,
automations, runs, connections, connectors, webhooks — plus usage:read and runtime:proxy.
keys:write is never granted to an OAuth token. A client that could mint API keys could turn
a scoped, revocable, audited consent into a permanent unaudited credential. The dashboard is the
only place a key is created.
On every response
| Header | Always |
|---|---|
X-Request-Id | The same value appears in the body, in our log line and in our error tracking. Support starts here |
X-RateLimit-Limit / -Remaining / -Reset | The bucket that governed this request |
Retry-After | On 429 and on 503 |
Idempotency-Replayed | Set when a stored response was returned rather than a new one produced |
Pagination
Cursor-based on the primary key. No offsets, ever — offsets skip and duplicate rows under concurrent writes, and an inbox is written to constantly.
GET /v1/messages?conversation_id=cnv_…&limit=50&after=msg_01K3F8QY7ZC4M2N9V6XJ0RTBHE
{
"data": [ … ],
"has_more": true,
"next_cursor": "msg_01K3F8QY7ZC4M2N9V6XJ0RTBHF"
}
limit defaults to 25 and caps at 100. after walks forward, before walks backward,
next_cursor is null when has_more is false. Treat the cursor as opaque even though it is
a readable ID — that is what lets it change shape later without breaking you.
There is no total count and there will not be one, and therefore no “page 4 of 17”. Counting a
filtered set on every list request is a scan of the largest tables in the system, and the number is
wrong by the time it renders. Build a Load more against has_more. The one place a total is
legitimate is the usage endpoint, where the count is the resource.
Idempotency
Every side-effecting POST accepts Idempotency-Key, and on send endpoints it is required —
a retried send with no key is a duplicate message to a real person.
- Keys are stored for 24 hours.
- The stored record includes a hash of the request. The same key with a different body returns
idempotency_key_reuse, naming the key. Silently returning the first response would swallow a second, different intent. - A replay while the first request is still running returns
idempotency_key_in_flightwithRetry-After: 1. - A completed replay returns the original status and body, with
Idempotency-Replayedset.
Validation
Every body is parsed against a schema. A failure is 400 with param set to the pointer of the
first offending field and up to ten entries in details[].
Unknown fields are rejected, not ignored. A mistyped template_nmae fails loudly at 10am
rather than silently sending the wrong thing at 3am. The one exception is metadata objects,
which are open by design — they are yours, they are stored, they are echoed back on webhooks, and
they are never sent to Meta.
Versioning
/v1 is additive only. Adding a field, an optional parameter or an enum value is not a
breaking change, and clients must tolerate fields they do not recognise. A breaking change means
/v2, announced twelve months ahead, with both live throughout.
On the passthrough the version is Meta’s and you pick it.
Rate limits
| Bucket | Default |
|---|---|
| Per connection key | 100 requests/second, burst 200 |
| Per organization key | 200 requests/second, burst 400 |
| Per OAuth token | 20 requests/second, burst 40 |
| Per connection, outbound to Meta | The connection’s throughput ceiling — 20 a second on coexistence |
| Per IP, unauthenticated | 20 requests/minute |
A 429 from our limiter uses the /v1 envelope. A 429 that originated at Meta is passed through
with Meta’s payload and a Retry-After derived from their headers where they sent any.
Errors
Nine type values, forty-seven code values, and every envelope carries a link to the entry for
its own code. That is the error reference, and it is worth reading once before you
write the retry logic rather than after.
Resources
| Resource | Endpoints |
|---|---|
| Identity | the current organization, member and scopes |
| Connections | list, get, update, delete, connect, sync, reauthorise |
| Messages | send, list, get, mark read, react, typing |
| Conversations | list, get, update, assign, control, notes, tags |
| Contacts | create, read, update, delete, merge, consent, window |
| Templates | list, get, create, delete, sync |
| Media | upload, get, download |
| Notifications | send, batch, get, list, cancel, replay |
| Schedules | create, read, update, delete, occurrences |
| Automations | create, read, update, delete, versions, deploy, roll back, enable |
| Runs | list, get, trigger, cancel, events |
| Connectors | catalogue, accounts, connect, disconnect |
| Approvals | list, approve, reject |
| Usage | get, breakdown |
| Webhook endpoints | create, read, update, delete, test, deliveries, replay |
| API keys | list, create, revoke, rotate |
| Events | the audit log |
| Memberships and invites | list, invite, change role, remove, transfer ownership |
| Billing | subscription, checkout, portal |
Every list endpoint takes limit, after, before, created_after, created_before and
order. List and get endpoints take expand= with a comma-separated list of relation names —
GET /v1/conversations?expand=contact,last_message — so one round trip replaces N+1.