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.

CredentialHow it is sentFor
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

ThingShape
IdentifiersUUIDs, as strings. Never assume they are sortable.
TimestampsISO-8601 with an offset — 2026-08-13T12:00:00+03:00.
MoneyInteger minor units plus an ISO-4217 code: {"amount_minor": 14900, "currency": "SAR"}.
Phone numbersDigits, 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.

StatusCodeMeaning
401unauthenticatedNo credential was presented.
401invalid_api_keyThe key is wrong, revoked or expired.
403insufficient_scopeValid credential, missing scope.
403endpoint_not_publicNot available to API keys or app tokens.
403app_not_installedThe app was uninstalled from that workspace.
404Not found, or not yours. The two are answered identically on purpose.
409request_in_progressAn earlier request with the same idempotency key is still running.
422Validation failed. details carries the field errors.
422idempotency_key_reusedThat key was used with a different body.
429rate_limitedSlow 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:

HeaderMeaning
X-RateLimit-LimitRequests per minute for this credential.
X-RateLimit-RemainingWhat is left in the bucket right now.
X-RateLimit-CostWhat this request cost. Most cost 1; bulk and export endpoints cost more.
Retry-AfterOn 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.*.

HeaderMeaning
X-Nexus-EventThe event name, e.g. message.delivered.
X-Nexus-Event-IdStable across retries. Deduplicate on it.
X-Nexus-Delivery-IdThis attempt's own id, for support tickets.
X-Nexus-Attempt1 for the first try.
X-Nexus-Signaturet=<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

ToolWhat it is
OpenAPI 3.1The machine description of everything below. Generate a client in any language.
Postman collectionEvery endpoint, with auth and environment variables already wired.
TypeScript & PHP clientsGenerated from that document, in sdk/ of the platform repository.
Store connectorsSeven languages, in sdk/connector/. See connect a store.
StatusLive platform health, unauthenticated — it exists for the incident, so it cannot need a credential.
ChangelogWhat 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.

Held on this page only. Never stored.