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 older provider_media_id answers
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_e164 is a field on
    the agents resource: PATCH /papi/v1/crm/agents/{id} {"phone_e164": "+9198…"}.
    Until one is recorded, agentPhone answers 404 agent_not_found naming what it
    tried — use agentEmail in the meantime.
  • A team alone resolves only when it has exactly one active agent, or exactly
    one manager. Several plausible assignees is a 409 team_assignee_ambiguous
    listing what it found — an assignment landing on the wrong dealer is worse than
    an error — and an empty team is a 404 team_has_no_active_agents, which is a
    different problem and says so.
  • The change is pushed to every webhook subscriber as conversation.assigned
    (with source: "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.

Was this helpful?