REST API
Send WhatsApp messages from your own app — order updates, sign-in codes, reminders — and receive replies by webhook. Keys, endpoints, errors and signed webhooks.
Updated September 25, 2026 · 9 min read
The REST API lets your app send and receive WhatsApp messages on its own, with the same rules as the dashboard and your AI assistant: opt-outs, the 24-hour window, plan limits and new-number warm-up all apply.
Base URL: https://sendfromchat.com/api/v1
Building with Claude Code? Connect SendFromChat (https://sendfromchat.com/api/mcp) and ask it to "add WhatsApp order notifications to my app". It fetches your approved templates and writes the integration for your language.
Authentication
Send an API key as a bearer token:
Authorization: Bearer sfc_live_…
With Claude Code, you never copy a key. Ask it to set up SendFromChat in your project: it creates the key and saves it straight into .env.local, or into Vercel, Netlify, Railway, Fly.io, Heroku, Cloudflare Workers or GitHub Actions secrets through that platform's CLI. The key never appears in the conversation — Claude only handles a one-time code that expires in ten minutes. Live keys are created only after you confirm.
You can also create keys on the dashboard's Developers page. Each key has a name and can be revoked on its own.
- Test keys (
sfc_test_…) run every check a real send would — plan, opt-outs, template, 24-hour window — then stop. Nothing is sent, saved or counted toward your plan. Build with one. - Live keys (
sfc_live_…) send for real.
Keep keys on your server. Never put one in browser or mobile-app code.
Check a key with GET /api/v1: it returns the workspace, whether the key is live or test, and the numbers it can send from.
Send a message
POST /messages
A template, which works any time:
{
"to": "+14155550132",
"template": { "name": "order_shipped", "language": "en_US", "variables": ["Ana", "#4821"] }
}
Free-form text, which only delivers within 24 hours of the person's last message to you:
{ "to": "+14155550132", "text": "Thanks — we're on it." }
to— international format with country code. A file, which follows the same 24-hour rule as text:
{
"to": "+14155550132",
"media": { "url": "https://example.com/brochure.pdf", "filename": "Brochure.pdf", "caption": "Here's the brochure" }
}
to— international format with country code.template.variables— fills{{1}},{{2}}… in order. An object like{"1": "Ana"}works too.whatsappAccountId— optional; the id of a connected number (fromis the same setting).GET /api/v1lists them. Without it: a number this person wrote to in the last 24 hours, so text replies deliver; otherwise the number last used with them; otherwise your first connected number.sentBy— optional; the person in your app who's sending, as{ "externalId": "u_12", "name": "Ana" }. The inbox shows the name, and it comes back inmessage.sentand when reading the conversation.nameis required, up to 100 characters.
Response 202:
{ "id": "wamid.HBgL…", "status": "accepted", "to": "+14155550132", "from": "…", "whatsappAccountId": "…", "type": "template" }
With a test key the response is 200 with "status": "test".
Files
media takes either a url or the id of a file you uploaded, plus these optional fields:
type—document,imageorvideo. With aurl, it's worked out from the file name if you leave it out, and anything unrecognised goes as a document.filename— the name shown on a document.caption— up to 1,024 characters.
A url must be public and use https, because WhatsApp fetches the file itself. If it can't, the send is accepted and message.status later reports failed.
If the file isn't at a public link, upload it first:
POST /media with a live key and { "filename": "brochure.pdf", "contentType": "application/pdf", "size": 2400000 }
Response 201: { "id": "…", "uploadUrl": "…" }. PUT the file's bytes to uploadUrl within two hours, with the same Content-Type, then send { "media": { "id": "…" } }.
| Kind | Formats | Largest upload |
|---|---|---|
| Image | JPEG, PNG | 5 MB |
| Video | MP4, 3GP | 16 MB |
| Document | PDF, Word, Excel, PowerPoint, text | 50 MB |
Links you send yourself follow WhatsApp's own limits: 5 MB, 16 MB and 100 MB.
An uploaded JPEG or PNG can also become a template's picture: pass its id as headerImageId to the create_template MCP tool.
When the window has closed
Text and files sent after the 24-hour window return window_closed. windowOpenUntil says when it closed, so your app can explain that and offer a template:
{ "error": { "type": "window_closed", "message": "…", "windowOpenUntil": "2026-11-03T09:14:00.000Z" } }
The window is per number, so it depends on which number you send from.
Sign-in codes
Use a template in the Authentication category and pass the code as variable 1. SendFromChat fills in the copy-code button Meta requires.
{ "to": "+14155550132", "template": { "name": "login_code", "variables": ["482913"] } }
Retries without double-sending
Send an Idempotency-Key header, derived from the event in your app:
Idempotency-Key: order-4821-shipped
A retry with the same key and body returns the first response, with Idempotent-Replayed: true, instead of sending again. Reusing a key with a different body returns idempotency_mismatch.
Check a message
GET /messages/{id} returns its status: sent (Meta accepted it) → delivered → read, or failed with the reason. For updates as they happen, use webhooks.
Read conversations
Reading never marks a chat as read, so your app can catch up without hiding anything from your team.
GET /conversations?phone=+14155550132 returns that person's conversation in a conversations list, which is empty if they have none. Without phone you get the most recent, up to limit (50 by default, 200 at most).
{
"conversations": [
{
"id": "…",
"phone": "+14155550132",
"name": "Ana",
"whatsappAccountId": "…",
"assignee": { "id": "…", "name": "Sam", "email": "sam@example.com" },
"optedOut": false,
"windowOpen": true,
"windowOpenUntil": "2026-11-04T09:14:00.000Z",
"openOn": [{ "whatsappAccountId": "…", "windowOpenUntil": "2026-11-04T09:14:00.000Z" }],
"unreadCount": 2,
"needsHuman": false,
"lastMessageAt": "2026-11-03T09:14:00.000Z"
}
]
}
windowOpenUntil is when the latest 24-hour window closes, or closed if windowOpen is false. It's null if they've never written in. openOn lists the numbers that can send free-form messages right now.
GET /conversations/{id}/messages?since=&cursor=&limit= returns messages in both directions, oldest first:
since— only messages from this time on.limit— 100 by default, 500 at most.cursor— pass thenextCursorfrom the previous response to get the next page. It'snullon the last page.
Each message has the fields of message.sent, plus id, direction (inbound or outbound), from, status and error. Incoming messages have from and text; their to, source and sentBy are null. messageId is what GET /messages/{id} and the webhooks use, and is null if a message failed before reaching Meta.
Assign a chat
PATCH /conversations/{id} with a live key, so a chat can follow its lead when the owner changes in your CRM:
{ "assignTo": "sam@example.com" }
assignTo is a teammate's email or id, or null to unassign. The teammate gets their usual alert, and the response is the updated conversation.
Templates
GET /templates lists your templates, their status and the variables each needs. Add ?status=approved for only the ones you can send.
A template with an image header sends with its picture automatically, so there's nothing extra to pass. If we don't have the picture, for example a template made in WhatsApp Manager without a usable sample, the send returns header_image_missing.
Contacts
PUT /contacts creates or updates a contact by phone number. Attributes merge into what's already there.
{ "phone": "+14155550132", "name": "Ana", "attributes": { "plan": "pro" }, "optedOut": false }
Only add people who agreed to hear from you on WhatsApp. Setting optedOut: true blocks template sends to them.
Sequences
Start a drip sequence for someone, for example when they sign up:
POST /sequences/{id}/enrollments with { "phone": "+14155550132" }
Stop it: DELETE /sequences/{id}/enrollments?phone=+14155550132
Webhooks
Add an endpoint on the Developers page, or with a live key:
POST /webhook-endpoints with { "url": "https://app.example.com/webhooks/sendfromchat", "events": ["message.received"] }
The response includes the signing secret, shown once.
| Event | When | data |
|---|---|---|
message.received |
A customer wrote in | messageId, from, name, type, text, receivedAt, whatsappAccountId, conversationId |
message.sent |
Any message went out | see below |
message.status |
A sent message changed status, whatever sent it | messageId, to, status, error, whatsappAccountId, at |
contact.opted_out |
Someone replied STOP, or was opted out | phone, at |
contact.opted_in |
Someone opted back in | phone, at |
message.sent fires for every outgoing message once Meta accepts it, from flows, auto-replies, bulk sends, drip sequences, the inbox, your assistant and this API:
{
"messageId": "wamid.HBgL…",
"conversationId": "…",
"to": "+14155550132",
"source": "api",
"sentBy": { "id": null, "externalId": "u_12", "name": "Ana" },
"flowId": null,
"type": "template",
"text": "Hi Ana, your order #4821 has shipped.",
"template": {
"name": "order_shipped",
"language": "en_US",
"variables": { "1": "Ana", "2": "#4821" },
"renderedBody": "Hi Ana, your order #4821 has shipped."
},
"media": null,
"whatsappAccountId": "…",
"at": "2026-11-04T09:14:00.000Z"
}
source—flow,auto,campaign,sequence,dashboardorapi. Your assistant counts asapi.sentBy— the person behind it, ornullfor automatic messages.idis a teammate's id, for messages sent from the inbox or a signed-in assistant.externalIdandnamecome from yoursentBy.text— the words the person received. For a template it's filled in from your template as it stands now, and isnullif the template isn't in this workspace yet.media—{ type, url, id, filename, caption }for a file.
An endpoint added before message.sent existed doesn't receive it. To get it, remove the endpoint and add it again.
Every request body looks like { "id": "evt_…", "type": "…", "created": "…", "data": { … } }. Deliveries can repeat, so skip id values you've already handled. Answer with any 2xx within 5 seconds; failures are retried with backoff for about a day.
Verifying signatures
Each request carries:
SendFromChat-Signature: t=1790300000,v1=5f2b…
v1 is the hex HMAC-SHA256 of <t>.<raw request body>, keyed with your endpoint's secret. Compute it over the raw body, compare in constant time, and reject timestamps older than five minutes to stop replays.
import crypto from "node:crypto";
function verifySendFromChat(rawBody, header, secret) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Errors
Errors share one shape, with a stable type your app can branch on:
{ "error": { "type": "window_closed", "message": "…" } }
| Status | type |
Meaning |
|---|---|---|
| 400 | invalid_request, missing_variables |
Fix the request |
| 401 | unauthorized |
Missing, wrong or revoked key |
| 402 | sending_paused |
Trial ended or plan limit reached |
| 403 | live_key_required |
That action needs a live key |
| 404 | template_not_found, message_not_found, sequence_not_found, conversation_not_found, teammate_not_found, media_not_found, number_not_found |
Nothing with that name or id |
| 409 | no_number, request_in_progress |
No connected number, or the same Idempotency-Key is still running |
| 422 | opted_out, window_closed, template_not_approved, header_image_missing, whatsapp_rejected |
WhatsApp's rules — whatsapp_rejected carries Meta's reason |
| 429 | warmup_limit |
A new number has reached today's warm-up cap; retry later |
Limits
Every message sent through the API counts toward your plan's monthly allowance, like messages from the dashboard and your assistant. New numbers are warmed up: template sends are capped at 50, 100, then 200 a day for the first three days, shared with campaigns.
Do this from your AI assistant
SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.
Start freeMore docs
Getting started with SendFromChat
From sign-up to your first WhatsApp message in a few minutes — connect a number in one click, connect your AI assistant, send.
Connect your AI assistant
Add SendFromChat to Claude, ChatGPT, Claude Code, Cursor, VS Code, Windsurf, Gemini CLI, Codex or any MCP client.
Sending messages
Free-form replies vs templates, the 24-hour window, check_recipient, and what "accepted" means.
Templates
Create WhatsApp message templates, pass Meta's review first time, and track approval.