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, so 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. A workspace 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.
Seventeen families are refused, and the list is published rather than described: the coverage table has every path we proxy and every one we do not. They are refused for two different reasons, and the difference is the one worth knowing: eleven are blocked and are not coming back, while six are real WhatsApp endpoints we have not proxied yet, each with a stated reason and a stated condition for re-entry.
“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.
Endpoint not available
404 in Meta’s envelope, with code: "endpoint_not_available". You reached this page from that
error, so the reasoning is here rather than a link away.
The path you called is not on the allowlist. That is either because it is one of the seventeen families below, or because nothing has added it yet. An unlisted path is refused by default, and a denylist would have meant every Graph endpoint Meta ships next month was exposed until somebody noticed.
Eleven families are blocked, and the reason is the same for all of them: they mutate account
structure, credentials or billing, and a key scoped to one customer must not be able to reach
them. Several have a supported equivalent on /v1, which is the thing to reach for instead.
| Blocked | Why, and what to use |
|---|---|
POST /{PNID}/deregister | Meta forbids it on coexistence numbers, and on cloud-API numbers it destroys the connection we hold open. Use DELETE /v1/connections/{id} |
POST/DELETE /{WABA}/subscribed_apps | Unsubscribing us from your webhooks would silently end every automation, inbox update and delivery receipt, and neither of us would know why. This subscription is ours |
/oauth/access_token, /debug_token | Token exchange and introspection: our app’s credentials, and our app ID and its true scopes |
POST /{BUSINESS}/client_whatsapp_business_accounts | Creates WABAs under our Business Portfolio |
*/extended_credit_line_allocation_config, */credit_lines, */allocate_credit | Billing and credit-line sharing. Real money |
POST /{APP}/uploads | App-scoped rather than customer-scoped. Template media goes to /{PNID}/media |
*/system_users, */assigned_users, */business_users | Business-Manager administration |
POST /{PNID}/smb_app_data | The coexistence sync trigger: one-shot, with a 24-hour deadline. Fired by hand it consumes your only history sync |
/{PNID}?fields=webhook_configuration (write) | Webhook overrides are a /v1/connections setting, so our routing stays consistent with what we believe it to be |
Everything on /{USER}, /me, /{PAGE}, ads, catalogs, Instagram | Not WhatsApp |
Six families are not proxied yet, which is a different statement: each is a real WhatsApp endpoint, excluded from v1 with a reason and a condition for re-entry rather than quietly omitted.
| Not yet | Why not | Comes back when |
|---|---|---|
| Flows | A stateful UI artefact with its own versioning and encryption; proxying it would hand you a Flow whose lifecycle we do not track | We model Flows, and customers ask |
| Calling | On Meta’s coexistence exclusion list, so it would work only on cloud-API connections, a feature that silently does nothing for most customers | Meta supports calling on coexistence |
| Groups | The Groups GA and the coexistence exclusion list contradict each other; proxying an endpoint whose availability we cannot state is worse than not having it | The contradiction is resolved |
| Marketing Messages | Some accounts already route marketing sends differently, so this belongs behind our send endpoint as a routing decision rather than as a new surface | That routing lands |
| Product catalog and commerce | On Meta’s coexistence exclusion list | Coexistence support |
| Payments | Regional, regulated and moving. Money endpoints are the last thing to proxy blindly | Explicit legal review |
If your path is in neither table, it is not blocked. It simply has not been added. New paths are opt-in by design, and the coverage table is where they appear when they are.
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 Free-plan ceiling or a connection limit was reached. Paid AI and automation usage has no ceiling: going over is billed, not refused | Upgrade, or add the connection to your plan |
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.