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 (from is the same setting). GET /api/v1 lists 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 in message.sent and when reading the conversation. name is 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, image or video. With a url, 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 the nextCursor from the previous response to get the next page. It's null on 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, dashboard or api. Your assistant counts as api.
  • sentBy — the person behind it, or null for automatic messages. id is a teammate's id, for messages sent from the inbox or a signed-in assistant. externalId and name come from your sentBy.
  • text — the words the person received. For a template it's filled in from your template as it stands now, and is null if 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 free