Runtime API

Migrate in two lines.

api.cosend.app speaks the Graph dialect you already wrote code against. Swap the hostname, swap the token — your payloads, your version pin and your error handling all stay exactly as they are.

The whole migration

Two lines, and nothing else changes. The version segment is yours: we validate it and forward it verbatim, so a client pinned to an older Graph version keeps working.

migration.diff
// the entire migration
- POST https://graph.facebook.com/v26.0/{phone_number_id}/messages
+ POST https://api.cosend.app/v26.0/{phone_number_id}/messages

- Authorization: Bearer EAAG…      // Meta system-user token
+ Authorization: Bearer csk_live_…  // Cosend connection key

The request body is forwarded byte for byte and Meta's response comes back byte for byte, including its status code and its error envelope. One thing is added on the way through: appsecret_proof is computed server-side, so you get a security control you would otherwise have to build, and our app secret never leaves our side.

The one place we are not byte-identical, stated here rather than discovered later. GET /{MEDIA} returns a download URL on a different Meta host, which your Cosend key cannot authenticate against. We rewrite that one field to a Cosend URL and stream the bytes. Returning Meta's URL unchanged would be faithful and useless.

What we proxy

An allowlist, never a denylist — otherwise every Graph endpoint Meta ships next month is exposed by default, including the ones that mutate account structure. An unlisted path returns a 404 in Meta's own envelope shape.

Graph paths the Runtime API proxies, the methods accepted, and any condition.
Path Method Coverage Notes
/{PNID}/messages POST Full The endpoint. Send, mark-read, typing indicator and reaction — Meta multiplexes all four here.
/{PNID}/media POST Full Upload. Streamed to Meta rather than buffered, up to Meta’s own 100 MB limit.
/{MEDIA} GET · DELETE One field rewritten The only place we are not byte-identical — see below. Media URLs expire in five minutes, so nothing here is cached.
/{PNID}/block_users GET · POST Full Blocking is a messaging operation, and it is yours.
/{WABA}/message_templates GET · POST · DELETE Full A customer with a template pipeline keeps it. Reads also refresh our own mirror.
/{WABA}/template_analytics GET Full Read-only.
/{PNID} GET Full Read the number: quality rating, throughput, platform type.
/{PNID}/whatsapp_business_profile GET · POST Full Your own business profile.
/{PNID}/conversational_automation GET · POST Full Ice-breakers and commands.
/{PNID}/request_code · /verify_code POST Full Number verification.
/{WABA}/phone_numbers GET Full List the numbers on the account.
/{WABA} GET Full Read the WhatsApp Business Account itself.
/{WABA}/analytics GET Full Message volume.
/{WABA}/conversation_analytics GET Full Conversation counts and cost.
/{WABA}/pricing_analytics GET Full Per-message cost. Needs no message content, so it works on a direct-delivery connection too — which is how the dashboard shows spend without storing anything.
/{PNID}/register POST Cloud API only Refused on a coexistence connection, where the number is already registered and re-registering underneath a live pairing is the fastest way to break it.
/{WABA}/subscribed_apps Refused Cosend owns this subscription. See below.
/{PNID}/deregister Refused Destroys the connection we hold open. See below.
/{PNID}/smb_app_data Refused The one-shot history sync. See below.

What we refuse, and why

Three refusals are worth naming individually, because each one exists to stop a key destroying the connection it is calling through:

POST /{WABA}/subscribed_apps
A single call would unsubscribe Cosend from your own webhooks. Every automation, every inbox update and every delivery receipt stops — silently, with no error anywhere, and neither of us knows why. Cosend owns this subscription.
POST /{PNID}/deregister
Meta forbids it on a connected coexistence number, and on a Cloud API number it destroys the connection we are holding open. Disconnecting is a first-class operation on our own API instead.
POST /{PNID}/smb_app_data
This is the coexistence history sync, and it is one-shot with a 24-hour deadline. Firing it by hand burns your only history import. Cosend owns the timing.

Path validation is the other half of it. A Graph request names its target by the phone-number ID in the path — and that segment never selects a connection. The key already identifies one, and the path is checked against it. A mismatch returns 404, not 403, because a 403 would confirm that somebody else's phone-number ID exists.

Not proxied yet — with the reason

These are real WhatsApp endpoints a passthrough could carry. They are excluded with a stated reason rather than silently omitted:

Flows
A Flow is a stateful UI artefact with its own versioning and encryption. Proxying its creation would give you a Flow whose lifecycle we do not track and whose responses arrive on a webhook we have no model for.
Calling
Calls are on Meta’s coexistence exclusion list, so this would be a feature that silently does nothing for most of our customers.
Groups
Meta’s general-availability announcement and the coexistence exclusion list contradict each other. An endpoint whose availability we cannot state is worse than not having it.
Product catalog and commerce
Also on the coexistence exclusion list. Your catalog keeps working in the WhatsApp Business app; the API cannot drive it.
Marketing Messages
Some accounts already route marketing sends down a different path, and fail with a specific Meta error when they do not. Our own send endpoint abstracts that, so this becomes a routing decision rather than a second public surface.
Payments
Regional, regulated and moving. Money endpoints are the last thing to proxy blindly.

A send, end to end

The same request you are sending today, against a different host. Nothing in the body changes.

curl
curl -X POST https://api.cosend.app/v26.0/106540352242922/messages \
  -H "Authorization: Bearer csk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "messaging_product": "whatsapp",
    "to": "+5511999482222",
    "type": "text",
    "text": { "body": "On our way — 10 minutes." }
  }'
200 · Meta's response, unmodified
{
  "messaging_product": "whatsapp",
  "contacts": [{ "wa_id": "5511999482222" }],
  "messages": [{ "id": "wamid.HBgLNTUxMTk5NDgyMjIVAgARGBI3" }]
}

Timeouts are 30 seconds to Meta, and a POST /messages that times out is never retried. A duplicate WhatsApp message to a real person is worse than an error.

What the proxy fills in while you use it

Every proxied send is recorded afterwards, asynchronously, and never on the critical path: a message row, the contact, the conversation. So a week after pointing a hostname at us, there is a working shared inbox, a contact list and a spend breakdown that nobody built.

If the recording fails, your send still succeeded. It happened at Meta and you got Meta's response; our bookkeeping is not your problem.

If you would rather we stored nothing, that is a setting rather than a different product. On a direct-delivery connection only metadata is recorded — no message body, at any point. The three delivery modes are all free, on every plan.