What the API does
Wamafy's public REST API lets your own systems (a CRM, an ecommerce backend, a helpdesk, Zapier/Make) drive WhatsApp programmatically - send messages, group those sends into a reportable API campaign, sync contacts, receive inbound messages via webhooks, run broadcasts, manage templates, and read analytics.
- Base URL:
https://api.wamafy.com/api/v1/public - Format: JSON in, JSON out. Every response is
{ "success": boolean, "data"?: ..., "error"?: { "message", "code" } }. - Two-way: send with the endpoints below; receive inbound customer messages by registering a webhook (see the last section).
Authentication
Create an API key at Settings → API Access → Create key. The full key (wamafy_live_…) is shown once - store it somewhere safe. Send it on every request:
Authorization: Bearer wamafy_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A revoked key, or a key for a suspended workspace, returns 401. You can hold up to 25 active keys and revoke any of them at any time.
Scopes (least privilege)
Each key carries a set of scopes. When you create a key, either tick Full access (covers everything, current + future) or choose only the scopes that integration needs. Calling an endpoint your key isn't scoped for returns 403 with code API_SCOPE_MISSING.
| Scope | Grants |
|---|---|
templates:read | List templates + their variables |
templates:write | Create + submit templates for review |
messages:send | Send templates + session replies; window check |
messages:read | Read a contact's message history |
contacts:read | List + fetch contacts |
contacts:write | Create / update / opt-in / opt-out contacts |
media:write | Upload media to reuse in sends |
campaigns:send | Trigger a broadcast to a segment or a set of tags |
analytics:read | Read message + delivery stats |
flows:trigger | Start a chatbot flow for a contact |
Keys created before scopes existed default to full access.
Quickstart - list templates + send one
Only approved templates can be sent to someone who hasn't messaged you yet (that's Meta's rule). List yours:
curl https://api.wamafy.com/api/v1/public/templates \ -H "Authorization: Bearer wamafy_live_xxx"
Then send one - map your data onto each variable key:
curl -X POST https://api.wamafy.com/api/v1/public/messages \
-H "Authorization: Bearer wamafy_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "to": "+919876543210", "templateName": "order_update", "variables": { "1": "Priya", "2": "#1234" } }'
The response returns { messageId, to, from, status, campaignName, templateName }. Use ?number= on the list, or "from" on the send, to pick a specific WhatsApp number.
Sending messages
Templates (cold start) - POST /messages
Body: to and templateName, plus optional variables, from, language, headerMediaUrl for media-header templates, buttons for templates with dynamic buttons, and campaignName to file the send under an API campaign. This is the reliable way to reach someone at any time.
templateName is required unless you pass campaignName - the campaign carries the template. Send neither and you get a 422 saying so.
Group your sends - campaignName
An API campaign is a named container your sends roll up into, so "order confirmations" becomes something you can report on rather than a filter you rebuild from a flat log. Create one in Campaigns → New campaign → API campaign (a name and a template - it is live immediately), then name it on each send:
curl -X POST https://api.wamafy.com/api/v1/public/messages -H "Authorization: Bearer wamafy_live_xxx" -H "Content-Type: application/json" -d '{
"to": "+919876543210",
"campaignName": "order_confirmations",
"variables": { "1": "Priya", "2": "#1234" }
}'
The response echoes both back, so you can confirm the send was filed where you meant and which template actually went out:
{
"success": true,
"data": {
"messageId": "wamid.ABC123",
"to": "919876543210",
"from": "dddddddd-dddd-dddd-dddd-dddddddddddd",
"status": "sent",
"campaignName": "order_confirmations",
"templateName": "order_update"
}
}
The campaign carries the template, so templateName is not needed - and that is the point: the workspace can change which template a campaign sends without you shipping code. Past sends keep the template they actually went out with.
- A campaign name that does not exist is a
404and the message is not sent - accepting it and filing it nowhere would leave you believing it was counted. - A paused campaign refuses sends with a
400. That is the workspace's kill switch for an integration, and it would be worthless if sends went out anyway. - Send
templateNameas well and the two must agree; a mismatch is a400naming the template the campaign actually sends. - Send neither and you get a
422- one of the two is required. - Omit
campaignNameentirely and nothing changes. It is optional, and existing integrations need no edit.
The report - funnel, per-day chart, and every failure with Meta's reason - is on the campaign's page in the dashboard.
Dynamic buttons - buttons
A template can carry buttons whose value is decided at send time: a URL button whose link contains a placeholder, and a COPY_CODE button (an OTP or a coupon). WhatsApp requires one parameter per such button and rejects the whole send when one is missing, so these templates cannot be sent without supplying them.
{
"to": "919876543210",
"templateName": "order_confirmed",
"variables": { "1": "Asha" },
"buttons": [
{ "index": 0, "type": "url", "value": "order/1043" },
{ "index": 1, "type": "copy_code", "value": "482913" }
]
}
index- the button's 0-based position in the template, as returned byGET /templates. Not its position in your array.type-urlfor a URL button with a placeholder,copy_codefor an OTP or coupon button.value- forurl, only the part that replaces the placeholder: WhatsApp appends it to the approved prefix, so sending a full URL produces a link with your domain in it twice. Forcopy_code, the code itself.
Tracking who clicked a link - /r/{{1}}
WhatsApp tells nobody when a URL button is tapped. There is no webhook for it and no per-message signal from Meta, so the only way to know who clicked is to route the tap through a link of ours first. That is a choice you make when the template is approved, not at send time.
To make a button trackable, submit the template with its URL set to exactly:
https://api.wamafy.com/r/{{1}}
Meta requires an example value for any URL containing a placeholder (anything will do, e.g. abc123) and requires the {{1}} to be at the END of the URL. Then send your real destination as the button's value:
{
"to": "919876543210",
"campaignName": "order_confirmations",
"buttons": [
{ "index": 0, "type": "url", "value": "https://yoursite.com/order/1043" }
]
}
We mint a one-time token for that URL, put it in the button, and record the tap against this exact recipient before forwarding them to https://yoursite.com/order/1043. The campaign report then shows a Clicked stage, and lets you list who clicked and who was delivered but did not.
The meaning of value flips for these buttons, and only for these:
- Button URL is
https://api.wamafy.com/r/{{1}}→ send the full destination URL. We mint the token. - Any other URL, e.g.
https://yoursite.com/{{1}}→ send only the part that replaces the placeholder (order/1043), as described above. Nothing is tracked, because the tap never reaches us.
You do not flag this on the request - we read it off the template, so the two can never disagree. Sending a path fragment to a tracked button is a 400 rather than a link that dies in the customer's hand.
Two things worth knowing before you commit to it. A template already approved with a fixed link cannot be retrofitted - Meta treats a static and a dynamic URL as different buttons, so it needs resubmitting. And the customer briefly passes through api.wamafy.com on the way to your page; that hop is what makes the click countable, and every platform that reports clicks does the same thing.
Only dynamic buttons take a value. A URL button with a fixed link, a quick reply and a phone-number button take none, and supplying one is rejected - WhatsApp refuses an unexpected button parameter, so accepting it here would move the failure somewhere that never mentions buttons.
Leave one out and you get a 400 naming each button by position and label, rather than a numbered WhatsApp error about a structure you never wrote:
Template "order_confirmed" needs a value for 2 button(s):
index 0 ("View order", URL), index 1 ("Copy code", COPY_CODE).
There is no raw components passthrough. Every field here is validated, and unknown fields are ignored rather than rejected - so a hand-built components array would be dropped silently and the send would fail at WhatsApp instead of here.
Session replies (inside the 24h window)
After a customer messages you, a 24-hour window opens where you can reply with free-form content. Check it first:
GET /window?to=919876543210 → { "windowOpen": true, "expiresAt": "..." }
POST /messages/text-{ to, text }POST /messages/interactive- replybuttons(up to 3) or alistmenu; the customer's tap returns on the inbound webhookPOST /messages/media- image/doc/video/audio (≤ 16 MB) from amediaHandleor a publiclink
If there's no open conversation you get 400 NO_OPEN_CONVERSATION - send a template instead. Session sends count against your monthly message limit and report delivery status like template sends.
Contacts (CRM sync)
Phone is the natural key. Read needs contacts:read, writes need contacts:write.
GET /contacts- filter (search,status,consentStatus,tag) + paginateGET /contacts/:phone- fetch onePOST /contacts- upsert by phone: updates if it exists, else creates; the response includes"created": true|falsePATCH /contacts/:phone- partial update (name / email / tags / status / consent)POST /contacts/:phone/opt-outand/opt-in- consent shortcutsGET /contacts/:phone/messages- message history, newest first, cursor-paginated withbefore/nextBefore(messages:read)
curl -X POST https://api.wamafy.com/api/v1/public/contacts \
-H "Authorization: Bearer wamafy_live_xxx" -H "Content-Type: application/json" \
-d '{ "phone": "+919876543210", "name": "Priya", "tags": ["vip"] }'
Media, campaigns, templates, analytics, flows
Media - POST /media (media:write)
Upload a file (multipart/form-data, field file) and get a mediaHandle to reuse in media sends. Or skip it and pass a public link straight to POST /messages/media.
Campaigns - POST /campaigns (campaigns:send)
Broadcast an approved template to a saved segment (segmentId), to contacts carrying one or more tags, or to every active contact when you pass neither. tags is a plain tag match and is mutually exclusive with segmentId - passing both is refused rather than silently resolved, because a segment already defines its audience. Creates + starts it immediately; opted-out contacts are skipped. Body: templateName, tags, variables, name.
Segments - GET /segments (contacts:read)
Your saved segments, each with its name, how many conditions the rule is built from, and an approximateContacts count with the countedAt timestamp that goes with it. That count is cached, and both fields are null on a segment nobody has opened since creating it - treat a null as "unknown", never as zero.
Read-only on purpose. A segment is a rule composed against a vocabulary the builder can show you; an integration creating one would be writing a nested rule tree by hand, and its first typo would be a segment matching nobody with nothing to say why. Build them in the dashboard, read them here.
Segment members - GET /segments/:id/contacts (contacts:read)
Who is in the segment right now, resolved on every call - anything you cache is back to being the frozen list a segment exists to avoid. Paged with limit (max 500) and offset; page on meta.hasMore rather than comparing counts, because total is live and can move while you read.
Only contacts a message can actually reach come back. Unreachable numbers and opted-out contacts are excluded here rather than left for your send to fail on - those sends cannot arrive, and every undeliverable feeds Meta's quality signals on your sending number.
To broadcast to the whole segment, use POST /campaigns with a segmentId - one call, no paging. The rule is re-resolved when the campaign starts and frozen then, so you get a real campaign with a report and the audience is whoever matched at send time rather than whoever matched when you read this endpoint. Page the members here only when you want to do something per contact yourself.
Templates - POST /templates (templates:write)
Create + submit a template to Meta for review (same spec as the dashboard builder). Poll GET /templates?status=all to watch it move from PENDING to APPROVED, then it's sendable.
Analytics - GET /analytics (analytics:read)
Message totals (sent / delivered / read / failed / readRate) + a per-day breakdown over from/to (default last 30 days), in your workspace timezone.
Flows - POST /contacts/:phone/trigger-flow (flows:trigger)
Start a chatbot flow for a contact by flowId, with optional variables that land in the run context. Returns started or skipped.
Webhooks - receiving events
To receive (not just send), register a webhook at Settings → API Access → Webhooks. Pick the events you want; you'll get a signing secret (shown once). Wamafy POSTs the event JSON to your URL.
message.inbound
Fires whenever a customer messages one of your numbers - text, button/list tap (interactiveReplyId), media (mediaId), and the Click-to-WhatsApp ad referral on the first message. Pair it with the session-send endpoints for a full two-way integration.
POST <your url>
X-Wamafy-Event: message.inbound
X-Wamafy-Signature: sha256=<hmac>
{
"event": "message.inbound",
"occurredAt": "2026-08-28T10:00:00.000Z",
"data": {
"messageId": "wamid.HBg...",
"from": "919876543210",
"fromName": "Priya",
"whatsappNumberId": "uuid-of-your-number",
"type": "text",
"text": "Is this in stock?",
"interactiveReplyId": null,
"mediaId": null,
"mediaCaption": null,
"conversationId": "uuid",
"leadId": "uuid",
"referral": null,
"sentAt": "2026-08-28T09:59:58.000Z"
}
}
Every field is always present; the ones that do not apply are null. type is WhatsApp's own message type (text, image, interactive, ...): for a button or list tap read interactiveReplyId, for media read mediaId, and referral carries the Click-to-WhatsApp ad context on the first message after an ad tap.
To reply, an inbound message opens a 24-hour window - use POST /messages/text, /messages/interactive or /messages/media for free-form replies inside it, and a template through POST /messages outside it. conversationId and leadId are Wamafy's own ids for the thread and the contact, so you can join a reply to whatever you store on your side.
Two more events worth subscribing to
conversation.handed_off fires when a chatbot reaches a handoff step - the flow decided a person should take over. It carries the conversation and contact, the flow and run ids, whatever note was written on the handoff step, and assignedToUserId / teamId saying who the step handed it to (both null if it named nobody). This is the one to route into a ticket or an alert: it means the bot gave up, which is not the same as a customer simply writing in. The chatbot is held off the thread from that moment, so a reply you send will not be talked over by a greeting flow.
csat.received fires when a customer taps a rating on the post-chat survey. It carries the score (1 to 5), the conversation and contact, and who was handling the thread - so a one-star rating can be routed to the right person without a second lookup.
There is deliberately no event for message delivery status. That has its own webhook, described below, because every send produces a sent, a delivered and a read receipt - a broadcast to 5,000 contacts would be 15,000 events.
Verify the signature
X-Wamafy-Signature is sha256= + HMAC-SHA256 of the raw request body, keyed with your webhook's signing secret. Recompute it and compare - reject the request if it doesn't match.
Two webhooks, two registrations, two secrets
These are separate features and each issues its own signing secret when you register it. "Signed the same way" means the same algorithm, not the same key - verify each against the secret that registration gave you, or one half of the integration will reject every delivery.
- Event webhooks (
message.inbound,conversation.handed_off,csat.received) - Settings → API Access → Webhooks. One secret per subscription, and anX-Wamafy-Eventheader naming the topic. The event JSON nests its fields underdata. - Status webhook (delivery receipts) - Settings → API Access → Status webhook. One secret for the workspace, no
X-Wamafy-Eventheader, and the fields are flat -messageIdsits at the top level, not underdata.
Neither webhook retries. Always answer 2xx.
Delivery is best-effort for both - a failed POST is logged and counted, never resent. So returning a 5xx when your own database is down wins nothing back and costs you something: an event subscription auto-disables after 15 consecutive failures, taking every future message with it. The status webhook does not auto-disable, but a 5xx there is equally pointless.
Answer 2xx as soon as you have the body, queue the work on your side, and treat your own log as the recovery path. If you need to recover what you missed, export the Sent messages CSV from the dashboard. (An earlier version of this page pointed at a GET /messages endpoint. There is no such endpoint and there never was - the mistake was ours.)
Two timestamps, and they mean different things
sentAt on an inbound message is when the customer sent it. occurredAt on the envelope is when Wamafy dispatched the callback. They are usually within a second of each other and are not the same thing - stamping a reply with occurredAt records your own dispatch time as the customer's.
Status webhook - delivery receipts
A separate callback pushes delivered / read / failed for messages you sent. Set it in Settings → API Access → Status webhook; it is signed the same way.
{
"event": "message.status",
"messageId": "wamid.ABC123",
"to": "919876543210",
"templateName": "order_confirmed",
"status": "delivered",
"errorCode": null,
"errorMessage": null,
"occurredAt": "2026-08-28T10:00:00.000Z"
}
Four things a correct receiver needs to know:
sentnever arrives here. You already have it from the send response, so a callback for it would be noise.- Order is not guaranteed.
readcan arrive beforedelivered. Key onmessageIdand treat the statuses as a set you have seen, not a sequence. - A status can repeat. WhatsApp re-delivers and Wamafy passes that through, so make your handler idempotent on (
messageId,status). occurredAtis when Wamafy sent the callback, not when WhatsApp reported the status. Close, but don't treat it as the delivery instant.
Delivery is best-effort (no retries in this version) - respond 2xx quickly. Every send is also visible in Settings → Sent messages, which exports to CSV.
Respond 2xx quickly. Endpoints that keep failing auto-disable; re-enable from the dashboard. Use Send test to check reachability.
Errors, limits + the request log
Errors come back as { "success": false, "error": { "message", "code" } } with an HTTP status. Common codes:
| Status | Meaning |
|---|---|
401 | Missing / invalid / revoked key |
403 | Key missing the required scope, or monthly API cap reached |
404 | Template / contact / flow / number not found |
400 | Bad request - e.g. NO_OPEN_CONVERSATION, template not approved |
422 | Validation failed (bad body shape) |
429 | Rate limit - 60 requests/min per key |
- Rate limit: 60 requests/minute per key, plus your plan's monthly message + campaign caps.
- Request log: every call is recorded at Settings → API Access → Request log - endpoint, status, outcome, and the error reason on failures. Your first stop for debugging.