Messaging API
ZingaConnect exposes a messaging API so your own applications can send WhatsApp
messages and receive delivery/inbound events — using ZingaConnect as your
messaging provider. These endpoints live under /papi/v1/messaging and use the
messaging:* scopes (separate from data scopes).
Scopes
| Endpoint | Scope required |
|---|---|
| List channels, read message status, read conversations, read attachments | messaging:read |
| Send a message | messaging:send |
| Assign a thread, typing indicator | messaging:write (or messaging:send) |
| Manage webhooks | messaging:webhooks |
A data:write:* key does not grant messaging; add the messaging scopes
explicitly when you create the key.
List channels
GET /papi/v1/messaging/channels
Returns the messaging channels configured in your entity (the WhatsApp numbers
and other channels you can send from). Use a channel's id as channelConfigId
when sending.
Send a WhatsApp message
POST /papi/v1/messaging/whatsapp/send → 202 Accepted
The message is dispatched to WhatsApp on the request itself — a caller on this
API is usually answering a customer who is waiting, so it does not sit in a
queue. Still a 202, not a 200: if the number is rate-limited or the Cloud
API hiccups, the send falls back to a retrying queue instead of failing your
call. Read the returned status to tell the two apart — sent means it has
already left, queued means it is retrying. Either way the final outcome
arrives at the message status endpoint or a webhook.
The body:
| Field | Notes |
|---|---|
to |
Recipient in E.164 format, e.g. +14155551234 |
channelConfigId |
Which configured channel/number to send from |
type |
text, template, media, or interactive (inferred if omitted) |
text |
Message text (for type: text) |
template |
{ name, language, params: [...] } for a pre-approved template |
media |
[ { url, filename, mime } ] for media messages |
interactive |
Interactive message payload (buttons/lists) |
externalId |
Your own reference, echoed back in status events |
# Simple text message
curl -X POST https://api-v1.zingasuite.com/papi/v1/messaging/whatsapp/send \
-H "Authorization: Bearer zk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"channelConfigId": "<channel-id>",
"type": "text",
"text": "Your order #1042 has shipped!",
"externalId": "ship-1042"
}'
# Template message
curl -X POST https://api-v1.zingasuite.com/papi/v1/messaging/whatsapp/send \
-H "Authorization: Bearer zk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155551234",
"channelConfigId": "<channel-id>",
"type": "template",
"template": { "name": "order_shipped", "language": "en", "params": ["1042", "FedEx"] }
}'
Read message status
GET /papi/v1/messaging/messages/{id}
Returns the current delivery state of a message you sent.
Read conversations (who owns the thread)
GET /papi/v1/messaging/conversations
GET /papi/v1/messaging/conversations/{id}
A conversation is one thread with one customer on one channel. These endpoints
return its routing state — status (open, pending, snoozed, closed),
assignedUserId + assignedUserName, teamId and externalThreadKey (the
counterparty's WhatsApp number) — plus the contact it is with.
This is what an automated responder checks before it answers: if a human agent
has claimed the thread in the ZingaConnect inbox, the bot should stay quiet.
| Filter | Notes |
|---|---|
phone |
The customer's number — matched against the thread key in +E.164 or bare form |
externalThreadKey |
Exact thread key, if you already store ours |
status |
open, pending, snoozed, closed |
assignedUserId |
A user id, or unassigned for threads nobody owns |
contactId |
Threads with one contact |
channel |
whatsapp, sms, email |
limit, offset |
Paging; meta.hasMore says whether another page exists |
# Is a human handling +919812345678?
curl -G https://api-v1.zingasuite.com/papi/v1/messaging/conversations \
-H "Authorization: Bearer zk_live_xxx" \
--data-urlencode "phone=+919812345678"
{
"data": [
{
"id": "8f2c…",
"channel": "whatsapp",
"status": "open",
"assignedUserId": "3a91…",
"assignedUserName": "Priya M",
"externalThreadKey": "+919812345678",
"contact": { "id": "b7d0…", "name": "Ravi K", "phone": "+919812345678" },
"unreadCount": 2,
"lastInboundAt": "2026-07-31T09:12:04+00:00"
}
],
"meta": { "hasMore": false, "count": 1, "offset": 0, "limit": 50 }
}
assignedUserId: null means nobody owns the thread. Fetch a single thread by id
(the conversationId a message.received webhook carries) and add
?messages=20 to include recent messages for context:
curl "https://api-v1.zingasuite.com/papi/v1/messaging/conversations/8f2c…?messages=20" \
-H "Authorization: Bearer zk_live_xxx"
Polling this on every inbound message works, but the conversation.* webhooks
below push the same change — prefer them, and use these endpoints to reconcile
after a restart or a missed delivery.
Read an inbound attachment
GET /papi/v1/messaging/media/{providerMediaId} → the raw bytes
An inbound photo, document or voice note reaches you as an entry in the
message's media array — provider_media_id and mime, but no URL, because
WhatsApp doesn't publish one. The bytes are fetchable only by media-id, using the
access token of the number that received them. This endpoint does that fetch and
streams the result back with its Content-Type, which is what you hand a
transcription or vision step:
curl "https://api-v1.zingasuite.com/papi/v1/messaging/media/1122334455?configId=<channel-id>" \
-H "Authorization: Bearer zk_live_xxx" \
-o voice-note.ogg
Scope: messaging:read — the same bytes ZingaConnect serves an agent in the
inbox. Pass configId (the channelConfigId on the message or webhook) when
your entity has more than one WhatsApp number; it defaults to the entity's
default channel.
Each media entry carries:
| Field | Notes |
|---|---|
provider_media_id |
What you pass to this endpoint |
mime |
e.g. image/jpeg, audio/ogg; codecs=opus, application/pdf |
filename |
Documents only — voice notes and photos have none |
voice |
Audio only. true = a recorded voice note, false = an uploaded audio file |
voice is worth acting on: it is the only thing separating push-to-talk from an
attached MP3 (both arrive as type: audio with no filename, and the ogg/opus
mime is a hint, not a rule). A customer who says "stop" out loud has opted out
just as surely as one who types it — a text-only keyword check never sees it.
Media expires. WhatsApp keeps inbound media roughly 30 days from
receipt, and this endpoint is a live passthrough — it stores nothing. The
message row outlives the bytes, so an olderprovider_media_idanswers
404 media_not_found. Download anything you need to keep when you receive it.
Route a thread to an agent
POST /papi/v1/messaging/assign
POST /papi/v1/messaging/conversations/{id}/assign
The write half of the same story: your system decides who should handle a
customer — a dealer for their country, the rep who owns the account — and hands
the thread over. Scope: messaging:write (messaging:send is accepted too, so
existing keys keep working).
# "this customer is in Kenya — route them to that dealer"
curl -X POST https://api-v1.zingasuite.com/papi/v1/messaging/assign \
-H "Authorization: Bearer zk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "phone": "+254712345678", "agentPhone": "+919812345678" }'
Name the two sides however you know them:
| Field | Notes |
|---|---|
phone / conversationId |
The customer. A number we have never heard from is a 404 — assignment never opens a thread |
agentPhone |
The agent's phone_e164. Set it on the agent first (see below) |
agentEmail |
The agent's login email — works with no extra setup |
agentId / userId |
Our own identifiers, from GET /papi/v1/crm/agents |
team |
A team name (case-insensitive) or id. Alone it must resolve to one person |
status |
Optionally set the thread status in the same call |
assigned |
false releases the thread — clears the agent and the team, so it isn't left in a queue with nobody on it (name a team alongside to hand it to that queue instead) |
- Assigning by phone needs a phone on the agent.
phone_e164is a field on
theagentsresource:PATCH /papi/v1/crm/agents/{id} {"phone_e164": "+9198…"}.
Until one is recorded,agentPhoneanswers404 agent_not_foundnaming what it
tried — useagentEmailin the meantime. - A team alone resolves only when it has exactly one active agent, or exactly
one manager. Several plausible assignees is a409 team_assignee_ambiguous
listing what it found — an assignment landing on the wrong dealer is worse than
an error — and an empty team is a404 team_has_no_active_agents, which is a
different problem and says so. - The change is pushed to every webhook subscriber as
conversation.assigned
(withsource: "api"), so a bot that both assigns and listens sees its own
action confirmed on the same channel it hears human takeovers on.
Typing indicator
POST /papi/v1/messaging/typing { "phone": "+254712345678" }
Shows the customer a typing bubble while you compose a reply. Sent immediately
rather than queued — a presence hint that waits in a queue is worthless.
Two properties come from the Cloud API itself, not from a choice made here:
- The bubble is attached to the read receipt of the message you are
answering, so this also blue-ticks that message. - It lasts about 25 seconds, or until your next message arrives — whichever
comes first. Send it when you start composing, not in advance.
sent: false with a reason means there was nothing to attach it to
(no_inbound_message_to_attach_to) or the channel isn't a Cloud API one
(provider_unsupported); it is best-effort and never raises.
Webhooks
Register HTTPS endpoints to receive message events (inbound + delivery status)
and conversation events (assignment + status changes).
GET /papi/v1/messaging/webhooks
POST /papi/v1/messaging/webhooks
PATCH /papi/v1/messaging/webhooks/{id}
DELETE /papi/v1/messaging/webhooks/{id}
Create a webhook with a targetUrl and (recommended) a secret. Omit events
to subscribe to all of them:
curl -X POST https://api-v1.zingasuite.com/papi/v1/messaging/webhooks \
-H "Authorization: Bearer zk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"targetUrl": "https://your-app.example.com/hooks/zinga",
"secret": "whsec_…",
"events": ["message.received", "conversation.assigned", "conversation.status_changed"]
}'
Events are POSTed as soon as ZingaConnect records them — typically well under a
second after the customer's message reaches us — so a bot on the other end can
answer while the person is still looking at their phone. Delivery is retried
with backoff if your endpoint is down, so design the handler to be idempotent:
the same event may arrive twice.
Return a 2xx quickly. A slow handler doesn't lose the event, but it does hold a
delivery slot, which delays the events queued behind it.
Events
| Event | Fires when |
|---|---|
message.received |
An inbound message arrives on a thread |
message.status |
An outbound message advances to sent / delivered / read / failed |
conversation.assigned |
The thread's owner changed — a human took over, or released it |
conversation.status_changed |
The thread moved between open / pending / snoozed / closed |
Every body carries event, entityId and channel. The two conversation.*
events additionally carry the thread's full routing state, so either one alone
tells you whether to keep replying:
{
"event": "conversation.assigned",
"entityId": "…",
"channel": "whatsapp",
"conversationId": "8f2c…",
"contactId": "b7d0…",
"externalThreadKey": "+919812345678",
"status": "open",
"previousStatus": "open",
"assignedUserId": "3a91…",
"assignedUserName": "Priya M",
"previousAssignedUserId": null,
"teamId": null,
"source": "inbox",
"actorUserId": "3a91…",
"occurredAt": "2026-07-31T09:14:22+00:00"
}
channelConfigId on a conversation.* event is always populated when the entity
has an active channel: threads that never recorded which account they ride on
fall back to the entity's channel config, so an endpoint serving several entities
from one URL can always attribute the event. (The conversation-read endpoints
report the thread's own value instead — a key is scoped to one entity, so a poll
is unambiguous without it.)
source says what caused the change: inbox (an agent in ZingaConnect),
api (a POST /messaging/assign, i.e. possibly your own call), ticket
(helpdesk routing), inbound (a customer reply reopening a closed thread) or
outbound (a send reopening one). A takeover is assignedUserId != null; a release back to your bot is
assignedUserId: null with previousAssignedUserId set. The field names match
the conversation-read endpoints above, so one parser handles both.
An endpoint only receives the events it is subscribed to (an endpoint registered
with an empty events list receives all of them). To add events to an endpoint
that is already live — without the gap a delete-and-re-register would leave —
patch it:
curl -X PATCH https://api-v1.zingasuite.com/papi/v1/messaging/webhooks/<id> \
-H "Authorization: Bearer zk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "events": ["message.received", "message.status", "conversation.assigned", "conversation.status_changed"] }'
Verifying webhook signatures
When a secret is set, each webhook request carries an HMAC-SHA256 signature of
the raw body in the X-Zinga-Signature header:
X-Zinga-Signature: sha256=<hex-digest>
Recompute the digest over the raw request body with your secret and compare in
constant time before trusting the payload. Deleting a webhook deactivates it.
Building on ZingaConnect
This is the same delivery pipeline ZingaConnect uses for its own campaigns and
inbox, so messages you send via the API appear alongside them. For the product
side — channels, templates, campaigns and the inbox — see the
ZingaConnect chapter.