Skip to content
Start free

Zappio Developer API

Integrate Zappio with your store, CRM, or backend. Send WhatsApp messages and approved templates over a simple, key-authenticated REST API.

Base URLhttps://getzappio.com/v1
Your API key Stored only in this browser. Used to call the live API from the Try it panels below.
🔑 Get started: create an API key from your Zappio workspace → Developers, paste it above, then run any endpoint right here with Try it.

Authentication

Every request is authenticated with a secret API key sent as a Bearer token. Keys are scoped to one workspace and WhatsApp number, and can be revoked at any time.

Authorization: Bearer zap_live_••••••••••••••••
⚠️ Keep keys secret. Never expose a zap_live_ key in client-side code. Use it only from your server.

Errors

Errors return a non-2xx status and a JSON body with a machine-readable code and a human message.

{
  "error": {
    "code": "unauthorized",
    "message": "Invalid or revoked API key."
  }
}
StatusCodeMeaning
400bad_requestMissing or invalid parameters.
401unauthorizedMissing, invalid, or revoked API key.
402not_connectedThe workspace has no connected WhatsApp number.
404not_foundResource (e.g. template) not found.
429rate_limitedToo many requests — slow down.

Rate limits

The API allows up to 60 requests per minute per key by default. Throttled requests return 429 with a Retry-After header. Need more? Ask your account manager.

Sending customer details

When your app sends a message it can pass the customer's details in the same request. Zappio saves them to Contacts — before the send, so the customer is on file even if WhatsApp refuses the message. Add an optional contact block to POST /messages/template, /triggers/send, /messages, /events or /bots/{id}/run. To save a customer without sending anything (signup, backfill), use Save a contact with the same fields at the top level — it needs no connected WhatsApp number.

{
  "to": "+919400800035",
  "template": "order_confirmed",
  "language": "en",
  "variables": [
    "Saranya",
    "#10428",
    "₹4,200"
  ],
  "idempotency_key": "order-10428",
  "source_app": "Store",
  "contact": {
    "name": "Saranya K",
    "email": "[email protected]",
    "instagram": "@saranya.k",
    "customerId": "C-2231",
    "country": "India",
    "tags": [
      "customer",
      "cod"
    ],
    "attributes": {
      "city": "Kochi",
      "pincode": "682001"
    },
    "consent": {
      "whatsapp": true,
      "email": true
    },
    "order": {
      "id": "10428",
      "total": 4200,
      "currency": "INR",
      "status": "paid"
    }
  }
}
FieldTypeNotes
namestringUp to 120 characters. Fills the name only if it is blank or just the phone number.
emailstringValidated and lower-cased.
country, language, notesstringNotes up to 2,000 characters.
customerIdstringYour own ID for the customer (customer_id and attributes.customer_id also work).
instagram, messenger, tiktokstringA username or profile link (@saranya.k, facebook.com/saranya.k). Lets a later DM be matched to this customer.
tagsstring[]Up to 20, 40 characters each. Added, never removed. API-created contacts also get the tag api.
attributesobjectUp to 50 keys; text or number values (500 characters max), not objects. Always written.
consentobjectMarketing permission per channel: whatsapp, sms, email — each true or false.
orderobjectid (required), total, currency (default INR), status. Counted once per order id; updates lifetime value and recent orders.
overwritebooleantrue lets this call replace values that already exist. Default false.

When the customer already exists. They are matched on phone (last 10 digits), or on email when there is no phone match and that email's contact has no phone of its own. A contact with a different phone on the same email stays separate and appears in Contacts as a possible match — Zappio never merges automatically. Name, email, country, language, notes, social handles and customerId only fill blanks unless you send overwrite: true. Attributes are always written; tags are added.

Consent. false always applies. true never overrides an opt-out the customer or your team made (a STOP reply, an unsubscribe link, an agent) — only an opt-out that came from the API can be reversed by the API. Only send true when the customer genuinely agreed to marketing.

What you get back. Send endpoints return contactId, and chatLinks (personal Messenger and Instagram links for the channels your workspace has connected — put them in the order email or SMS; when the customer taps one, their account is tied to this contact). A malformed contact block returns 400 bad_request naming the field, and nothing is sent. If WhatsApp refuses the message, the contact is still saved.

Endpoints

GET/v1/me

Get account & connection

Returns the workspace and WhatsApp number your API key is bound to. Use it to verify a key and discover the sending number.

Response 200 OK

{
  "tenant": {
    "id": "t-9f2a",
    "name": "Acme Retail"
  },
  "project": {
    "id": "p-1c4e",
    "name": "Sage & Loom"
  },
  "whatsapp": {
    "connected": true,
    "displayPhoneNumber": "+91 70128 87078"
  }
}
curl https://getzappio.com/v1/me \
  -H "Authorization: Bearer zap_live_••••••••"
const res = await fetch("https://getzappio.com/v1/me", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});
const data = await res.json();
import requests

res = requests.get(
    "https://getzappio.com/v1/me",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
data = res.json()
⚡ Try it
GET/v1/templates

List message templates

Returns the WhatsApp templates on your number with everything you need to send one: id, name, language, status, category, header format (for media templates), the variables (named or positional, with their names), and buttons. Send them with the Send template endpoint.

Query parameters

NameTypeRequiredDescription
statusstringoptionalFilter by status: approved, in_review, rejected. Default: all.
limitintegeroptionalMax templates to return (1–200). Default: 100.

Response 200 OK

{
  "data": [
    {
      "id": "order_shipped_v3:en_IN",
      "name": "order_shipped_v3",
      "language": "en_IN",
      "status": "approved",
      "category": "UTILITY",
      "header": null,
      "variables": {
        "count": 3,
        "named": false,
        "names": []
      },
      "buttons": [],
      "body": "Hi {{1}}, your order {{2}} has shipped! 📦 Tracking: {{3}}."
    },
    {
      "id": "warranty_reg:en",
      "name": "warranty_reg",
      "language": "en",
      "status": "approved",
      "category": "UTILITY",
      "header": {
        "format": "image"
      },
      "variables": {
        "count": 2,
        "named": true,
        "names": [
          "name",
          "amount"
        ]
      },
      "buttons": [
        {
          "type": "url",
          "text": "Register"
        }
      ],
      "body": "Hi {{name}}, register your warranty — amount {{amount}}."
    }
  ],
  "total": 2
}
curl https://getzappio.com/v1/templates \
  -H "Authorization: Bearer zap_live_••••••••"
const res = await fetch("https://getzappio.com/v1/templates", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});
const data = await res.json();
import requests

res = requests.get(
    "https://getzappio.com/v1/templates",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
data = res.json()
⚡ Try it
POST/v1/messages

Send a text message

Sends a free-form text message to a customer within the 24-hour customer-service window. Outside the window you must use a template.

Request body

{
  "to": "+9170128 87078",
  "text": "Thanks for reaching out — how can we help?"
}

Response 200 OK

{
  "id": "wamid.HBgM...",
  "status": "sent",
  "to": "917012887078"
}
curl -X POST https://getzappio.com/v1/messages \
  -H "Authorization: Bearer zap_live_••••••••" \
  -H "Content-Type: application/json" \
  -d '{"to":"+9170128 87078","text":"Thanks for reaching out — how can we help?"}'
const res = await fetch("https://getzappio.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "to": "+9170128 87078",
  "text": "Thanks for reaching out — how can we help?"
}),
});
const data = await res.json();
import requests

res = requests.post(
    "https://getzappio.com/v1/messages",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
  "to": "+9170128 87078",
  "text": "Thanks for reaching out — how can we help?"
},
)
data = res.json()
⚡ Try it
POST/v1/messages/template

Send a template message

Sends an approved template — the only way to start a conversation or message outside the 24-hour window. Add an optional `contact` block (same fields as Save a contact) and the customer's name, email and other details are saved to Contacts with the send — see Save a contact for the rules. Fill the variables three ways: params by name ([{"name":"amount","value":"100"}] or {"amount":"100"}) for NAMED templates ({{amount}}), or positional (variables:["a","b"]) for {{1}}, {{2}}… templates. For a media-header template, add header:{type,link} with an image/video/document URL (or omit it to reuse the file stored for the template). Get each template's id, variable names and header format from GET /v1/templates.

Request body

{
  "to": "+9170128 87078",
  "template": "warranty_reg",
  "language": "en",
  "header": {
    "type": "image",
    "link": "https://cdn.example.com/logo.png"
  },
  "params": [
    {
      "name": "name",
      "value": "SARANYA"
    },
    {
      "name": "amount",
      "value": "100"
    }
  ],
  "reference": "order-10428",
  "contact": {
    "name": "Saranya K",
    "email": "[email protected]",
    "instagram": "@saranya.k",
    "consent": {
      "whatsapp": true,
      "email": true
    },
    "country": "India",
    "tags": [
      "customer"
    ],
    "attributes": {
      "city": "Kochi",
      "pincode": "682001",
      "customer_id": "C-2231"
    },
    "order": {
      "id": "10428",
      "total": 4200,
      "currency": "INR",
      "status": "paid"
    }
  }
}

Response 200 OK

{
  "id": "wamid.HBgM...",
  "status": "sent",
  "to": "917012887078",
  "template": "warranty_reg",
  "contactId": "+917012887078"
}
curl -X POST https://getzappio.com/v1/messages/template \
  -H "Authorization: Bearer zap_live_••••••••" \
  -H "Content-Type: application/json" \
  -d '{"to":"+9170128 87078","template":"warranty_reg","language":"en","header":{"type":"image","link":"https://cdn.example.com/logo.png"},"params":[{"name":"name","value":"SARANYA"},{"name":"amount","value":"100"}],"reference":"order-10428","contact":{"name":"Saranya K","email":"[email protected]","instagram":"@saranya.k","consent":{"whatsapp":true,"email":true},"country":"India","tags":["customer"],"attributes":{"city":"Kochi","pincode":"682001","customer_id":"C-2231"},"order":{"id":"10428","total":4200,"currency":"INR","status":"paid"}}}'
const res = await fetch("https://getzappio.com/v1/messages/template", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "to": "+9170128 87078",
  "template": "warranty_reg",
  "language": "en",
  "header": {
    "type": "image",
    "link": "https://cdn.example.com/logo.png"
  },
  "params": [
    {
      "name": "name",
      "value": "SARANYA"
    },
    {
      "name": "amount",
      "value": "100"
    }
  ],
  "reference": "order-10428",
  "contact": {
    "name": "Saranya K",
    "email": "[email protected]",
    "instagram": "@saranya.k",
    "consent": {
      "whatsapp": true,
      "email": true
    },
    "country": "India",
    "tags": [
      "customer"
    ],
    "attributes": {
      "city": "Kochi",
      "pincode": "682001",
      "customer_id": "C-2231"
    },
    "order": {
      "id": "10428",
      "total": 4200,
      "currency": "INR",
      "status": "paid"
    }
  }
}),
});
const data = await res.json();
import requests

res = requests.post(
    "https://getzappio.com/v1/messages/template",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
  "to": "+9170128 87078",
  "template": "warranty_reg",
  "language": "en",
  "header": {
    "type": "image",
    "link": "https://cdn.example.com/logo.png"
  },
  "params": [
    {
      "name": "name",
      "value": "SARANYA"
    },
    {
      "name": "amount",
      "value": "100"
    }
  ],
  "reference": "order-10428",
  "contact": {
    "name": "Saranya K",
    "email": "[email protected]",
    "instagram": "@saranya.k",
    "consent": {
      "whatsapp": True,
      "email": True
    },
    "country": "India",
    "tags": [
      "customer"
    ],
    "attributes": {
      "city": "Kochi",
      "pincode": "682001",
      "customer_id": "C-2231"
    },
    "order": {
      "id": "10428",
      "total": 4200,
      "currency": "INR",
      "status": "paid"
    }
  }
},
)
data = res.json()
⚡ Try it
GET/v1/messages

Your message log (sent, failed, replied)

Every message this key has sent, newest first: whether it went, the reason it did not, which template it used, and what the customer replied. Filter with status (queued|sent|delivered|read|failed), to, template, since, until. Page with limit (max 200) and cursor — pass the nextCursor from the previous page. Only messages sent with THIS key are returned. Pass your own id as `reference` when sending and it comes back here, so you can reconcile against your records without storing ours.

Query parameters

NameTypeRequiredDescription
statusstringoptionalqueued | sent | delivered | read | failed
tostringoptionalRecipient phone number (matched on its last 10 digits).
templatestringoptionalTemplate name.
sinceintegeroptionalOnly messages created at/after this epoch-ms.
untilintegeroptionalOnly messages created at/before this epoch-ms.
limitintegeroptional1–200, default 50.
cursorintegeroptionalnextCursor from the previous page.

Response 200 OK

{
  "messages": [
    {
      "id": "am_3f9c21d8",
      "messageId": "wamid.HBgM...",
      "reference": "order-10428",
      "to": "917012887078",
      "type": "template",
      "template": "order_update",
      "status": "read",
      "sentAt": 1717800000000,
      "deliveredAt": 1717800004000,
      "readAt": 1717800051000,
      "replies": 1,
      "lastReply": {
        "text": "Yes, please deliver today",
        "at": 1717800097000
      }
    },
    {
      "id": "am_77b0e412",
      "reference": "order-10429",
      "to": "917012887079",
      "type": "template",
      "template": "order_update",
      "status": "failed",
      "error": "(#131026) Message undeliverable",
      "errorReason": "Undeliverable — the recipient couldn't receive it (often transient)",
      "failedAt": 1717800002000,
      "replies": 0
    }
  ],
  "nextCursor": 1717799880000
}
curl https://getzappio.com/v1/messages \
  -H "Authorization: Bearer zap_live_••••••••"
const res = await fetch("https://getzappio.com/v1/messages", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});
const data = await res.json();
import requests

res = requests.get(
    "https://getzappio.com/v1/messages",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
data = res.json()
⚡ Try it
GET/v1/messages/{id}

One message, by any id you hold

The same record as the log, for a single message. `id` accepts the id we returned (am_…), Meta's message id (wamid.…), or the jobId from /v1/triggers/send — whichever you kept. A message that never reached WhatsApp has no Meta id, so ours (or the jobId) is the only way to ask about exactly the sends that failed.

Query parameters

NameTypeRequiredDescription
idstringrequiredam_… , wamid.… or a jobId.

Response 200 OK

{
  "id": "am_3f9c21d8",
  "messageId": "wamid.HBgM...",
  "reference": "order-10428",
  "to": "917012887078",
  "type": "template",
  "template": "order_update",
  "status": "delivered",
  "sentAt": 1717800000000,
  "deliveredAt": 1717800004000,
  "replies": 0
}
curl https://getzappio.com/v1/messages/{id} \
  -H "Authorization: Bearer zap_live_••••••••"
const res = await fetch("https://getzappio.com/v1/messages/{id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});
const data = await res.json();
import requests

res = requests.get(
    "https://getzappio.com/v1/messages/{id}",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
data = res.json()
⚡ Try it
GET/v1/conversations/{id}/messages

Every message in one conversation, whoever sent it

The whole thread, oldest first: the customer's messages, your agents' replies, what a bot asked, broadcasts, and sends from every API key — not just yours. `id` takes our conversation id or simply the customer's phone number, since that is usually what you hold. Each message says who sent it in `sentBy` (`customer`, `agent` with their name, `bot` with the bot's name, `api`, `broadcast`), its `text`, its `at` (epoch milliseconds), and for a photo or file the `mediaType`, `filename` and a `mediaUrl` you fetch with your key. Page with `limit` (default 100, max 500) and `cursor` — the cursor counts messages in the thread rather than time, so two messages sent in the same millisecond can't be skipped or repeated. This is the companion to /v1/messages, which is your own key's send log; use this when you need the conversation AROUND a reply.

Query parameters

NameTypeRequiredDescription
idstringrequiredA conversation id (c-…) or the customer's phone number.
limitintegeroptional1–500, default 100.
cursorintegeroptional`nextCursor` from the previous page.

Response 200 OK

{
  "conversationId": "c-1c4e",
  "contact": {
    "name": "Saranya K",
    "phone": "+917012887078"
  },
  "channel": "whatsapp",
  "messages": [
    {
      "cursor": 0,
      "messageId": "wamid.HBgM...",
      "direction": "out",
      "kind": "template",
      "text": "Your order 10428 has shipped.",
      "template": "order_update",
      "mediaType": null,
      "filename": null,
      "mediaUrl": null,
      "status": "read",
      "sentBy": {
        "type": "api",
        "name": "Shop backend"
      },
      "at": 1717800000000
    },
    {
      "cursor": 1,
      "messageId": "wamid.HBgN...",
      "direction": "in",
      "kind": "in",
      "text": "Where is it now?",
      "template": null,
      "mediaType": null,
      "filename": null,
      "mediaUrl": null,
      "status": null,
      "sentBy": {
        "type": "customer",
        "name": "Saranya K"
      },
      "at": 1717800097000
    },
    {
      "cursor": 2,
      "messageId": "wamid.HBgO...",
      "direction": "out",
      "kind": "out",
      "text": "Out for delivery today 🙂",
      "template": null,
      "mediaType": null,
      "filename": null,
      "mediaUrl": null,
      "status": "delivered",
      "sentBy": {
        "type": "agent",
        "name": "Riya Menon"
      },
      "at": 1717800140000
    }
  ],
  "nextCursor": null
}
curl https://getzappio.com/v1/conversations/{id}/messages \
  -H "Authorization: Bearer zap_live_••••••••"
const res = await fetch("https://getzappio.com/v1/conversations/{id}/messages", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});
const data = await res.json();
import requests

res = requests.get(
    "https://getzappio.com/v1/conversations/{id}/messages",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
data = res.json()
⚡ Try it
GET/v1/media/{id}

Download a photo or file a customer sent

`message.received` gives you `mediaId` and a ready-made `mediaUrl` for anything a customer sends — a photo, a document, a voice note. This is what that URL points at: it streams the file itself with the right `Content-Type`, authenticated with your API key like every other call. WhatsApp only keeps media for a limited time, so fetch it when the event arrives rather than days later. Files YOUR workspace sends are stored here rather than by WhatsApp, so they have no media id — `message.sent` carries the caption in `text` and the `mediaType`.

Query parameters

NameTypeRequiredDescription
idstringrequiredThe `mediaId` from a message.received webhook.

Response 200 OK

"(the file's bytes, with its own Content-Type)"
curl https://getzappio.com/v1/media/{id} \
  -H "Authorization: Bearer zap_live_••••••••"
const res = await fetch("https://getzappio.com/v1/media/{id}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});
const data = await res.json();
import requests

res = requests.get(
    "https://getzappio.com/v1/media/{id}",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
data = res.json()
⚡ Try it
POST/v1/contacts

Save a contact (create or update)

One customer record for every channel: WhatsApp and SMS reach them on `phone`, email on `email`, and Instagram, Messenger and TikTok once they DM you (Meta and TikTok don't let a business message first). Send their `instagram` and `tiktok` usernames and `messenger` (Facebook profile link or username) so the DM can be matched: a DM from the same Instagram or TikTok username is offered to your team as a possible match in Contacts, never merged on its own. Send `phone`, `email` or both; the contact is matched on phone (last 10 digits) or, failing that, email. Nothing is sent. Every send endpoint accepts the same fields inside a `contact` block. Rules: name, email, country, language, notes, instagram, messenger and tiktok only fill blanks (a name that is just the phone number counts as blank) unless you send overwrite:true. `attributes` (up to 50 text/number keys) are always written. Tags are added, never removed. `consent` is marketing permission per channel (whatsapp, sms, email): false always applies; true never overrides an opt-out the customer made themselves (a STOP reply, an unsubscribe link, Meta) or one an agent set. `order` counts once per order id. When the Engage module is on, the same customer, consent and order go to Engage for email/SMS/WhatsApp journeys. Returns 201 when created, 200 when updated. `chatLinks` holds the customer's personal Messenger and Instagram links (for channels your workspace has connected): put them in the order email or SMS — when the customer taps one, their Messenger/Instagram account is tied to this contact automatically, and you can reply there. Send endpoints return the same `chatLinks`.

Request body

{
  "phone": "+91 70128 87078",
  "email": "[email protected]",
  "instagram": "@saranya.k",
  "messenger": "facebook.com/saranya.k",
  "tiktok": "@saranya.k",
  "customerId": "C-2231",
  "name": "Saranya K",
  "country": "India",
  "language": "English",
  "tags": [
    "customer",
    "cod"
  ],
  "consent": {
    "whatsapp": true,
    "sms": false,
    "email": true
  },
  "attributes": {
    "city": "Kochi",
    "pincode": "682001",
    "address": "12 MG Road"
  },
  "order": {
    "id": "10428",
    "total": 4200,
    "currency": "INR",
    "status": "paid"
  }
}

Response 200 OK

{
  "created": true,
  "contact": {
    "id": "+917012887078",
    "name": "Saranya K",
    "phone": "+917012887078",
    "email": "[email protected]",
    "instagram": "saranya.k",
    "messenger": "saranya.k",
    "tiktok": "saranya.k",
    "customerId": "C-2231",
    "country": "India",
    "language": "English",
    "tags": [
      "api",
      "customer",
      "cod"
    ],
    "channels": {
      "whatsapp": true,
      "sms": true,
      "email": true,
      "instagram": "waiting_for_dm",
      "messenger": "waiting_for_dm",
      "tiktok": "waiting_for_dm"
    },
    "consent": {
      "whatsapp": "subscribed",
      "sms": "unsubscribed",
      "email": "subscribed"
    },
    "source": "Store",
    "attributes": {
      "city": "Kochi",
      "pincode": "682001",
      "address": "12 MG Road"
    },
    "orders": 1,
    "lifetimeValue": 4200,
    "lifetimeCurrency": "INR",
    "recentOrders": [
      {
        "id": "10428",
        "total": 4200,
        "currency": "INR",
        "status": "paid",
        "at": 1717800000000
      }
    ],
    "chatLinks": {
      "messenger": "https://m.me/104857300112233?ref=zc_Q2x8mW4rTb1p",
      "instagram": "https://ig.me/m/sageandloom?ref=zc_Q2x8mW4rTb1p"
    }
  }
}
curl -X POST https://getzappio.com/v1/contacts \
  -H "Authorization: Bearer zap_live_••••••••" \
  -H "Content-Type: application/json" \
  -d '{"phone":"+91 70128 87078","email":"[email protected]","instagram":"@saranya.k","messenger":"facebook.com/saranya.k","tiktok":"@saranya.k","customerId":"C-2231","name":"Saranya K","country":"India","language":"English","tags":["customer","cod"],"consent":{"whatsapp":true,"sms":false,"email":true},"attributes":{"city":"Kochi","pincode":"682001","address":"12 MG Road"},"order":{"id":"10428","total":4200,"currency":"INR","status":"paid"}}'
const res = await fetch("https://getzappio.com/v1/contacts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "phone": "+91 70128 87078",
  "email": "[email protected]",
  "instagram": "@saranya.k",
  "messenger": "facebook.com/saranya.k",
  "tiktok": "@saranya.k",
  "customerId": "C-2231",
  "name": "Saranya K",
  "country": "India",
  "language": "English",
  "tags": [
    "customer",
    "cod"
  ],
  "consent": {
    "whatsapp": true,
    "sms": false,
    "email": true
  },
  "attributes": {
    "city": "Kochi",
    "pincode": "682001",
    "address": "12 MG Road"
  },
  "order": {
    "id": "10428",
    "total": 4200,
    "currency": "INR",
    "status": "paid"
  }
}),
});
const data = await res.json();
import requests

res = requests.post(
    "https://getzappio.com/v1/contacts",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
  "phone": "+91 70128 87078",
  "email": "[email protected]",
  "instagram": "@saranya.k",
  "messenger": "facebook.com/saranya.k",
  "tiktok": "@saranya.k",
  "customerId": "C-2231",
  "name": "Saranya K",
  "country": "India",
  "language": "English",
  "tags": [
    "customer",
    "cod"
  ],
  "consent": {
    "whatsapp": True,
    "sms": False,
    "email": True
  },
  "attributes": {
    "city": "Kochi",
    "pincode": "682001",
    "address": "12 MG Road"
  },
  "order": {
    "id": "10428",
    "total": 4200,
    "currency": "INR",
    "status": "paid"
  }
},
)
data = res.json()
⚡ Try it
GET/v1/contacts/{phoneOrEmail}

Get a contact

Reads one contact back by phone number (matched on its last 10 digits) or email address. `channels` says where they can be reached now; `consent` is marketing permission per channel. 404 if there is no such contact on your key's number.

Query parameters

NameTypeRequiredDescription
phoneOrEmailstringrequirede.g. 917012887078 or [email protected]

Response 200 OK

{
  "id": "+917012887078",
  "name": "Saranya K",
  "phone": "+917012887078",
  "email": "[email protected]",
  "instagram": "saranya.k",
  "channels": {
    "whatsapp": true,
    "sms": true,
    "email": true,
    "instagram": "connected",
    "messenger": null,
    "tiktok": "waiting_for_dm"
  },
  "consent": {
    "whatsapp": "subscribed",
    "sms": null,
    "email": "subscribed"
  },
  "tags": [
    "api",
    "customer"
  ],
  "attributes": {
    "city": "Kochi"
  },
  "orders": 3,
  "lifetimeValue": 11800,
  "lifetimeCurrency": "INR",
  "recentOrders": [],
  "chatLinks": {
    "messenger": "https://m.me/104857300112233?ref=zc_Q2x8mW4rTb1p",
    "instagram": "https://ig.me/m/sageandloom?ref=zc_Q2x8mW4rTb1p"
  }
}
curl https://getzappio.com/v1/contacts/{phoneOrEmail} \
  -H "Authorization: Bearer zap_live_••••••••"
const res = await fetch("https://getzappio.com/v1/contacts/{phoneOrEmail}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});
const data = await res.json();
import requests

res = requests.get(
    "https://getzappio.com/v1/contacts/{phoneOrEmail}",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
data = res.json()
⚡ Try it
POST/v1/bots/{id}/run

Run a bot for a contact

Triggers a specific bot flow for a contact from your own backend — useful for onboarding, post-purchase or re-engagement sequences. Pass variables your flow references with {{var}}.

Query parameters

NameTypeRequiredDescription
idstringrequiredThe bot id (from the Bots page URL).

Request body

{
  "to": "+9170128 87078",
  "vars": {
    "plan": "pro",
    "first_name": "Aya"
  }
}

Response 200 OK

{
  "ok": true,
  "botId": "bot_8fa21",
  "conversationId": "c-1c4e"
}
curl -X POST https://getzappio.com/v1/bots/{id}/run \
  -H "Authorization: Bearer zap_live_••••••••" \
  -H "Content-Type: application/json" \
  -d '{"to":"+9170128 87078","vars":{"plan":"pro","first_name":"Aya"}}'
const res = await fetch("https://getzappio.com/v1/bots/{id}/run", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "to": "+9170128 87078",
  "vars": {
    "plan": "pro",
    "first_name": "Aya"
  }
}),
});
const data = await res.json();
import requests

res = requests.post(
    "https://getzappio.com/v1/bots/{id}/run",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
  "to": "+9170128 87078",
  "vars": {
    "plan": "pro",
    "first_name": "Aya"
  }
},
)
data = res.json()
⚡ Try it
POST/v1/events

Trigger event-subscribed bots

Pushes a named event to the workspace. Every live bot whose trigger subscribes to that event name runs for the given contact. One event can fan out to multiple flows.

Request body

{
  "event": "order.created",
  "to": "+9170128 87078",
  "vars": {
    "order_id": "#10428"
  }
}

Response 200 OK

{
  "ok": true,
  "matched": 1,
  "conversationId": "c-1c4e"
}
curl -X POST https://getzappio.com/v1/events \
  -H "Authorization: Bearer zap_live_••••••••" \
  -H "Content-Type: application/json" \
  -d '{"event":"order.created","to":"+9170128 87078","vars":{"order_id":"#10428"}}'
const res = await fetch("https://getzappio.com/v1/events", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "event": "order.created",
  "to": "+9170128 87078",
  "vars": {
    "order_id": "#10428"
  }
}),
});
const data = await res.json();
import requests

res = requests.post(
    "https://getzappio.com/v1/events",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
  "event": "order.created",
  "to": "+9170128 87078",
  "vars": {
    "order_id": "#10428"
  }
},
)
data = res.json()
⚡ Try it
POST/v1/triggers/send

Fire a trigger (queued send)

The way a 3rd-party app turns a business event (invoice submitted, order shipped, appointment booked) into a WhatsApp template message. The send is accepted instantly, queued, paced through all rate limits, and logged — your app never touches WhatsApp directly. Pass an `idempotency_key` so a retry never double-sends, and a `source_app` label so it's attributable in the delivery log. An optional `contact` block saves the customer's details to Contacts (see Save a contact).

Request body

{
  "to": "+9170128 87078",
  "template": "invoice_notification",
  "language": "en_IN",
  "variables": [
    "Aya",
    "#10428",
    "₹4,200"
  ],
  "idempotency_key": "inv-10428",
  "source_app": "Invoice App",
  "contact": {
    "name": "Aya Nair",
    "email": "[email protected]",
    "order": {
      "id": "10428",
      "total": 4200,
      "currency": "INR"
    }
  }
}

Response 200 OK

{
  "ok": true,
  "jobId": "q_abc123",
  "deduped": false,
  "status": "queued",
  "contactId": "+917012887078",
  "chatLinks": {
    "messenger": "https://m.me/104857300112233?ref=zc_Q2x8mW4rTb1p",
    "instagram": "https://ig.me/m/sageandloom?ref=zc_Q2x8mW4rTb1p"
  }
}
curl -X POST https://getzappio.com/v1/triggers/send \
  -H "Authorization: Bearer zap_live_••••••••" \
  -H "Content-Type: application/json" \
  -d '{"to":"+9170128 87078","template":"invoice_notification","language":"en_IN","variables":["Aya","#10428","₹4,200"],"idempotency_key":"inv-10428","source_app":"Invoice App","contact":{"name":"Aya Nair","email":"[email protected]","order":{"id":"10428","total":4200,"currency":"INR"}}}'
const res = await fetch("https://getzappio.com/v1/triggers/send", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "to": "+9170128 87078",
  "template": "invoice_notification",
  "language": "en_IN",
  "variables": [
    "Aya",
    "#10428",
    "₹4,200"
  ],
  "idempotency_key": "inv-10428",
  "source_app": "Invoice App",
  "contact": {
    "name": "Aya Nair",
    "email": "[email protected]",
    "order": {
      "id": "10428",
      "total": 4200,
      "currency": "INR"
    }
  }
}),
});
const data = await res.json();
import requests

res = requests.post(
    "https://getzappio.com/v1/triggers/send",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={
  "to": "+9170128 87078",
  "template": "invoice_notification",
  "language": "en_IN",
  "variables": [
    "Aya",
    "#10428",
    "₹4,200"
  ],
  "idempotency_key": "inv-10428",
  "source_app": "Invoice App",
  "contact": {
    "name": "Aya Nair",
    "email": "[email protected]",
    "order": {
      "id": "10428",
      "total": 4200,
      "currency": "INR"
    }
  }
},
)
data = res.json()
⚡ Try it
GET/v1/triggers/{jobId}

Check a trigger's delivery status

Poll the status of a queued trigger — queued → sent → delivered → read, or failed with a reason.

Query parameters

NameTypeRequiredDescription
jobIdstringrequiredThe jobId returned by the send endpoint.

Response 200 OK

{
  "id": "q_abc123",
  "status": "delivered",
  "recipient": "917012887078",
  "template": "invoice_notification",
  "attempts": 1,
  "wamid": "wamid.HBgM..."
}
curl https://getzappio.com/v1/triggers/{jobId} \
  -H "Authorization: Bearer zap_live_••••••••"
const res = await fetch("https://getzappio.com/v1/triggers/{jobId}", {
  method: "GET",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
  },
});
const data = await res.json();
import requests

res = requests.get(
    "https://getzappio.com/v1/triggers/{jobId}",
    headers={"Authorization": f"Bearer {API_KEY}"},
)
data = res.json()
⚡ Try it

Webhooks

Receive events in real time. Register a URL in your workspace → Developers → Webhooks, choose the events you care about, and Zappio will POST a signed JSON payload to your endpoint.

Which number an event is for. A workspace can run several WhatsApp numbers, and every event says which one it belongs to: data.project ({id, name}) and data.number, the same values GET /v1/me returns. If you only want one number's events, pick it when you add the endpoint — an endpoint scoped to a number receives nothing about the others, and one left on Every number hears them all.

EventFires when
message.receivedA customer sends you a message — including a reply to something you sent. When it answers one of your messages it carries replyTo (the message, its reference and template) and, for a quick-reply tap, button.payload.
message.sentA message goes out — from an agent in the inbox, a bot, a broadcast or the API. Carries the text, the kind, and sentBy ({type, name} — the agent's name when a person sent it, the bot's name when a bot did).
message.statusA delivery status changes (delivered / read / failed).
bot.completedA bot flow finishes for a contact.

Payload

{
  "event": "message.received",
  "createdAt": 1717800000000,
  "data": {
    "conversationId": "c-1c4e",
    "from": "+917012887078",
    "channel": "whatsapp",
    "text": "Where's my order?",
    "project": {
      "id": "prj-7f3a",
      "name": "Vape Big"
    },
    "number": "+44 7927 936601"
  }
}

A delivery status

message.status moves forward only — sent → delivered → read, or failed with the reason. at is when the status happened; every timestamp in a webhook is epoch milliseconds.

{
  "event": "message.status",
  "createdAt": 1717800004000,
  "data": {
    "conversationId": "c-1c4e",
    "wamid": "wamid.HBgM...",
    "status": "delivered",
    "recipient": "+917012887078",
    "at": 1717800004000
  }
}

A message you sent

message.sent tells you what went out and who sent it, so a helpdesk mirroring this thread can show the reply rather than just the fact of one. It fires for every outgoing message — an agent in the inbox, a bot, a broadcast, and anything sent through this API, whichever key sent it.

{
  "event": "message.sent",
  "createdAt": 1717800042000,
  "data": {
    "conversationId": "c-1c4e",
    "to": "+917012887078",
    "kind": "out",
    "text": "It ships today — tracking follows this afternoon.",
    "source": "agent",
    "sentBy": {
      "type": "agent",
      "name": "Riya Menon"
    },
    "wamid": "wamid.HBgM...",
    "template": null
  }
}

Photos and files

When a customer sends a photo or document, message.received carries mediaType, the filename where there is one, and mediaUrl — fetch it with your API key, exactly like any other call:

{
  "event": "message.received",
  "createdAt": 1717800110000,
  "data": {
    "conversationId": "c-1c4e",
    "from": "+917012887078",
    "channel": "whatsapp",
    "text": "Photo",
    "mediaType": "image",
    "mediaId": "1024455512345678",
    "filename": null,
    "mediaUrl": "https://app.getzappio.com/v1/media/1024455512345678"
  }
}

The link streams the file itself, with the right Content-Type. WhatsApp keeps media for a limited time, so fetch it when the event arrives rather than days later. Files you send are stored by Zappio rather than WhatsApp, so message.sent gives you the caption in text and the mediaType, with no media id.

A reply to a message you sent

When the customer answers a message sent with your key — tapping a template's quick-reply button, or just replying — the same event tells you which of your messages it answers. Branch on button.payload (stable) rather than the visible label, which changes with the template's language.

How sure is the link? replyTo.matchedBy says. "context" means WhatsApp itself told us what the message answers — it does that when the customer taps a quick-reply button or long-press-quotes your message. "recent" means the customer simply typed an answer, which arrives attached to nothing, so this is the message we sent them immediately before it. We only infer that when nothing else was said in between (no other reply from them, no agent message after yours) and your message went out within the last 24 hours. Treat "recent" as a strong hint rather than proof: if a wrong match would cost something — a payment, a cancellation — ask the customer to confirm with a quick-reply button, which gives you "context" every time.

{
  "event": "message.received",
  "createdAt": 1717800097000,
  "data": {
    "conversationId": "c-1c4e",
    "from": "+917012887078",
    "channel": "whatsapp",
    "text": "Yes, please deliver today",
    "button": {
      "text": "Yes, please deliver today",
      "payload": "CONFIRM_DELIVERY"
    },
    "replyToWamid": "wamid.HBgM...",
    "replyTo": {
      "id": "am_3f9c21d8",
      "messageId": "wamid.HBgM...",
      "reference": "order-10428",
      "template": "order_update",
      "sentAt": 1717800000000
    }
  }
}

Verifying the signature

Each request carries an X-Zappio-Signature header of the form sha256=<hex> — an HMAC-SHA256 of the raw request body using your endpoint's secret. Recompute it and compare to confirm the request really came from Zappio.

import crypto from "node:crypto";

function verify(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected), Buffer.from(signatureHeader),
  );
}
import hmac, hashlib

def verify(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
↻ Test it instantly: use Send test on any endpoint in Developers → Webhooks to fire a sample event and watch the delivery log.