Runtime API reference
The Graph-compatible passthrough — version handling, path validation, the routing gate and its seven reasons, headers, limits, and what gets recorded on the way past.
api.cosend.app proxies an allowlisted set of Meta Graph paths byte-compatibly. Same paths, same
payloads, same error envelopes. This page is the reference; the Runtime API
page is the argument for it, and migrating is the
ninety-second version.
Shape
https://api.cosend.app/v{n}.{m}/{path}
Authorization: Bearer csk_live_…
Every allowlisted request is forwarded to https://graph.facebook.com/v{n}.{m}/{path} with:
- the connection’s real Meta access token substituted into
Authorization; appsecret_proofcomputed server-side and appended — you never hold our app secret, and this is a piece of security work you would otherwise have to do yourself;- the request body forwarded byte for byte;
- Meta’s response returned byte for byte: status code,
errorenvelope, and the headers listed below.
A connection key, and only a connection key. An organization key is refused here. A Graph request names its target with the phone-number ID in the path, so if an org-wide credential were accepted that path segment would be selecting the connection — and a bug in that resolution is a cross-tenant leak. With a connection key the connection is already known and the path is only ever checked against it.
Version handling
You choose. The segment is validated against ^v\d{1,2}\.\d{1,2}$ and against a supported range,
then forwarded verbatim. That is what makes this a drop-in for an integration pinned to v25.0.
- Supported range: the newest three Graph versions Meta lists as available, plus any version Meta still serves.
- Out of range →
400in Meta’s envelope withunsupported_api_version. - Cosend’s own internal calls use a pinned version of their own, independently of whatever you send.
What is proxied
An allowlist, never a denylist. An unlisted path returns 404 in Meta’s envelope. A denylist
would mean every Graph endpoint Meta ships next month is exposed by default, including ones that
mutate our own account structure — so new paths are opt-in rather than opt-out.
Six families are refused, each with a stated reason and a stated condition for re-entry, and the list is published rather than described: the coverage table has every path we proxy and every one we do not.
“Which parts of Graph do you support?” is the first question a technically serious buyer asks, and everything is a lie every proxy tells once. So the answer is a table.
Path validation
For every proxied request, the leading node ID in the path is checked rather than resolved:
- A phone-number ID must equal the one belonging to the key’s connection.
- A WABA ID must equal the account that connection belongs to.
- A media ID is forwarded with the phone-number ID forced to the key’s connection, so Meta itself enforces ownership.
- Any mismatch →
404in Meta’s envelope, and nothing is sent to Meta.
404, not 403. A 403 would confirm that the ID exists somewhere in the system, which
turns a guess into an oracle. The path never selects the connection; it is only ever checked
against it.
The routing gate
Before anything is forwarded, the request is refused if the subscription does not permit routing.
HTTP 403, in Meta’s envelope shape, so existing client error handling sees a familiar
object:
{
"error": {
"code": "connection_not_routable",
"reason": "billing_unpaid",
"message": "The subscription does not currently permit API routing.",
"docs": "https://cosend.app/docs/runtime-api#connection-not-routable"
}
}
Connection not routable
This heading is the anchor the envelope above points at, which is why it is worded as the code is.
reason is the field to branch on, and these seven are the whole set:
reason | Cause | Fix |
|---|---|---|
billing_unpaid | An invoice failed; the subscription is past due or unpaid | Update the card |
subscription_canceled | Cancelled, or lapsed | Resubscribe |
free_tier_message_cap | Free’s 1,000 routed messages a month is reached. The Runtime API itself is included on Free — this is the volume cap, not a feature gate | Upgrade |
quota_exceeded | A hard cap was reached | Wait for the period, or upgrade |
connection_suspended | We suspended it, for abuse or an acceptable-use breach | Contact support |
connection_not_active | The connection needs reauthorisation, is disconnected, or is payment-blocked at Meta | Reconnect |
test_key_on_live_connection | A test credential against a live connection | Use a live key |
Headers
| Direction | Headers |
|---|---|
| Forwarded to Meta | Content-Type, Content-Length, Accept, Accept-Encoding, and Idempotency-Key if you sent one — which is also honoured locally |
| Stripped, always | Authorization (replaced), Cookie, X-Forwarded-*, Host, and any X-Cosend-* header you send. A client must not be able to forge our internal headers |
| Returned to you | Meta’s status code, Content-Type and body, plus X-Request-Id, the rate-limit headers and X-Cosend-Proxy. Meta’s own rate-limit headers are forwarded where present, prefixed X-Meta- |
Streaming and limits
| Concern | Behaviour |
|---|---|
| Request body | Streamed to Meta, not buffered. A 100 MB video upload must not live in an API process’s heap |
| Maximum upload | 100 MB, which is Meta’s own limit. Over it → 413 in Meta’s envelope |
| Response body | Streamed back |
| Timeout | 30 seconds, connect plus read. On expiry → 504 in Meta’s envelope with upstream_timeout |
| Retries | None on a non-idempotent method. A POST to /messages that times out is *in doubt*, not retried — a duplicate WhatsApp message to a real person is worse than an error. An idempotent GET retries once on a connection error |
What gets recorded on the way past
Every proxied request is recorded asynchronously and never on the critical path. This is the part that makes a passthrough worth pointing at rather than merely equivalent:
- A trace row — the host, never the full URL.
- On a successful send: a message row with the returned message ID, and the contact and conversation rows created or touched. This is how a passthrough customer’s inbox fills up without them doing anything.
- On a template list: the template mirror is refreshed.
- A usage event for the send — counted for display and for limits, and never billed. Meta bills your WhatsApp Business Account directly and we do not mark it up.
- On a connection in direct-delivery mode: the trace and the metadata only. No body is written at any point.
If the recording fails, the proxied response is still returned. Your send succeeded at Meta; our bookkeeping is not your problem.