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.
// 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.
| 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 -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." }
}' {
"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.