Developers

Send WhatsApp from your own system

The SyncChat API lets your CRM, booking tool or back office send and receive WhatsApp through your organization's number — without ever holding WhatsApp provider credentials of its own.

Overview

What the API is for

Every business system eventually needs to reach a customer on WhatsApp — an order is ready, a booking moved, an account is overdue. Wiring each of those systems directly to a WhatsApp provider means spreading credentials, opt-out rules and message logs across places nobody is watching.

SyncChat sits in the middle instead. Your systems call one API; SyncChat owns the number, the provider, the conversation history and the billing. Whatever your app sends appears in the SyncChat inbox next to everything your team sends by hand, and whatever the customer replies is pushed back to your app as it arrives.

The core rule

A key belongs to one organization

Every API key is issued to exactly one organization, and every request resolves its organization from the key. A caller cannot name an organization, reach an instance outside its own, or read another tenant's contacts — those simply read as “not found”.

Three things follow from that:

  • The organization pays. Every message sent through a key is written against that organization and counted by its plan. A capped plan stops the send with a 402 rather than letting the bill drift.
  • The organization decides. It chooses which number a key may send from, which scopes it carries, and can revoke it in one click. Deactivating the organization stops all of its API traffic at once.
  • A leak is contained. A stolen key exposes one organization's messaging and nothing else.

Create keys in Settings → Developers. The full key is shown once, at creation, and never again.

Getting started

Authentication

Send the key as a bearer token on every request.

Every request
curl https://app.syncchat.co.za/api/v1/status \
  -H "Authorization: Bearer sc_live_a1b2c3d4e5f6_…"

x-api-key is accepted as an alternative for clients that cannot set an Authorization header. Scopes are checked per endpoint: messages:send, messages:read, contacts:read, contacts:write, conversations:manage, webhooks:manage.

Endpoints

Sending a message

POST/api/v1/messages
Sends a WhatsApp message from the organization's number, creating the contact and conversation if they don't exist yet.
Request
POST /api/v1/messages
Authorization: Bearer sc_live_…
Content-Type: application/json

{
  "to": "27821234567",
  "body": "Hi Thabo, your upgrade is ready for collection.",
  "clientRef": "lead-9f1c8a2e",
  "contactName": "Thabo M"
}
201 Created
{
  "message": {
    "id": "6c0f…",
    "conversationId": "b41a…",
    "direction": "outbound",
    "type": "text",
    "content": "Hi Thabo, your upgrade is ready for collection.",
    "status": "sent",
    "clientRef": "lead-9f1c8a2e",
    "providerMessageId": "true_27821234567@c.us_3EB0…",
    "createdAt": "2026-07-31T09:14:22.104Z"
  }
}

Always send a clientRef. It is your own id for the message, and it is what makes retries safe. If your request times out and you send it again with the same ref, you get the original message back with "duplicate": true instead of the customer getting a second copy. The ref is claimed before the provider is called, so even a crash mid-send cannot turn into a message you never learned about.

instanceId is optional. Leave it out and SyncChat uses the number the key is pinned to, or the organization's connected number. Media and other message types use type plus values.

Endpoints

Sending a template

On the WhatsApp Business Platform, free text only reaches a customer who has messaged you in the last 24 hours. Anything you start — an upgrade notice, a delivery update — must be an approved template, or WhatsApp drops it after accepting the call. Send a template by id or name and supply its custom variables; phone and email are filled from the contact, and so is name once we know it.

Send name yourself for a customer we have not met: a template variable called name (or first_name, full_name, customer_name), a top-level name, or contactName all do it. The contact is created under that name rather than a bare phone number, and one filed under its own number gets the real one. A name already on the contact is never overwritten.

GET/api/v1/templates
The organization's templates with their Meta approval status and variables.
Request
POST /api/v1/messages
Authorization: Bearer sc_live_…
Content-Type: application/json

{
  "to": "27821234567",
  "templateId": "58ad9318-…",
  "variables": { "line": "+27 82 123 4567" },
  "clientRef": "upgrade-2026-09-1234",
  "contactName": "Thabo M"
}

Outside the 24-hour window a free-text send is refused with 422 outside_service_window rather than silently lost; a template that Meta has not approved yet is refused with 422 template_not_approved; a missing variable with 400 missing_variables. A key pinned to a number that was removed falls back to your live number and the response carries a warnings array saying so.

Reading messages and conversations

GET/api/v1/messages?conversationId=&since=&limit=
The organization's message log, newest first. limit caps at 200.
GET/api/v1/conversations?status=&since=&limit=
Threads with their contact and last message.

Webhooks are the fast path, but they are not a guarantee — endpoints go down. These two endpoints are how a receiver catches up on anything it missed: poll with since set to the last event you successfully processed.

Turning the AI on and off

GET/api/v1/conversations/ai?phone=
Whether the AI is currently answering that customer.
POST/api/v1/conversations/ai
{ "phone": "27821234567", "enabled": false } — hand the thread to a human, or give it back to the bot.

Turn it off when one of your people picks a conversation up, so the customer does not get a machine reply on top of a human one. This is the same switch as Take over in the SyncChat inbox, so the two never disagree.

A number with no thread yet reads as aiEnabled: true — nothing has been said to it, and the AI would answer if something were. Posting to such a number returns 404; send a message first. Requires the conversations:manage scope.

Contacts

GET/api/v1/contacts?phone=
Look a contact up by number, or list the organization's contacts.
POST/api/v1/contacts
Create or update a contact by phone number — name, email, tags, metadata.

Sending already creates whatever contact it needs, so this is for pushing your customer list across in advance. Worth doing: it means your team sees “Thabo M” in the inbox when he replies, rather than a bare number.

Status and usage

GET/api/v1/status
The organization behind the key, its plan and message usage, and the numbers the key can send from.
200 OK
{
  "organization": { "id": "…", "name": "Cellular Citi", "plan": "pro", "isActive": true },
  "usage": { "plan": "pro", "capped": false, "limit": null, "used": 3412, "remaining": null, "reached": false },
  "key": { "scopes": ["messages:send", "messages:read"], "pinnedInstanceId": null },
  "instances": [
    { "id": "…", "name": "Main line", "phoneNumber": "+27821234567", "status": "connected", "provider": "ultramsg" }
  ]
}

Two good uses: render a connection card in your own admin screen, and call it at deploy time so a key pointed at the wrong organization fails loudly instead of quietly sending from the wrong number.

Receiving

Webhooks

Register endpoints in Settings → Developers. SyncChat posts these events to every active endpoint that subscribes to them:

message.inboundA customer replies
message.sentAn outbound message reaches the provider
message.deliveredThe handset acknowledges it
message.readThe customer opens it
message.failedThe provider rejects it
Delivery
POST https://your-app.example.com/webhooks/syncchat
x-syncchat-event: message.inbound
x-syncchat-timestamp: 1793827200
x-syncchat-signature: sha256=6f2a…

{
  "event": "message.inbound",
  "orgId": "…",
  "timestamp": 1793827200,
  "data": {
    "message": { "id": "…", "conversationId": "…", "content": "INTERESTED", "status": "delivered" },
    "contact": { "id": "…", "phone": "27821234567", "name": "Thabo M" },
    "instanceId": "…"
  }
}

Outbound events carry the clientRef you sent, so you can match a delivery receipt back to your own record without keeping a second index.

Delivery is fire-and-forget: a slow endpoint of yours never delays a WhatsApp send. Return 200 quickly and do your work afterwards. The last delivery status and error for each endpoint are shown in Settings.

Security

Verifying a webhook

Anyone can POST to a public URL. Before trusting a payload, check the signature: it is sha256= followed by the HMAC-SHA256 of <timestamp>.<raw body> under the endpoint's signing secret. Compare in constant time, and reject anything older than five minutes so a captured payload cannot be replayed.

Node
import crypto from "crypto";

export function verify(rawBody: string, headers: Headers, secret: string): boolean {
  const timestamp = Number(headers.get("x-syncchat-timestamp"));
  const signature = headers.get("x-syncchat-signature") ?? "";

  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Sign over the raw body — parse the JSON only after the check passes. Re-serializing first will produce a different byte sequence and the signature will never match.

Errors

Every failure returns a JSON body with a stable code you can branch on.

401unauthorizedMissing, malformed or revoked key
403insufficient_scopeThe key lacks the scope for this endpoint
400invalid_recipient / invalid_bodyThe number or message body is unusable
404no_instance / instance_not_foundNo connected number, or not one of this org&apos;s
402plan_limit_reachedThe organization&apos;s plan is capped and the cap is reached
502send_failedThe provider rejected it — the message row exists with status failed

402 deserves special handling. It is not a transient error and retrying will not clear it — the organization has hit its plan cap and someone needs to upgrade. Hold the message and surface it, rather than burning it as failed.

Putting it together

A complete integration

The shape that works, whatever your system does. Four moving parts:

  1. Queue, don't send inline. Write the outbound message to your own table first, with its own id. That id becomes the clientRef.
  2. Drain the queue on a schedule. A worker claims a batch, posts each to /api/v1/messages, and records the returned message id. If SyncChat is unreachable the row stays queued and the next run picks it up — nothing is lost and nothing is sent twice.
  3. Receive on a webhook. Verify the signature, then apply: inbound messages become replies in your system, delivery events update the status of the row you already have.
  4. Reconcile occasionally. Poll /api/v1/messages?since= for anything a missed webhook left stale.

Consent stays yours. SyncChat delivers what you ask it to; whether a customer has opted out is a question only your system can answer, so check it before you queue.

Ready to build?

Create your first key in Settings → Developers, then call /api/v1/status to confirm it is pointed at the right organization.

Open SettingsTalk to us