API documentation
One REST API over every part of the platform — contacts, conversations, messages, campaigns, automations, commerce — plus GraphQL for the reads that would otherwise take six round trips. Everything the dashboard does, it does through these endpoints.
Quickstart
The base URL is https://whatsapp.qaffplus.sa/api/v1. Create an API key
under Developer → API keys in the dashboard, then:
curl https://whatsapp.qaffplus.sa/api/v1/contacts \
-H "Authorization: Bearer nxs_your_key_here" \
-H "Accept: application/json"
Every response is JSON. Success is { "data": … }; failure is
{ "error": { "code", "message" } }. There is no third shape, so a client
can branch on the presence of error and stop there.
Try any endpoint from this page: paste a key into the box in the reference below and press Send. The key stays in this browser tab and is never written to storage.
Authentication
Three credentials reach the same endpoints. Which one you want depends on who is calling.
| Credential | How it is sent | For |
|---|---|---|
| API key | Authorization: Bearer nxs_…or X-Api-Key: nxs_… |
Your own server talking to your own workspace |
| OAuth token | Authorization: Bearer … |
An app installed on somebody else's workspace |
| Session | Cookie | The dashboard itself |
X-Api-Key exists because server-to-server callers routinely have an HTTP client
that already owns the Authorization header. It is the same key and the same
permissions either way.
Scopes
Machine credentials are scoped, and every operation in the reference lists the scope it
needs — contacts.read, messages.write, and so on. A call without the
scope is refused with 403 insufficient_scope, and the response names the scope
that was missing so you are not guessing. The full list is at
/api/v1/scopes.
Endpoints that are not mapped to a scope are closed to machine callers entirely
(403 endpoint_not_public). That is deliberate: the safe answer for an
unrecognised route is no, so a new internal endpoint is never public by accident.
Workspaces
Every row in the platform belongs to a workspace, and a credential is bound to one. An API key already knows its workspace, so you never send a header for it.
A user who belongs to several workspaces — an agency, typically — picks one per request with
X-Workspace-Id. It is validated against membership, so the header can widen
nothing: it chooses among the workspaces you already have, and is ignored otherwise.
Conventions
| Thing | Shape |
|---|---|
| Identifiers | UUIDs, as strings. Never assume they are sortable. |
| Timestamps | ISO-8601 with an offset — 2026-08-13T12:00:00+03:00. |
| Money | Integer minor units plus an ISO-4217 code: {"amount_minor": 14900, "currency": "SAR"}. |
| Phone numbers | Digits, E.164 without the +. Send what you have; it is normalised on the way in. |
| Empty lists | {"data": []}, never null. |
Money is integers for the usual reason: a float cannot hold 0.1, and a total that is
149.00000000000003 on somebody's invoice is a support ticket that never fully
closes. The currency decides the exponent — 2 for SAR, 3 for KWD, 0 for JPY — so
14900 SAR is 149.00 and 14900 KWD is 14.900.
Pagination
Lists accept per_page and cursor. Read meta.next_cursor and
pass it back to page forward; when it is null you have everything.
GET /api/v1/contacts?per_page=100
{
"data": [ … ],
"meta": { "next_cursor": "eyJpZCI6…", "per_page": 100 }
}
GET /api/v1/contacts?per_page=100&cursor=eyJpZCI6…
A cursor is opaque and signed. Do not parse one, and do not build one — its contents are an implementation detail that will change.
Offset pagination (page=) still works on older endpoints and is not recommended.
While you page through a list that is being written to, offsets skip rows and repeat rows;
a cursor does neither. Some endpoints keep offsets as the default for compatibility and
switch on cursor_pagination=1.
Errors
{
"error": {
"code": "insufficient_scope",
"message": "This credential is missing the [contacts.write] scope.",
"details": { "required_scope": "contacts.write" }
}
}
Branch on code, not on message. Messages are written for humans and get
reworded; codes are part of the contract.
| Status | Code | Meaning |
|---|---|---|
| 401 | unauthenticated | No credential was presented. |
| 401 | invalid_api_key | The key is wrong, revoked or expired. |
| 403 | insufficient_scope | Valid credential, missing scope. |
| 403 | endpoint_not_public | Not available to API keys or app tokens. |
| 403 | app_not_installed | The app was uninstalled from that workspace. |
| 404 | — | Not found, or not yours. The two are answered identically on purpose. |
| 409 | request_in_progress | An earlier request with the same idempotency key is still running. |
| 422 | — | Validation failed. details carries the field errors. |
| 422 | idempotency_key_reused | That key was used with a different body. |
| 429 | rate_limited | Slow down. See rate limits. |
A 404 for a record in somebody else's workspace is not evasion for its own sake: answering 403 would confirm that the id exists, which is exactly what an attacker enumerating ids wants to learn.
Rate limits
Every response carries the state of your bucket:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests per minute for this credential. |
X-RateLimit-Remaining | What is left in the bucket right now. |
X-RateLimit-Cost | What this request cost. Most cost 1; bulk and export endpoints cost more. |
Retry-After | On a 429 only: seconds to wait. Honour it rather than guessing. |
It is a token bucket holding a minute's worth, refilled continuously — so a client that has been idle can spend a full minute's quota at once, and a steady client is never punished for the burst it did not make.
On a 429, back off with jitter. A fleet of workers that all retry after exactly the number of seconds they were told re-creates the spike that caused the 429.
Idempotency
Send Idempotency-Key on any POST you might retry — a UUID per logical action, reused
across every attempt of that action:
curl -X POST https://whatsapp.qaffplus.sa/api/v1/messages \
-H "Authorization: Bearer nxs_…" \
-H "Idempotency-Key: 6f2a1c8e-9b3d-4a71-9c2e-2f0b7a1d5e44" \
-H "Content-Type: application/json" \
-d '{"conversation_id":"…","body":"Your order shipped."}'
The first call runs. A repeat with the same key and the same body replays the stored response
and carries Idempotent-Replay: true — the action does not happen twice. This is what
makes a network timeout safe: without it, the retry you have no choice but to make is how a
customer receives the same message twice.
Same key, different body is 422 idempotency_key_reused — almost always a bug where
one key was reused for two different actions. A repeat while the first is still in flight is
409 request_in_progress; wait and retry. Keys are remembered for 24 hours, and only
successful responses are stored, so a transient 500 does not become permanent for that key.
Versioning
The version is in the path (/api/v1) and echoed on every response as
X-Nexus-Api-Version. Additive changes — a new field, a new endpoint, a new optional
parameter — happen inside v1, so parse defensively and ignore fields you do not know.
Anything that would break a client gets a new version. When an endpoint is on its way out it
answers with Deprecation: true and a Sunset date before it stops
answering at all. Log those two headers; they are the only warning that arrives without an
email.
Webhooks
Subscribe to events under Developer → Webhooks, or via
POST /webhooks. Wildcards work: message.*, store.*.
| Header | Meaning |
|---|---|
X-Nexus-Event | The event name, e.g. message.delivered. |
X-Nexus-Event-Id | Stable across retries. Deduplicate on it. |
X-Nexus-Delivery-Id | This attempt's own id, for support tickets. |
X-Nexus-Attempt | 1 for the first try. |
X-Nexus-Signature | t=<unix>,v1=<hex HMAC-SHA256>. |
Verifying a delivery
The signature covers "{timestamp}.{raw body}". Sign the raw bytes you
received, not a re-encode of the parsed object — JSON encoders normalise escaping and
spacing, and a digest over re-encoded JSON matches roughly never.
$parts = [];
foreach (explode(',', $request->header('X-Nexus-Signature')) as $part) {
[$key, $value] = explode('=', $part, 2);
$parts[$key] = $value;
}
// Reject anything older than five minutes: a signature proves who sent a
// request, never when, so without this a captured request is replayable
// for as long as the secret lives.
abort_if(abs(time() - (int) $parts['t']) > 300, 403);
$expected = hash_hmac('sha256', $parts['t'].'.'.$request->getContent(), $secret);
abort_unless(hash_equals($expected, $parts['v1']), 403); // never ===
Answer 2xx quickly and do the work afterwards. Anything else is retried with
backoff, and an endpoint that keeps failing is disabled automatically — check
Developer → Webhooks if deliveries stop.
Connect a store
Traffic in the other direction: your shop tells the platform what happened, so an order becomes a WhatsApp message and an abandoned cart becomes a recovery sequence. Any storefront that can post a signed JSON webhook can connect — no plugin, no platform allow-list.
There are ready-made connector libraries for PHP, Node, Python, Ruby, Go, Java and
C#, each with no dependencies, in
sdk/connector/ of the platform repository. They handle the four things worth
getting right: signing the exact bytes posted, keeping the event id stable across retries,
retrying only what a retry can fix, and writing money the way the contract requires.
$connector = new Nexus\StoreConnector\StoreConnector($webhookUrl, $webhookSecret);
$connector->orderPaid([
'id' => (string) $order->id,
'number' => $order->reference,
'currency' => 'SAR',
'total' => $order->total,
'customer' => ['name' => $order->name, 'phone' => $order->phone],
'items' => $lines,
]);
Nothing messages anybody by itself: a store event is published to the event catalogue, and a message goes out because the merchant built an automation on that event. Connecting a shop starts a data flow, not a campaign.
Website chat widget
One script tag puts a chat bubble on your site. Everything about it — colour, wording, which pages it appears on, whether it opens WhatsApp or a chat panel — is configuration fetched at run time, so you paste the snippet once and never edit your site again.
<script async src="https://whatsapp.qaffplus.sa/w/YOUR_PUBLIC_KEY.js"></script>
Create the widget under Sites & Funnels → Chat widget, or through
POST /api/v1/chat-widgets, which returns the snippet ready to paste. Put it
just before </body> on every page the bubble should appear on.
The two modes
Open WhatsApp (mode: "whatsapp") is a deep link. Clicking the
bubble opens WhatsApp with the conversation started and, if you use the
{{page}} placeholder, the page they were reading already quoted. There is
no chat panel and no session — the reply lands in the inbox your team already works.
Chat on the page (mode: "webchat") keeps the conversation in
the browser. A visitor's message goes through the same pipeline as a WhatsApp one, so your
bot flows answer first, your AI agent answers what the flows do not, and an agent can take
over from the inbox at any point. Replies arrive over a websocket, falling back to polling
where websockets are blocked.
Capturing leads
Turn on the pre-chat form and the widget asks for a name and email before the conversation
starts. That creates the contact and a CRM lead immediately — not when the visitor
types, and not when an agent replies — because most people who fill in a form close the tab
straight afterwards. Anything else the form asks is kept on the lead, under
meta.answers, along with the page and referrer they arrived from. A visitor who
comes back is the same lead with a higher touch count, never a duplicate.
Endpoints
The widget is a normal client of these, all public and CORS-enabled, so you can build your
own interface against them instead of using ours. The visitor is identified by a session key
plus a token issued once at start; the token authorises everything that reads
or writes the conversation.
POST /api/v1/web-chat/{key}/start { session_key?, locale, page_url, profile? }
POST /api/v1/web-chat/{key}/send { session_key, body } Authorization: Bearer <token>
POST /api/v1/web-chat/{key}/history { session_key } Authorization: Bearer <token>
POST /api/v1/web-chat/{key}/end { session_key } Authorization: Bearer <token>
GET /w/{key}/config what the bubble should look like, here
POST /w/{key}/track impressions and opens
start returns the history so a visitor who navigates or comes back tomorrow
picks the conversation up where they left it. Resuming an existing session returns
token: null — the browser keeps the one it was issued, because minting a new
token for anyone presenting a session key would make the key alone enough to read somebody's
conversation.
Restricting where it runs
Leave allowed_origins empty and the widget works on any site, which is right
for a marketing bubble and wrong for anything behind a login. Set it, and both the config
and the chat endpoints refuse any other origin.
Measuring it
GET /api/v1/chat-widgets/{id}/report returns impressions, opens, conversations,
messages and leads for the last 30 days, with the open rate and the lead rate — read from
the event log rather than nightly rollups, so a widget you installed ten minutes ago already
has numbers.
SDKs & tools
| Tool | What it is |
|---|---|
| OpenAPI 3.1 | The machine description of everything below. Generate a client in any language. |
| Postman collection | Every endpoint, with auth and environment variables already wired. |
| TypeScript & PHP clients | Generated from that document, in sdk/ of the platform repository. |
| Store connectors | Seven languages, in sdk/connector/. See connect a store. |
| Status | Live platform health, unauthenticated — it exists for the incident, so it cannot need a credential. |
| Changelog | What changed, machine-readable. |
Both generated clients retry idempotent requests with jittered backoff and honour
Retry-After. Neither retries a POST on its own, because retrying a send that timed
out is how a customer receives the same message twice — that is what
idempotency keys are for, and they make the retry your decision.
GraphQL
POST /api/v1/graphql, same credentials and same scopes as REST. It exists for the
reads that would otherwise be six round trips — a conversation with its contact, its deals and
its last twenty messages — not as a second way to do everything.
The schema is served at /api/v1/graphql/schema. Queries
are cost-scored before they run: POST /api/v1/graphql/cost tells you what one will
cost against your rate limit before you spend it, and a query that would cost more than the
budget is refused rather than run slowly.
Every endpoint
Rendered from the OpenAPI document, which is generated from the router — so this list cannot drift from what the API actually serves.