WhatsApp Business API
WhatsApp Business API
Send an approved WhatsApp template to a customer with one HTTPS request: media headers, dynamic buttons, contact tags, idempotency and signed delivery status callbacks.
- Base URL
- https://app.agentive.co.in/api/v1
- Format
- JSON over HTTPS
- Auth
- Bearer campaign token
Introduction
The WhatsApp Business API sends an approved WhatsApp template to one recipient per request, from your own code. Use it for the messages your system already knows when to send: order and shipping updates, appointment reminders, payment confirmations, renewal notices.
Every request goes through an API campaign that you create once in the Agentive Chat dashboard. The campaign fixes the template, the sending number and which variables come from the request, so your code only sends the values that change. Broadcasts to a list and campaigns that fire on Agentive events stay in the dashboard; this API is for sends that your own system triggers.
If your Agentive Voice account is connected, call events become triggers too, so a missed call can send a template with no code at all. See When Agentive Voice is connected.
pricing object of your status callbacks.Before you start
- A WhatsApp number connected to an Agentive agent (Agent > WhatsApp).
- A template approved by WhatsApp, synced under Agent > WhatsApp > Templates.
- An API campaign that is live, with its token copied. The quick start walks through it.
Platform conventions
Agentive ships two API families: the Voice APIs (Voice Broadcast, AI Voice Agent, Voice OTP and telephony webhooks) and the WhatsApp Business API. They share a brand, a dashboard account and a set of habits, but they are separate products and they do not agree on everything. This section is the one place that says what is common and, where they differ, exactly how.
Base URLs
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Base URL | https://voice.agentive.co.in/api/public/v1 | https://app.agentive.co.in/api/v1 |
| Covers | Voice Broadcast, AI Voice Agent, Voice OTP and the endpoints webhook events point back at. All of them share one base, one credential pair and one envelope. | Template sends for one API campaign. Issued and managed from the Chat dashboard. |
| Transport | HTTPS only, JSON in and JSON out. | HTTPS only, JSON in and JSON out. |
Authentication
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Credential | One publishable key (pk_live_...) and one secret (sk_live_...) per account. | One token (agcamp_...) per API campaign, so a leaked token can only send that campaign. |
| How to send it | x-api-key and x-api-secret headers, or HTTP Basic with the publishable key as the username and the secret as the password. | Authorization: Bearer <token>. |
| Scope | Account-wide. Every endpoint on the Voice base accepts it. | Campaign-wide. The template and number are fixed on the campaign. |
| Treat as | Both keys are confidential. The publishable key names your account to anyone who holds it, so keep it with the secret, on your server only. | A secret. Keep it on your server only. |
| Rotation | From the API Access tab of the Voice dashboard. The old secret stops working at once. | From the campaign page in the Chat dashboard. The old token stops working at once. |
Error envelope
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Shape | { status, code: number, error_code: string, error, details, unique_id, reference_id } | { error, code: string, details: object } |
| What to branch on | error_code. The numeric code mirrors the HTTP status and stays for compatibility. | code, which is itself the machine string. |
| Human sentence | details, repeated in error so a handler written for the WhatsApp shape reads a Voice error too. | error. |
| Extra context | Named fields beside the envelope (lines, lines_in_use, attempts_left, retry_after_sec, balance_inr). | A details object (missing, retryAfter, meta, validation). |
401 and 403
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| 401 | Credentials missing, unknown, or rejected. The status never confirms whether a key exists. | Token missing or wrong. |
| 403 | Credentials accepted, but the account is not active or the product is not enabled. | Token accepted, but the campaign is not live or the number is not connected. |
| 404 | Not found, or not yours. A foreign id is never a 403, because that would confirm it exists. | Not found, or not yours. Same rule. |
Idempotency
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Key | Idempotency-Key request header, 1 to 128 visible characters. A longer key is refused with HTTP 400. | externalId in the body (the Idempotency-Key header is accepted too), up to 160 characters. |
| Window | 24 hours per account. | The life of the campaign. |
| A repeat | Replays the stored response. The one exception is a 5xx raised after the call was already handed to the network: that reply is stored as call_state_unknown with a sentence appended telling you to check the call before retrying. A 400 or 404 is stored for the 24 hours too, so once you fix the request or the dashboard setting, send it with a new key. | HTTP 200 with duplicate: true and the earlier delivery's current status. |
| Replay marker | Idempotent-Replayed: true, plus the original idempotency-replayed: true header. | duplicate: true in the body. |
| Same key, different body | HTTP 422 with idempotency_conflict. | The first send stands; the second is reported as a duplicate. |
| Still in flight | HTTP 409 with idempotency_in_progress and Retry-After. | Not applicable. |
Rate limits and headers
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Budget | Per account: 120 reads a minute and 120 writes a minute, counted separately, so polling can never starve call placement. | Per campaign token: 300 requests a minute over a sliding window. |
| Limit headers | RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, with X-RateLimit-* aliases of the same three values. | None. |
| How long to wait | Retry-After on every 429 and every 409, mirrored in the body as retry_after_sec. | Retry-After on rate_limited; details.retryAfter (IST) on quiet_hours. |
| Capacity refusal | HTTP 429 concurrency_limit when the account has no free line, with lines, lines_in_use and retry_after_sec in the body and a Retry-After of 5 seconds. Call-placing responses also carry X-Agentive-Lines and X-Agentive-Lines-In-Use. | Not applicable; there are no lines to exhaust. |
Webhook signing
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Header to verify | X-Agentive-Signature-V2: t=<unix seconds>,v2=<hex> | X-Agentive-Signature: <hex>, with no prefix. |
| What is signed | HMAC-SHA256, with the signing secret, of t, a dot, then the exact raw body. | HMAC-SHA256 of the exact raw body with the endpoint's signing secret. |
| Freshness | Reject a t more than 300 seconds from your own clock. The timestamp is inside the signed material, so a captured delivery cannot be sent again later. | Nothing time-bound is signed. De-duplicate on the event id for good, and accept deliveries over HTTPS only. |
| Comparison | Constant time, on bytes. Reject on any mismatch, and reject a delivery with no version 2 header. | Constant time, on bytes. Reject on any mismatch. |
X-Agentive-Signature-V2. Voice deliveries also carry the older body-only X-Agentive-Signature for receivers built before version 2; do not accept it on its own, because it proves nothing about when a body was sent. The WhatsApp signature is the body alone with no prefix. A shared handler needs one verifier per product.Webhook delivery headers
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Event name | X-Agentive-Event | X-Agentive-Event |
| Event id | X-Agentive-Event-Id, the same value as id in the body. Shared by every endpoint that receives the event. | X-Agentive-Delivery, the same value as id in the body. Despite the name it is the event id, not a per-endpoint delivery id. |
| Delivery id | X-Agentive-Delivery-Id (dlv_...), one per event per endpoint, stable across every retry. | None. X-Agentive-Delivery is the Chat product's name for the event id above. |
| Attempt number | X-Agentive-Attempt, 1-based, counting through the whole retry schedule. | None. |
| Attempt time | X-Agentive-Timestamp, Unix seconds at the moment of this attempt, the same value as t in the version 2 signature. Check the age against the signed t, never against this header alone. | X-Agentive-Timestamp, the same timestamp as the envelope. |
| User agent | Agentive-Webhooks/2026-06-01 | Agentive-Webhooks/2026-05-08, the Chat payload version. |
Webhook retries
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Attempts | Nine: three fast, then six spread out. | Three. |
| Schedule | 0.5 s and 2 s between the first three, then 1 min, 5 min, 30 min, 2 h, 6 h and 12 h. | 0 s, 2 s and 8 s. |
| Total window | About 24 hours, after which the delivery is marked failed. | About 10 seconds. |
| Timeout per attempt | 6 seconds. | 10 seconds. |
| What you see | The dashboard lists recent deliveries per endpoint with the event, the latest result, the number of tries and the time in IST. | The campaign's Deliveries tab shows the message and its current status. |
Timestamps and amounts
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| Time zone | Indian Standard Time, ISO 8601 with a +05:30 offset. The field is named per product: created_at_ist on a call, expires_at_ist on a verification, timestamp_ist on a webhook envelope. | timestamp on the webhook envelope is UTC ISO 8601. Schedules and quiet hours are set and reported in IST. |
| UTC alongside | created_at_utc on a call, and occurred_at (UTC) plus created (Unix seconds) on a webhook envelope. | The envelope timestamp is already UTC. |
| Money | Indian Rupees, always. balance_inr is a number, never a formatted string. | Indian Rupees, always. Amounts appear in the dashboard, not in the send API. |
Versioning and field case
| Voice APIs | WhatsApp Business API | |
|---|---|---|
| API version | In the path: /api/public/v1. | In the path: /api/v1. |
| Payload version | api_version on every webhook event. Currently 2026-06-01. | apiVersion on every webhook event. Currently 2026-05-08. |
| Compatibility | Fields are added, never removed or redefined. Ignore fields you do not recognise. | Fields are added, never removed or redefined. Ignore fields you do not recognise. |
| Field case | snake_case throughout, request and response. | camelCase throughout, request and response. |
Security
- Every credential is a secret. That includes the Voice publishable key, whatever its name suggests: treat it exactly like the secret. Keep all keys on your server, in an environment variable or a secrets store, never in a browser, a mobile app, a spreadsheet or source control.
- Send them only to the API. Never forward a key or a secret to any other host, and do not let an HTTP client carry them across a redirect to another host.
- Rotate at once if one leaks. Rotate the Voice pair from the API Access tab of the Voice dashboard and a WhatsApp token from its campaign page. The old value stops working immediately, so update your server in the same step.
- HTTPS only. Write
https://in the base URL in your code. A request sent over plain HTTP has already exposed its credentials before any redirect can protect it. - No address allow-listing today. Requests are not restricted to your server's IP addresses, and webhooks are not sent from a fixed list of addresses. Authenticate every webhook by its signature, never by where it came from.
Base URL
https://app.agentive.co.in/api/v1Every path on this page is relative to this base. HTTPS only, JSON in and JSON out. This is the Agentive Chat app; the voice APIs use a different base, listed on their own pages.
Authentication
Each API campaign has its own token, prefixed agcamp_. It is shown once when the campaign goes live and can be rotated at any time from the campaign page (Agent > WhatsApp > Campaigns > your campaign). A token only sends for the campaign it belongs to, so a leaked token can never reach another template or number.
Send it on every request in the Authorization header:
curl https://app.agentive.co.in/api/v1/whatsapp/campaigns/cmf3k9d2a0001x7q2m4n8p1r6/send \
-H "Authorization: Bearer agcamp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "to": "+919876543210", "templateParams": ["Asha"] }'Tokens are compared in constant time. A missing or wrong token returns HTTP 401 with one of two codes:
| Status | Code | When |
|---|---|---|
| 401 | missing_token | No Authorization header, or one that is not Bearer <token>. |
| 401 | invalid_token | The token is unknown, was rotated, or belongs to a different campaign than the one in the URL. |
{
"error": "Send the campaign token as Authorization: Bearer <token>.",
"code": "missing_token"
}Response format
Every response is JSON. A send that was accepted returns HTTP 200 with success: true:
{
"success": true,
"duplicate": false,
"status": "accepted",
"messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSNkY3QjE4RkY0RjA2NDU2QjAxAA==",
"deliveryId": "cmf3kd8y10004x7q2h1k3v9pz",
"contactId": "cmf1a2b3c0009x7q2d4e5f6g7",
"warnings": []
}Anything else returns the matching HTTP status (400, 401, 404, 409, 429 or 502) and one error shape:
{
"error": "templateParams has 2 values but the template body needs 3.",
"code": "missing_template_values",
"details": { "missing": ["body_3"] }
}Error fields
| Field | Type | Description |
|---|---|---|
| error | string | A sentence you can show in a log or a support ticket. |
| code | string | A stable machine code. Branch on this, never on the text. The full list is under Errors. |
| details | object | Present when there is more to say: missing for missing_template_values, retryAfter for quiet_hours, meta for meta_rejected, the offending fields for validation. |
Errors
Every error carries a stable code. The table lists all of them with what to do. Most 4xx codes are yours to fix before resending; the 429 codes are timing; the 502 codes come from WhatsApp and are safe to retry with the same externalId.
| Status | Code | When | What to do |
|---|---|---|---|
| 400 | invalid_json | The body is not valid JSON, or the Content-Type is not application/json. | Send a JSON object with the Content-Type header set. Checked before anything else, including the token. |
| 400 | validation | A field has the wrong type or exceeds its limit. details lists the offending fields. | Fix the named fields against the request body table and resend. |
| 400 | invalid_recipient | to is not a number the API can resolve: too short, 11 digits, or a country code without a full number. | Send E.164 (+919876543210) or a 10 digit Indian mobile number. |
| 400 | missing_template_values | A body, header or button variable the campaign expects from the request was not sent. details.missing names the keys, for example body_3. | Send one templateParams entry per body variable and a value for every dynamic button. |
| 400 | media_required | The template has a media header, the request has no media, and no header media is saved on the campaign. | Send media.url or media.id, or save a default file on the campaign in the dashboard. |
| 400 | whatsapp_not_connected | The agent's WhatsApp number is disconnected or its access has expired. | Reconnect the number under Agent > WhatsApp, then retry with the same externalId. |
| 401 | missing_token | No Authorization header, or one that is not Bearer <token>. | Send Authorization: Bearer agcamp_… on every request. |
| 401 | invalid_token | The token is unknown, was rotated, or belongs to a different campaign than the one in the URL. | Copy the current token from the campaign page and check the campaignId in the URL. |
| 404 | campaign_not_live | The campaign is a draft, paused, deleted, or is not an API campaign. | Open the campaign in the dashboard and set it live. |
| 404 | template_not_found | The campaign's template was deleted or is no longer approved. | Sync templates under Agent > WhatsApp > Templates and pick an approved one on the campaign. |
| 409 | opted_out | The recipient has opted out of messages from this number. | Do not retry. The delivery is recorded as skipped and nothing was sent. |
| 429 | rate_limited | More than 300 requests in a minute on this token. The Retry-After header says how many seconds to wait. | Wait for Retry-After, then resend with the same externalId. |
| 429 | frequency_cap | The recipient has reached the marketing message limit for the last 24 hours. | Do not retry today. The delivery is recorded as skipped and nothing was sent. |
| 429 | quiet_hours | The request arrived inside the campaign's quiet hours. details.retryAfter is an ISO 8601 timestamp (IST offset) of when sending resumes. | Queue the send until retryAfter and resend with the same externalId. |
| 502 | meta_rejected | WhatsApp rejected the message. details.meta carries the platform's code and message. | Look up the code in the WhatsApp error reference. The idempotency key is released, so a corrected request can reuse the same externalId. |
| 502 | send_failed | WhatsApp could not be reached, or timed out before accepting the message. | Retry after a short pause with the same externalId; the key is released on this failure too. |
Example error bodies
{
"error": "(#131026) Message undeliverable",
"code": "meta_rejected",
"details": {
"meta": { "code": 131026, "message": "Message undeliverable" }
}
}{
"error": "Quiet hours are on. Sending resumes at 6 Sep 2026, 9:00 am IST.",
"code": "quiet_hours",
"details": { "retryAfter": "2026-09-06T09:00:00+05:30" }
}Rate limits
Requests are limited per token to 300 requests per minute over a sliding window. One campaign's traffic never throttles another. Over the limit you receive HTTP 429 with rate_limited and a Retry-After header in seconds:
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{
"error": "Too many requests for this token. Retry after 12 seconds.",
"code": "rate_limited"
}Two more 429 codes are about the recipient rather than your request rate, and they are not retried the same way:
frequency_cap: WhatsApp limits how many marketing messages one person can receive in 24 hours. The delivery is recorded as skipped, nothing was sent, and there is nothing to retry today.quiet_hours: the campaign has quiet hours and the request arrived inside them. Nothing was sent. Queue it untildetails.retryAfter(IST) and resend with the sameexternalId.
Retry-After, and never retry opted_out or frequency_cap. For a one-off list of thousands, a broadcast from the dashboard with a CSV is simpler than looping over the API.Endpoints
One endpoint sends a message. The older trigger URL still works and shares the same handler, so both accept the same body and return the same responses.
Send a message
/whatsapp/campaigns/{campaignId}/sendSends the campaign's template to one recipient. Creates or matches the contact by phone number, applies tags and attributes, checks opt-out and caps, then hands the message to WhatsApp. Returns as soon as WhatsApp accepts it.
Headers
| Header | Type | Required | Description |
|---|---|---|---|
| Authorization | string | Required | Bearer followed by the campaign token. See Authentication.Example: Bearer agcamp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |
| Content-Type | string | Required | Must be application/json. Any other body type is rejected with invalid_json.Example: application/json |
| Idempotency-Key | string | Optional | Optional. Same meaning as externalId in the body; send one or the other. See Idempotency.Example: order-1142-shipped |
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| campaignId | string | Required | The API campaign id, copied from the campaign page in the dashboard. The token in the Authorization header must belong to this campaign.Example: cmf3k9d2a0001x7q2m4n8p1r6 |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| to | string | Required | The recipient. E.164 with the country code, or a 10 digit Indian mobile number (a leading 0, 91 or +91 is understood). Alias: destination. Anything ambiguous, such as 11 digits, is refused with invalid_recipient rather than guessed.Example: +919876543210 |
| name | string | Optional | Display name saved on the contact. Up to 120 characters. Alias: userName.Example: Asha Verma |
| string | Optional | Email saved on the contact. Example: asha@example.in | |
| templateParams | string[] | Optional | Positional body variables in order: templateParams[0] fills {{1}}. The count must match the variables the campaign expects from the request, or the send fails with missing_template_values. See Template variables.Example: ["Asha", "ORD-1142", "Friday, 12 September"] |
| headerText | string | Optional | The value for a text header variable, when the template header has one. Example: Order ORD-1142 |
| media | object | Optional | The file for an image, video or document header: { "url", "filename" } with a public https URL (up to 2048 characters, filename for documents), or { "id" } with a media id already uploaded to your number. See Media requirements.Example: { "url": "https://cdn.example.in/invoices/ORD-1142.pdf", "filename": "Invoice.pdf" } |
| buttons | object | string[] | Optional | Dynamic button values keyed by button index ( "0" is the first button). The template decides the meaning: a URL button takes its dynamic suffix or the full URL, a COPY_CODE button the coupon code, a QUICK_REPLY button the payload you get back when it is tapped. A positional array is accepted. See Buttons.Example: { "0": "orders/ORD-1142" } |
| values | object | Optional | Advanced keyed overrides: body_N, header_N, button_i_url_N. A key here wins over the positional fields. See Keyed overrides.Example: { "body_1": "Asha", "button_0_url_1": "orders/ORD-1142" } |
| tags | string[] | Optional | Tags added to the contact. Up to 30, each up to 40 characters, created automatically if new. See Tags and attributes. Example: ["customer", "shipped"] |
| attributes | object | Optional | Custom fields merged onto the contact. Keys become snake_case, values are stored as strings (up to 200 characters), up to 40 keys.Example: { "city": "Pune", "orderValue": "2499" } |
| source | string | Optional | Where the contact came from, recorded when the contact is new. Up to 80 characters. Defaults to API campaign.Example: shopify |
| externalId | string | Optional | Your idempotency key for this send, up to 160 characters, unique per campaign. The same key never sends twice. Alias: idempotencyKey. See Idempotency.Example: order-1142-shipped |
| callbackData | string | Optional | Any string up to 512 characters. Stored with the delivery and echoed unchanged on every whatsapp.message.status callback, so you can route the status without a lookup.Example: order=ORD-1142 |
Request
curl https://app.agentive.co.in/api/v1/whatsapp/campaigns/cmf3k9d2a0001x7q2m4n8p1r6/send \
-H "Authorization: Bearer agcamp_..." \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"name": "Asha Verma",
"templateParams": ["Asha", "ORD-1142", "Friday, 12 September"],
"buttons": { "0": "orders/ORD-1142" },
"tags": ["customer", "shipped"],
"attributes": { "city": "Pune", "orderValue": "2499" },
"source": "shopify",
"externalId": "order-1142-shipped",
"callbackData": "order=ORD-1142"
}'Response (200)
{
"success": true,
"duplicate": false,
"status": "accepted",
"messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSNkY3QjE4RkY0RjA2NDU2QjAxAA==",
"deliveryId": "cmf3kd8y10004x7q2h1k3v9pz",
"contactId": "cmf1a2b3c0009x7q2d4e5f6g7",
"warnings": []
}Response fields
| Field | Type | Description |
|---|---|---|
| success | boolean | Always true on HTTP 200. Errors use the error shape instead. |
| duplicate | boolean | true when the externalId was already used and nothing new was sent. The rest of the response then describes the earlier delivery. |
| status | string | accepted for a fresh send. On a duplicate, the earlier delivery's current status: accepted, sent, delivered, read, replied, clicked, failed or skipped. |
| messageId | string | null | The WhatsApp message id ( wamid.…). The same id appears on every status callback for this message. |
| deliveryId | string | Agentive's id for this delivery. Shown on the campaign's Deliveries tab and on status callbacks. |
| contactId | string | The contact the message went to, created or matched by phone number. |
| warnings | string[] | Non-fatal notices, for example a tag that was trimmed or an override key the template does not use. Empty when there is nothing to say. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON, or the Content-Type is not application/json. |
| 400 | validation | A field has the wrong type or exceeds its limit. details lists the offending fields. |
| 400 | invalid_recipient | to is not a number the API can resolve: too short, 11 digits, or a country code without a full number. |
| 400 | missing_template_values | A body, header or button variable the campaign expects from the request was not sent. details.missing names the keys, for example body_3. |
| 400 | media_required | The template has a media header, the request has no media, and no header media is saved on the campaign. |
| 400 | whatsapp_not_connected | The agent's WhatsApp number is disconnected or its access has expired. |
| 401 | missing_token | No Authorization header, or one that is not Bearer <token>. |
| 401 | invalid_token | The token is unknown, was rotated, or belongs to a different campaign than the one in the URL. |
| 404 | campaign_not_live | The campaign is a draft, paused, deleted, or is not an API campaign. |
| 404 | template_not_found | The campaign's template was deleted or is no longer approved. |
| 409 | opted_out | The recipient has opted out of messages from this number. |
| 429 | rate_limited | More than 300 requests in a minute on this token. The Retry-After header says how many seconds to wait. |
| 429 | frequency_cap | The recipient has reached the marketing message limit for the last 24 hours. |
| 429 | quiet_hours | The request arrived inside the campaign's quiet hours. details.retryAfter is an ISO 8601 timestamp (IST offset) of when sending resumes. |
| 502 | meta_rejected | WhatsApp rejected the message. details.meta carries the platform's code and message. |
| 502 | send_failed | WhatsApp could not be reached, or timed out before accepting the message. |
A repeat of an externalId that already sent returns HTTP 200 with duplicate: true and the earlier delivery's current status. Nothing is sent again.
{
"success": true,
"duplicate": true,
"status": "delivered",
"messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSNkY3QjE4RkY0RjA2NDU2QjAxAA==",
"deliveryId": "cmf3kd8y10004x7q2h1k3v9pz",
"contactId": "cmf1a2b3c0009x7q2d4e5f6g7",
"warnings": []
}Legacy trigger endpoint
/api/chatbots/{chatbotId}/whatsapp/campaigns/{campaignId}/triggerLegacy, still supported. This path lives on https://app.agentive.co.in directly, not under the /api/v1 base, and needs the agent's chatbotId in the URL as well as the campaign id. Same headers, same body, same responses and errors as Send a message.
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| chatbotId | string | Required | The id of the agent that owns the campaign, from the agent's URL in the dashboard. Example: cmk2p4x1a0000x7q2b9c3d4e5 |
| campaignId | string | Required | The API campaign id, copied from the campaign page in the dashboard. The token in the Authorization header must belong to this campaign.Example: cmf3k9d2a0001x7q2m4n8p1r6 |
Request
curl https://app.agentive.co.in/api/chatbots/cmk2p4x1a0000x7q2b9c3d4e5/whatsapp/campaigns/cmf3k9d2a0001x7q2m4n8p1r6/trigger \
-H "Authorization: Bearer agcamp_..." \
-H "Content-Type: application/json" \
-d '{ "destination": "9876543210", "userName": "Asha Verma", "templateParams": ["Asha", "ORD-1142", "Friday, 12 September"] }'/whatsapp/campaigns/{campaignId}/send on the base URL: it is shorter, does not expose the agent id, and is the path this reference documents.Template variables
A WhatsApp template has numbered placeholders, {{1}} to {{n}}, in its body, sometimes in its header, and in any dynamic button. When you create the API campaign you decide, per variable, whether it has a fixed value or comes from the request. Only the request-mapped ones need to be sent.
Header: DOCUMENT
Body: Hi {{1}}, your order {{2}} has shipped and should reach you by {{3}}.
Footer: Reply STOP to opt out.
Buttons: [0] URL "Track order" https://shop.example.in/{{1}}
[1] QUICK_REPLY "Need help?"The request that fills every variable of that template:
{
"to": "+919876543210",
"templateParams": ["Asha", "ORD-1142", "Friday, 12 September"],
"media": {
"url": "https://cdn.example.in/invoices/ORD-1142.pdf",
"filename": "Invoice ORD-1142.pdf"
},
"buttons": { "0": "orders/ORD-1142", "1": "HELP_ORD-1142" }
}Body variables
templateParams is positional: templateParams[0] fills {{1}}, templateParams[1] fills {{2}}, and so on. Send exactly as many values as the campaign expects from the request; fewer fails with missing_template_values and details.missing names the gap, for example body_3. Values are strings; numbers are converted.
meta_rejected.Header
A text header with a variable takes headerText. An image, video or document header takes media, described under Media requirements. A header without a variable needs nothing.
{
"to": "+919876543210",
"headerText": "Order ORD-1142",
"templateParams": ["Asha", "Friday, 12 September"]
}Keyed overrides
values addresses every variable by an explicit key: body_N for body variable N, header_N for a text header variable, button_i_url_N for variable N of the URL button at index i. A key in values wins over templateParams, headerText and buttons. It exists for integrations that already build keyed maps; new code should use the positional fields.
{
"to": "+919876543210",
"values": {
"body_1": "Asha",
"body_2": "ORD-1142",
"body_3": "Friday, 12 September",
"header_1": "Order ORD-1142",
"button_0_url_1": "orders/ORD-1142"
}
}Media requirements
A template with an image, video or document header needs a file on every send. Give WhatsApp a public https URL it can fetch at send time, or the id of a file already uploaded to your number.
| Header type | Formats | Max size | Notes |
|---|---|---|---|
| IMAGE | JPEG, PNG | 5 MB | Static images only; no animation or transparency. |
| VIDEO | MP4 (H.264 video, AAC audio) | 16 MB | A single audio stream. |
| DOCUMENT | 100 MB | Send media.filename; the recipient sees it as the file name. |
// Image or video header: a public https URL.
{ "media": { "url": "https://cdn.example.in/offers/diwali-2026.jpg" } }
// Document header: add the filename the recipient sees.
{ "media": { "url": "https://cdn.example.in/invoices/ORD-1142.pdf", "filename": "Invoice ORD-1142.pdf" } }
// A media id you already uploaded to your WhatsApp number.
{ "media": { "id": "1234567890123456" } }- The URL must be
https, at most 2048 characters, and reachable without a login. A signed URL that stays valid for 10 minutes is fine; the file is fetched once, when the message is sent. - The response
Content-Typemust match the file (for exampleapplication/pdf); a mismatched type is rejected by WhatsApp asmeta_rejected. - If you saved a default file on the campaign in the dashboard, omit
mediaand that file is used. A media-header template with neither is a 400media_required. - The legacy key
values.header_media_urlstill works and means the same asmedia.url.
Idempotency
Set externalId (or the Idempotency-Key header) on every send that a retry could repeat. Pick it from your own system: the object plus the event, such as order-1142-shipped. Keys are scoped to the campaign and may be up to 160 characters.
- The same
externalIdnever sends twice. A repeat returns HTTP 200 withduplicate: trueand the earlier delivery's current status, whether that isaccepted,deliveredorfailed. - A send that WhatsApp rejects (
meta_rejectedorsend_failed) releases the key. Fix the problem and retry with the sameexternalId; the retry sends fresh and the failed attempt stays in the history. - A request rejected before the send (validation, missing values, wrong token, quiet hours) never consumes the key.
- Without an
externalId, every request sends a new message. Network retries then mean duplicate messages to the customer.
{
"success": true,
"duplicate": true,
"status": "delivered",
"messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSNkY3QjE4RkY0RjA2NDU2QjAxAA==",
"deliveryId": "cmf3kd8y10004x7q2h1k3v9pz",
"contactId": "cmf1a2b3c0009x7q2d4e5f6g7",
"warnings": []
}Status callbacks
Delivery status arrives on a webhook instead of polling. Add an endpoint under Agent > Webhooks, subscribe it to whatsapp.message.status, and copy its signing secret. One event is delivered for each status change of a message: sent, delivered, read, replied, clicked, failed.
Correlate on externalId, callbackData, or messageId from the send response. Statuses can arrive out of order (read before delivered when a phone comes back online), so treat the furthest state you have seen as current rather than the latest event.
Envelope
| Field | Type | Description |
|---|---|---|
| id | string | Unique per delivery attempt group ( evt_…). Retries reuse it, so store it to skip duplicates. |
| event | string | whatsapp.message.status for every event on this page. |
| apiVersion | string | The payload version of the Chat webhooks. |
| chatbotId | string | The agent that owns the campaign and the WhatsApp number. |
| chatbot | object | { id, name, orgId } of that agent. |
| timestamp | string | ISO 8601, UTC. When the event was created. |
| data | object | The event payload, described below. |
whatsapp.message.status
Fields in data
| Field | Type | Description |
|---|---|---|
| messageId | string | null | The WhatsApp message id from the send response. null when the message never reached WhatsApp. |
| deliveryId | string | The deliveryId from the send response. |
| campaignId | string | The campaign the message belongs to. |
| campaignName | string | The campaign's name in the dashboard. |
| campaignType | string | api for messages sent through this API. The same event fires for broadcast and ongoing campaigns, so filter on this or on campaignId. |
| to | string | The recipient as digits with the country code, no plus sign. Example: 919876543210 |
| status | string | sent, delivered, read, replied, clicked or failed. |
| externalId | string | null | The externalId you sent, or null. |
| callbackData | string | null | The callbackData you sent, echoed unchanged, or null. |
| timestamp | string | ISO 8601, UTC. When this status was recorded. |
| error | object | Only on failed: { code, title } with the WhatsApp error code (a number, or null for a transport failure) and a short title. |
| pricing | object | Present once WhatsApp reports it: { category, billable } with the conversation category (marketing, utility or authentication) and whether the message was billed. |
{
"id": "evt_5f1c9a2b7d3e4f60a1b2c3d4",
"event": "whatsapp.message.status",
"apiVersion": "2026-05-08",
"chatbotId": "cmk2p4x1a0000x7q2b9c3d4e5",
"chatbot": {
"id": "cmk2p4x1a0000x7q2b9c3d4e5",
"name": "Orders assistant",
"orgId": "cmk2p3w9z0000x7q2a1b2c3d4"
},
"timestamp": "2026-09-05T10:42:17.913Z",
"data": {
"messageId": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgSNkY3QjE4RkY0RjA2NDU2QjAxAA==",
"deliveryId": "cmf3kd8y10004x7q2h1k3v9pz",
"campaignId": "cmf3k9d2a0001x7q2m4n8p1r6",
"campaignName": "Order shipped (API)",
"campaignType": "api",
"to": "919876543210",
"status": "delivered",
"externalId": "order-1142-shipped",
"callbackData": "order=ORD-1142",
"timestamp": "2026-09-05T10:42:17.913Z",
"pricing": { "category": "utility", "billable": true }
}
}Failed message: data
"data": {
"messageId": null,
"deliveryId": "cmf3kd8y10004x7q2h1k3v9pz",
"campaignId": "cmf3k9d2a0001x7q2m4n8p1r6",
"campaignName": "Order shipped (API)",
"campaignType": "api",
"to": "919876543210",
"status": "failed",
"externalId": "order-1142-shipped",
"callbackData": "order=ORD-1142",
"timestamp": "2026-09-05T10:42:19.204Z",
"error": { "code": 131026, "title": "Message undeliverable" }
}Delivery headers
| Header | Description |
|---|---|
| Content-Type | application/json |
| X-Agentive-Signature | HMAC-SHA256 of the raw body with your webhook secret, hex encoded. No prefix. |
| X-Agentive-Event | The event name, for routing before you parse the body. |
| X-Agentive-Delivery | The same id as the envelope's id field. Despite the name it is the event id, not a per-endpoint delivery id. The Voice APIs send the same value as X-Agentive-Event-Id. |
| X-Agentive-Timestamp | The same timestamp as the envelope. |
| User-Agent | Agentive-Webhooks/<apiVersion>. |
| X-Agentive-Delivery-Id | Not sent here. On the Voice APIs this is a separate per-endpoint delivery id that is stable across retries; on this product, retries of one event carry the same X-Agentive-Delivery instead. |
| X-Agentive-Attempt | Not sent here. There is no attempt counter on this product; the three attempts of one event are indistinguishable from each other. |
Reply with any 2xx within 10 seconds. A non-2xx reply or a timeout is retried twice more, after 2 seconds and after 8 seconds, with the same id. Do your processing after you have responded if it is slow.
Verify the signature
Verify every delivery before you trust it. The signature is the HMAC-SHA256 of the exact raw request body with the endpoint's signing secret, hex encoded, sent as is:
X-Agentive-Signature: 4f1d2c9b8a7e6f5d4c3b2a1908f7e6d5c4b3a291807f6e5d4c3b2a19080706e9Read the raw bytes before any JSON parsing, compute the digest, and compare in constant time:
import express from "express";
import crypto from "node:crypto";
const app = express();
// Read the RAW body: the signature covers the exact bytes we sent.
app.post(
"/webhooks/agentive",
express.raw({ type: "application/json" }),
(req, res) => {
const secret = process.env.AGENTIVE_WEBHOOK_SECRET;
if (!secret) return res.status(500).send("signing secret not set"); // never verify with an empty key
const given = Buffer.from(req.header("X-Agentive-Signature") ?? "", "utf8");
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(req.body).digest("hex"),
"utf8"
);
// Compare byte lengths first: timingSafeEqual throws on unequal lengths.
const ok =
given.length === expected.length && crypto.timingSafeEqual(given, expected);
if (!ok) return res.status(401).send("bad signature");
const evt = JSON.parse(req.body.toString("utf8"));
// Deliveries retry, so skip an evt.id you have already processed.
if (evt.event === "whatsapp.message.status") {
const { status, externalId, callbackData, error } = evt.data;
console.log(externalId, status, callbackData, error?.title);
}
res.sendStatus(200);
}
);
app.listen(3000);hash_hmac("sha256", $rawBody, $secret) in PHP or hmac.new(secret, raw, hashlib.sha256).hexdigest() in Python, and compare with hash_equals or hmac.compare_digest. Refuse to run with an empty secret. Do not copy the Telephony Webhooks handlers as they are: the Voice APIs sign a timestamp together with the body, and this product signs the body alone.X-Agentive-Timestamp is not signed, so a delivery someone captured would still verify if sent again later. Keep a permanent record of every event id you have handled and ignore repeats, and accept deliveries over HTTPS only, so they cannot be read off the network in the first place.Quick start
From an approved template to a delivered message.
- Create an API campaignIn the Agentive Chat dashboard open your agent, then WhatsApp > Campaigns > New campaign > API campaign. Pick an approved template, choose which variables come from the request (and fix the rest), and go live.
- Copy the tokenThe campaign page shows the token once. Store it as an environment variable on your server, for example
AGENTIVE_CAMPAIGN_TOKEN, together with the campaign id from the same page. Lost it? Rotate from the campaign page and copy the new one. - Send your first messageReplace the placeholders and send to your own number. Send one
templateParamsentry per request-mapped body variable.cURLcurl https://app.agentive.co.in/api/v1/whatsapp/campaigns/<campaignId>/send \ -H "Authorization: Bearer <agcamp_token>" \ -H "Content-Type: application/json" \ -d '{ "to": "+91XXXXXXXXXX", "templateParams": ["Asha"], "externalId": "first-test-1" }' - Verify deliveryThe response carries
status: "accepted"and amessageId. Open the campaign's Deliveries tab to watch it move through sent, delivered and read, or subscribe a webhook towhatsapp.message.statusto receive the same states in your own system.
Before you go live
- Set
externalIdon every send a retry could repeat. - Honour
Retry-Afteronrate_limitedanddetails.retryAfteronquiet_hours. - Never retry
opted_outorfrequency_cap; nothing was sent and the contact is protected. - Verify
X-Agentive-Signatureon every callback and skip anidyou have already processed. - Keep the campaign token on the server; rotate it if it ever leaves.
Migrating from another WhatsApp provider
The request is deliberately close to what the popular Indian WhatsApp APIs accept, so most integrations move by changing the URL, the auth header and a few field names. The aliases destination, userName and idempotencyKey are accepted as is.
Tools with an account key and a flat body
| Typical field in other tools | Agentive field | Note |
|---|---|---|
apiKey | Authorization: Bearer <token> | A token per campaign instead of one account key. |
campaignName | campaignId in the URL | The campaign is addressed by id, not by name in the body. |
destination | to | destination still works as an alias. |
userName | name | userName still works as an alias. |
templateParams | templateParams | |
media.url | media.url | Add media.filename for documents. |
buttons | buttons | Keyed by button index, or a positional array. |
attributes | attributes | Keys become snake_case. |
tags | tags | |
source | source |
Tools with a split number and value arrays
| Typical field in other tools | Agentive field | Note |
|---|---|---|
countryCode + phoneNumber | to | One field: +919876543210, or the 10 digits. |
bodyValues | templateParams | |
headerValues[0] | media.url or headerText | media.url for a media header, headerText for a text header. |
buttonValues | buttons | |
callbackData | callbackData | Echoed on every status callback. |
traits | attributes | Keys become snake_case. |
template.name + languageCode | not needed | The campaign fixes the template and language. |
- One token per campaign, sent as
Authorization: Bearer, instead of an account-wide key in the body. - The campaign is addressed by id in the URL. The template and its language are fixed on the campaign, so they are not in the body.
- The response is
{ success, duplicate, status, messageId, deliveryId, contactId, warnings }, and errors always carry a machinecode. - Delivery status comes on the signed
whatsapp.message.statuswebhook, with yourcallbackDataechoed.
When Agentive Voice is connected
Connecting your Agentive Voice account to Agentive Chat adds call events as triggers for Ongoing WhatsApp campaigns, so a missed call, a finished call, a voicemail or a callback request can send an approved template on its own. Everything is set up from the Voice panel, and none of it needs code.
How to connect
- Open Integrations in the Voice panelSign in to your Agentive Voice dashboard and open Integrations.
- Choose Agentive ChatSign in once with your Agentive Chat admin email and password. The password is used to verify the account and is not stored.
- Check that call events are linkedThey are linked as part of connecting. If the Integrations page shows Call events not linked, use Link call events to retry.
- Create an Ongoing campaignIn WhatsApp > Campaigns create an Ongoing campaign, pick a Voice event as its trigger, add conditions if you want to narrow it, and map the template variables to the event data below.
Voice trigger events
Eight events appear in the Ongoing trigger list once Voice is connected. One call fires both the any-direction event and its directional twin, so subscribe at the grain you want rather than at both, or the customer receives two messages for one call.
| Event key | Name | When it fires | Condition fields | Variables |
|---|---|---|---|---|
| voice.call.missed | Missed Call (any direction) | Any unanswered call, inbound or outbound. Covers both a customer you missed and a customer who did not pick up. |
|
|
| voice.call.missed.inbound | Inbound Missed Call | A customer called in and nobody answered. The classic instant follow-up on a missed call. |
|
|
| voice.call.missed.outbound | Outbound No-Answer | You called a customer and they did not pick up. Follow up with a message saying you tried to reach them. |
|
|
| voice.call.completed | Call Completed (any direction) | Any answered call that has ended, inbound or outbound. |
|
|
| voice.call.completed.inbound | Inbound Call Completed | A customer call was answered and has ended. Send a summary, a feedback ask or the next steps. |
|
|
| voice.call.completed.outbound | Outbound Call Completed | An outbound call you placed connected and has ended. Send a recap or what happens next. |
|
|
| voice.voicemail.left | Voicemail Left | A customer left a voicemail after not reaching anyone. Acknowledge it and promise a callback. |
|
|
| voice.callback.requested | Callback Requested | A call was marked for a callback, or the customer asked to be called back. Confirm the time over WhatsApp. |
|
|
Conditions are an AND list over the event data, so endReason is no_answer together with durationSec of at least 10 fires only for a call that rang for ten seconds and was never picked up.
Event data available to conditions and variables
The table above lists the conditions and the template variables each event offers, and both are chosen from those lists in the campaign builder. The fields below are the call data those choices resolve against, so you can see what a variable such as trigger.callerNumber will put in the message. A variable mapped to a field the event does not carry stops the send, as described below.
Event data
| Field | Type | Description |
|---|---|---|
| phone | string | The customer's number, and what the campaign matches a contact on. It is the far end of the call: the caller on an inbound call, the number you dialled on an outbound one. Example: +919876543210 |
| callerNumber | string | The same number, exposed for templates as trigger.callerNumber.Example: +919876543210 |
| direction | string | inbound when the customer called you, outbound when you called the customer.Example: inbound |
| durationSec | integer | Seconds. Talk time on an answered call, ring time on an unanswered one, and the length of the message on a voicemail. Example: 120 |
| endReason | string | Why an unanswered call ended, normalised to one of no_answer, busy, rejected, canceled, failed, voicemail. On the missed and voicemail events only.Example: no_answer |
| agentName | string | The person or line that handled the call. Blank when it cannot be determined, and a template variable mapped to it then fails the send rather than guessing a name, so map it only when the line is known. Example: Priya |
| callId | string | The call's unique id, the same one the call carries in the Voice panel. Useful for tying a message back to a call in your own reporting. Example: 9f2c1d6a-3e4f-40b9-8d7c-2a1e5f6b8c0d |
| callType | string | What kind of call it was, when the Voice account reports one, for example voicemail.Example: voicemail |
| campaignId | string | The outbound calling campaign the call belonged to, when it came from one. Blank for an organic call. Example: 482 |
| campaignType | string | The kind of that calling campaign, carried in the event data alongside the campaign id. Example: human_pool |
| callTime | string | When the call happened. ISO 8601 timestamp; the Voice and Chat panels show the same moment in IST. Example: 2026-09-05T09:41:12.000Z |
| missedAt | string | When the call was missed, or when the voicemail was left. ISO 8601 timestamp; the Voice and Chat panels show the same moment in IST. On the missed and voicemail events only. Example: 2026-09-05T09:41:12.000Z |
| callbackAt | string | The callback time that was asked for. ISO 8601 timestamp; the Voice and Chat panels show the same moment in IST. On Callback Requested only, and blank when no time was set. Example: 2026-09-06T05:30:00.000Z |
Delay, cooldown and quiet hours
A Voice-triggered campaign obeys the same controls as every other Ongoing campaign. A delay holds the message for a set time after the call, which is useful when someone may call the customer straight back. A cooldown limits one contact to a single message from that campaign per set number of days, so a customer who rings three times in a morning is messaged once.
Quiet hours and the marketing frequency cap apply as they do to a broadcast, and both cover marketing templates only: utility and authentication templates are never held. A marketing message that lands inside quiet hours is held and sent when the window opens; a contact who has already reached the 24 hour marketing cap is skipped for that event. See Rate limits for what each one means.
Next steps
The other Agentive references, on the Voice app.