Voice Broadcast API
Voice Broadcast API
Place recorded and Interactive Voice calls, trigger saved campaigns, and read call results from your own systems over a JSON REST API.
- Base URL
- https://voice.agentive.co.in/api/public/v1
- Format
- JSON over HTTPS
- Auth
- x-api-key + x-api-secret
Introduction
The Voice Broadcast API gives your application programmatic access to the same calling stack that powers the dashboard. Place a single recorded or Interactive Voice call, run a saved campaign for one number, poll the result of any call you started, page through your call history, and download recordings.
Every amount is in Indian Rupees (INR). Every timestamp is in Indian Standard Time (IST), in ISO 8601 format. Every request is authenticated with a publishable key and a secret, and every response uses one consistent JSON envelope so your integration code stays simple.
When to use it
- Send an order confirmation, a delivery alert or a payment reminder as a recorded voice call straight from your backend.
- Run an Interactive Voice call (press 1) and act on the key the recipient presses, or bridge them to your team.
- Trigger a campaign configured in the dashboard for one number at a time, from a CRM, a form submit or a workflow tool.
- Reconcile results: poll a call, list calls in bulk, download the recording, and check your balance before a large run.
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
Every endpoint path on this page is relative to this base URL. The Voice OTP API uses the same base.
https://voice.agentive.co.in/api/public/v1Authentication
Every request must be authenticated with two credentials issued to your account.
| Credential | What it is | How to send it |
|---|---|---|
| Publishable key | Names your account on every request. Starts with pk_live_. Despite the name, treat it as confidential and keep it with the secret. | x-api-key request header |
| Secret key | Your account's private proof. Starts with sk_live_. Treat it like a password. | x-api-secret request header (preferred), or a secret field in the JSON body |
View and rotate both keys from the API tab in your Voice dashboard. The secret is shown in full exactly once, when it is first generated or rotated. It is stored hashed on our side, so it can never be shown again, by you or by us. If you lose it, rotate to generate a new pair, and the old secret stops working immediately.
The header form is recommended because it keeps the secret out of request bodies and most log formats. If you send the secret in both the header and the body, the header value is used. There is a single set of live credentials per account; both keys begin with a _live_ prefix and every call is processed against your live account.
Example: authenticated request
curl https://voice.agentive.co.in/api/public/v1/calls \
-H "x-api-key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "x-api-secret: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "type": "audio_blast", "number": "9812345678", "campaign_id": 4821 }'HTTP Basic authentication
As an alternative to the two headers, you may send the credentials with HTTP Basic auth, using the publishable key as the username and the secret as the password. This is the familiar curl -u form. When an x-api-key header is present it takes precedence; otherwise the Basic credentials are used.
curl https://voice.agentive.co.in/api/public/v1/account \
-u pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAuthentication failures
| Status | Code | error_code | When |
|---|---|---|---|
| 401 | 401 | missing_credentials | No publishable key sent.details: "Missing API key. Send your publishable key in the x-api-key header." |
| 401 | 401 | invalid_credentials | Unknown publishable key.details: "Invalid API credentials." |
| 401 | 401 | invalid_credentials | Known key, wrong or missing secret.details: "Invalid API credentials." |
| 403 | 403 | account_inactive | Account is not active.details: "This account is not active. Please contact support." |
| 403 | 403 | api_disabled | API access not enabled for the account.details: "The API is not enabled for this account. Please contact support." |
| 403 | 403 | feature_disabled | The endpoint's product is not enabled for the account.details: "This feature is not enabled for your account." |
The wording and the status are intentionally the same for an unknown key and a wrong secret, so a caller cannot learn whether a key exists by watching which code comes back. 401 always means the credentials were rejected; 403 always means the credentials were accepted and the account or the product is not entitled.
Response format
Every response is JSON and uses one consistent envelope.
{
"status": "success",
"code": 200,
"unique_id": "run_4821",
"reference_id": "order-99213",
"details": "Call queued."
}Envelope fields
| Field | Type | Description |
|---|---|---|
| status | string | "success" or "error". |
| code | number | A numeric result code. Mirrors the HTTP status (see Error codes). |
| unique_id | string | null | Our identifier for the resource created by this call. Use it with Get call status. |
| reference_id | string | null | Echoes the reference_id you sent, so you can correlate the response with your own records. null if you did not send one. |
| details | string | A short human-readable description of the result. |
| error_code | string | Error responses only. A stable machine string naming the exact condition. Branch on this rather than on details. See Error codes. |
| error | string | Error responses only. The same sentence as details, under the key the WhatsApp Business API uses, so one handler can read both products. |
Successful responses may include extra fields alongside the envelope. These are documented per endpoint (for example call_status and duration_sec on Get call status, and data plus pagination on the list endpoints).
status: "success". A failed request returns the matching HTTP error status (400, 401, 402, 403, 404, 409, 422, 429, or 5xx) with status: "error", and the same value in the code field.Error codes
Every error carries three things: the HTTP status, the numeric code that mirrors it, and a string error_code naming the exact condition. Branch on error_code. The numeric code is kept for compatibility and the sentence in details (repeated in error) is for your logs, never for a customer.
| HTTP | Meaning | When it happens |
|---|---|---|
| 200 | Success | The request was accepted. |
| 400 | Bad request | A required field is missing or invalid (a malformed number, an unknown type, a missing campaign_id, a campaign with no recording, or a supplied code that is not 4 to 8 digits). |
| 401 | Unauthorized | The credentials were rejected: no key, an unknown key, or a wrong secret. On POST /otp/verify it also reports an incorrect code. |
| 402 | Balance too low | Your balance cannot cover the call. Nothing was created. Top up and retry. |
| 403 | Forbidden | The credentials were accepted but the account is not active, API access is not enabled, the endpoint's product is not enabled, or a trial may not call this number. |
| 404 | Not found | The campaign, call or verification does not exist for your account, or the call has no recording. A foreign id is a 404, never a 403. |
| 409 | Conflict | Outside calling hours, or a request with the same Idempotency-Key is still in flight, or the account's AI channels are all busy. Every 409 carries Retry-After. |
| 422 | Idempotency key reuse | The same Idempotency-Key was sent with a different request body. A new request needs a new key. |
| 429 | Too many requests | A limit was hit: your request budget, a per-number cap, a daily cap, or your account's lines. Every 429 carries Retry-After. |
| 502 | Upstream problem | We could not place the call. If we do not know whether it went out, error_code is call_state_unknown. |
| 503 | Temporarily unavailable | Briefly unavailable on our side. Safe to retry after Retry-After. |
error_code values
A closed set. New values are added only with a note in the changelog, so a client may treat an unknown one as a generic failure of its HTTP class.
| error_code | HTTP | Means |
|---|---|---|
| missing_credentials | 401 | No publishable key was sent. |
| invalid_credentials | 401 | The key is unknown, or the secret does not match it. |
| account_inactive | 403 | The account is not active. |
| api_disabled | 403 | API access is not enabled for the account. |
| feature_disabled | 403 | The product this endpoint belongs to is not enabled. |
| trial_restricted | 403 | A trial account may only call its registered number. |
| validation_error | 400, 422 | A field is missing, unknown or unusable. |
| invalid_number | 400 | The destination is not a valid Indian mobile number. |
| invalid_caller_id | 400 | caller_id is not yours, or is not enabled for voice broadcast. |
| invalid_code | 400 | A supplied verification code is not 4 to 8 digits. |
| calling_hours | 409 | Outside the 9:00 AM to 9:00 PM IST window. |
| idempotency_in_progress | 409 | The first request with this key is still running. |
| idempotency_conflict | 422 | This key was used with a different body. |
| rate_limited | 429 | Your per-minute request budget is spent. |
| number_flood | 429 | Too many calls to one destination number. |
| daily_cap | 429 | POST /ai/calls only (AI Voice Agent API): the calling pool numbers you call from reached their daily limit. Placing a call here never answers it. |
| concurrency_limit | 429 | Your account has no free line. |
| ai_concurrency_limit | 409 | Your AI call channels are all busy. |
| whatsapp_locked | 403 | The campaign sends a WhatsApp message and WhatsApp is not on your plan. |
| whatsapp_not_connected | 409 | No WhatsApp account is connected to this account yet. |
| whatsapp_template_required | 400 | The campaign has no WhatsApp account and approved template saved on it. |
| number_locked | 429 | Too many wrong codes for this number; send and verify are locked for it. |
| insufficient_balance | 402 | Your balance cannot cover the call. |
| not_found | 404 | No such resource on your account. |
| conflict | 409 | A state conflict with no more specific name. Read details and Retry-After. |
| upstream_error | 502 | We could not place the call, and it was not placed. |
| call_state_unknown | 502 | We asked the network to place the call and lost track of it. |
| service_unavailable | 503 | Briefly unavailable on our side, or your calling-hours setting could not be read (retry_after_sec 30); nothing was placed. |
Five more appear only on POST /otp/verify: incorrect_code, max_attempts, code_expired, code_used and verification_not_found.
The split between 429 and 5xx matters for retry logic. A 429 means a limit was hit, so wait for Retry-After. A 502 or 503 means a problem on our side, so a retry after a short pause is right, with one exception: call_state_unknown means we do not know whether the call went out, so poll before you retry. Error responses never include internal infrastructure detail.
Example error response
{
"status": "error",
"code": 400,
"unique_id": null,
"reference_id": "order-99213",
"details": "A valid Indian mobile number is required.",
"error_code": "invalid_number",
"error": "A valid Indian mobile number is required."
}Rate limits
Six limits protect the shared calling network. Each one refuses with HTTP 429, names itself in error_code, and says how long to wait in both the Retry-After header and the body's retry_after_sec.
| Limit | Window | Scope | error_code | Retry-After |
|---|---|---|---|---|
| 240 failed requests | 1 minute | Client address | rate_limited | Up to 60 seconds. Only requests refused for their credentials or account (401 or 403) count, so a working integration never meets it. |
| 20 failed credential checks | 1 minute | Publishable key and client address | rate_limited | Up to 60 seconds. Guards a key against someone guessing its secret. A request with the correct secret is always let through, so nobody can lock your integration out. |
| 120 read requests | 1 minute | Account | rate_limited | Up to 60 seconds. |
| 120 write requests | 1 minute | Account | rate_limited | Up to 60 seconds. Counted separately from reads, so a polling loop can never starve call placement. |
| 3 calls to one number | 10 minutes | Account and destination number | number_flood | Up to 600 seconds. |
| Your lines | While calls are live | Account | concurrency_limit | 5 seconds. |
The RateLimit-* headers on a response describe the per-account bucket for that request's method: a GET reports the read bucket, a POST the write bucket. The X-RateLimit-* headers carry the same three values under the older names, so use whichever your client already reads.
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 41
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 41When you exceed a request limit you receive HTTP 429 with:
{
"status": "error",
"code": 429,
"unique_id": null,
"reference_id": null,
"details": "Too many requests. Please slow down and retry shortly.",
"error": "Too many requests. Please slow down and retry shortly.",
"error_code": "rate_limited",
"retry_after_sec": 60
}Your lines
A line is one call your account can have live at the same time. It is the number you bought, and everything on the account draws from it: inbound calls, calls your team makes, campaign dials, AI calls and every call you place through this API. Read it any time from Get account balance, which returns lines and lines_in_use. Responses to call-placing requests (POST /calls, POST /campaigns/:id/trigger and POST /otp/send) also carry X-Agentive-Lines and X-Agentive-Lines-In-Use, whether the call was admitted or refused, so a run can pace itself without an extra request. The read endpoints do not send them.
A call your account has no free line for is refused, never queued: HTTP 429 with error_code: "concurrency_limit", lines, lines_in_use and a Retry-After of 5 seconds. That way you always know what happened, instead of waiting on a call that was silently parked.
X-Agentive-Lines: 10
X-Agentive-Lines-In-Use: 2HTTP/1.1 429 Too Many Requests
Retry-After: 5
X-Agentive-Lines: 10
X-Agentive-Lines-In-Use: 10
{
"status": "error",
"code": 429,
"unique_id": null,
"reference_id": "order-99213",
"details": "All 10 lines on this account are in use. Retry in a few seconds.",
"error": "All 10 lines on this account are in use. Retry in a few seconds.",
"error_code": "concurrency_limit",
"lines": 10,
"lines_in_use": 10,
"retry_after_sec": 5
}queued has been accepted and is waiting only for the network to place it. It is never waiting for a free line, because a request with no free line is refused before anything is created. So queued always means the call is on its way.AI voice agent calls carry a second, narrower cap inside your lines, reported with HTTP 409 and error_code: "ai_concurrency_limit". See the AI Voice Agent API.
error_code: "number_flood". A 429 always means wait; only 502 and 503 are safe to retry straight away.POST /calls with type: "otp" is the same request as Send a code and is held to the verification limits as well: at least 30 seconds between two sends to one number, at most 10 in 24 hours, and a number lock after 10 wrong codes in an hour. They are listed in full on the Voice OTP page.Endpoints
The write endpoints place a call and trigger a saved campaign. The read endpoints fetch a single call's status, list your calls, download a recording, list your campaigns, and report your account balance.
| Endpoint | Purpose |
|---|---|
| POST /calls | Place a recorded, Interactive Voice or verification call to one number. |
| POST /campaigns/:id/trigger | Run a saved campaign for one number. |
| GET /calls/:unique_id | Poll the status of a call you placed. |
| GET /calls | Page through your call history with filters. |
| GET /calls/:unique_id/recording | Download a call recording. |
| GET /campaigns | List the campaigns you can trigger. |
| GET /account | Read your wallet balance in INR. |
Place a call
/callsPlace a single outbound call immediately. The type field selects what happens on the call: audio_blast plays a clip from your audio library and ends, press1 plays a clip and then acts on the key the recipient presses, and otp reads a verification code out to the recipient. The response is the standard envelope with unique_id set to the queued call's id.
Headers
| Header | Type | Required | Description |
|---|---|---|---|
| x-api-key | string | Required | Your publishable key ( pk_live_...). See Authentication. |
| x-api-secret | string | Required | Your secret key ( sk_live_...). Keep it server-side. |
| Content-Type | string | Required | Always application/json. |
| Idempotency-Key | string | Optional | A key unique to this logical request, 1 to 128 visible ASCII characters. A longer key is refused with HTTP 400. Send one on every request so a network retry can never place a second billed call. See Idempotency. Example: order-99213-call-1 |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | Required | Example: audio_blast |
| number | string | Required | The recipient's Indian mobile number. See Phone number format. Example: 9812345678 |
| campaign_id | number | Required | Which campaign this call is for. Required for audio_blast and press1, and it must be a campaign of that same type on your account. The campaign holds the recording, the caller number, what each keypress does and every other setting; your request supplies the number to call. Optional for otp, where omitting it uses your account's authentication campaign.Example: 412 |
| code | string | Optional | otp only. Supply your own code: 4 to 8 digits, nothing else. Anything shorter, longer or non-numeric is refused with HTTP 400 and invalid_code. If you omit it, a code is generated for you. |
| length | number | Optional | otp only. Length of the generated code. Default 4; send 6 for anything that guards money or an account. Values outside 4 to 8 are clamped into range, not refused.Example: 6 |
| ttl | number | Optional | otp only. Seconds the code stays valid. Default 600 (10 minutes). Values outside 60 to 1800 are clamped into range. |
| max_attempts | number | Optional | otp only. Verify attempts allowed for this code. Default 3. Values outside 1 to 10 are clamped into range. |
| message | string | Optional | otp only. A custom spoken message. Use {code} where the code should be read out. |
| reference_id | string | Optional | Your own correlation key. Echoed back and included in webhooks. See Reference IDs. Example: order-99213 |
Request
curl https://voice.agentive.co.in/api/public/v1/calls \
-H "x-api-key: pk_live_..." \
-H "x-api-secret: sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-99213-call-1" \
-d '{
"type": "audio_blast",
"number": "9812345678",
"campaign_id": 4821,
"reference_id": "order-99213"
}'Response (audio_blast and press1)
{
"status": "success",
"code": 200,
"unique_id": "run_4821",
"reference_id": "order-99213",
"details": "Call queued."
}Errors
| Status | Code | error_code | When | What to do |
|---|---|---|---|---|
| 400 | 400 | invalid_number | number is missing, malformed, or not an Indian mobile number.details: "A valid Indian mobile number is required." | Send a 10-digit mobile number beginning with 6, 7, 8 or 9. See Phone number format. |
| 400 | 400 | validation_error | type is unknown, campaign_id is missing on a audio_blast or press1 call, the campaign has no recording yet, or the request tries to set something the campaign owns. | Use one of the three call types and name a campaign of that type. Change the recording, the caller number or what a keypress does on the campaign itself. |
| 404 | 404 | not_found | The campaign_id is not on your account, or the campaign has been deleted. Deleting a campaign stops every call for it, on this endpoint and on the trigger endpoint alike.details: "Campaign not found." | Check the id in your dashboard. If you deleted the campaign, create it again and use the new id. |
| 400 | 400 | validation_error | The campaign is switched off. Pausing or stopping a campaign stops every call for it. A campaign sitting in draft or completed is normal for API use and keeps working.details: "That campaign is paused. Start it again in your dashboard before placing calls for it." or the same wording for a stopped campaign. | Start the campaign again in your dashboard, then resend. |
| 400 | 400 | validation_error | The campaign's recording has been deleted since it was set up. We refuse rather than accept a call that would go out silent.details: "The recording this campaign plays is no longer in your audio library." | Add a recording to the campaign, then place the call. |
| 400 | 400 | invalid_caller_id | What the campaign picked has stopped being usable since it was set up. We refuse rather than call from some other number without telling you.details: "The number this campaign calls from is no longer available for voice broadcast on your account." or "The rotational pool this campaign uses is not available on your account any more." | Open the campaign, pick a caller number or a rotational pool again, and resend. |
| 400 | 400 | invalid_code | type: "otp" with a code that is not 4 to 8 digits. | Send 4 to 8 digits, or omit code and let one be generated. |
| 401 | 401 | invalid_credentials | The publishable key is unknown, or the secret does not match it.details: "Invalid API credentials." | Check both headers. Before September 2026 a known key with a wrong secret returned 403; it now returns 401 like every other credential failure. |
| 402 | 402 | insufficient_balance | Your balance is below the minimum needed to place a call. Checked before anything is created, so nothing was queued and nothing was charged. The body carries balance_inr and minimum_balance_inr. From September 2026 that minimum is this call's own per-call rate on your account rather than a flat figure, so the pre-flight refuses only what the dialler itself would have refused a moment later. | Top up and retry with the same Idempotency-Key. |
| 403 | 403 | feature_disabled | Voice broadcast is not enabled for your account.details: "This feature is not enabled for your account." | Contact support to enable it. |
| 403 | 403 | trial_restricted | The account is on a trial and the number is not its registered mobile. | Complete business verification, or call the registered number. |
| 409 | 409 | calling_hours | The request arrived outside calling hours. Verification calls are exempt. | Queue the call and retry from 9:00 AM IST. Retry-After says how long that is. |
| 409 | 409 | idempotency_in_progress | A request with the same Idempotency-Key is still being processed. | Wait for Retry-After (2 seconds) and retry with the same key. |
| 422 | 422 | idempotency_conflict | The same Idempotency-Key was sent with a different request body. | Use a fresh key for a new request. |
| 429 | 429 | rate_limited | You went past your account's per-minute request budget. | Retry-After is up to 60 seconds. Pace with the RateLimit headers. |
| 429 | 429 | number_flood | More than 3 calls to this destination number in 10 minutes. | Retry-After is up to 600 seconds. |
| 429 | 429 | concurrency_limit | Your account has no free line for this call. The body carries lines and lines_in_use. | Retry-After is 5 seconds. Watch X-Agentive-Lines-In-Use to pace a run. |
| 502 | 502 | upstream_error | The calling network refused the request outright.details: "Could not place the call right now. Please retry." | Retry after a short pause with the same Idempotency-Key. If that reply comes back as call_state_unknown, stop and follow that row instead: a 502 is never proof on its own that nothing was dialled. |
| 502 | 502 | call_state_unknown | We asked the network to place the call and then lost track of it, so we cannot say whether it went out. The first reply carries the sentence quoted here; replaying the same Idempotency-Key returns it with a second sentence appended: "The call may or may not have been placed. Check the call with GET /calls/{unique_id} before retrying, or retry with a new Idempotency-Key."details: "Could not place the call right now. Please retry." | Do not blind-retry. Poll Get call status with the unique_id, or choose a fresh key deliberately. |
| 503 | 503 | service_unavailable | The service is briefly unavailable, or your account's calling-hours setting could not be read, so the calling window could not be checked. In that case retry_after_sec is 30. Nothing was placed. | Retry after Retry-After with the same Idempotency-Key. |
Call types
| type | What it does | Required fields |
|---|---|---|
| audio_blast | Plays the campaign's recording to the number, then ends. | number, campaign_id |
| press1 | Places an Interactive Voice call: plays the campaign's recording, then acts on the key the recipient presses (connect them onward, capture their response, record an opt-out, or send an approved WhatsApp template). Everything it does, including spoken yes/no responses and guided flow steps, is whatever the campaign is set up to do. | number, campaign_id |
| otp | Places a verification call that reads out a code. A shortcut to the same call the Voice OTP API sends. | number |
Interactive Voice (press1) example body
{
"type": "press1",
"number": "9812345678",
"campaign_id": 4822,
"reference_id": "promo-march"
}campaign_id; your request supplies the number to call. That is also why those calls show up under the campaign in your dashboard, alongside everything else it has sent.For the otp type the response carries request_id and expires_in_sec and is identical to Send a code in the Voice OTP API, which also documents how to verify the code the recipient enters.
Idempotency-Key header. Send one on every request so a network retry can never place a second billed call. See Idempotency.Trigger a saved campaign
/campaigns/:id/triggerRun an existing API campaign for a single number. Identical in effect to POST /calls with a campaign_id; use whichever shape suits your client. The trigger uses the campaign exactly as configured in your dashboard, including its audio clips, response mode (keypad, keypad plus spoken yes/no, or spoken only), guided flow steps, voicemail detection, ring timeout, custom response labels, key map and wait times, and caller number. Audio Blast, Interactive Voice and AI voice agent campaigns can be triggered this way; the response is the usual run_... id. A campaign whose response action is WhatsApp sends its template after the call; the trigger is refused when WhatsApp is locked or not connected on the account (see the errors below). Verification-code campaigns are sent with the Voice OTP API instead.
Headers
| Header | Type | Required | Description |
|---|---|---|---|
| x-api-key | string | Required | Your publishable key ( pk_live_...). See Authentication. |
| x-api-secret | string | Required | Your secret key ( sk_live_...). Keep it server-side. |
| Content-Type | string | Required | Always application/json. |
| Idempotency-Key | string | Optional | A key unique to this logical request, 1 to 128 visible ASCII characters. A longer key is refused with HTTP 400. Send one on every request so a network retry can never place a second billed call. See Idempotency. Example: order-99213-call-1 |
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| id | number | Required | The numeric id of the campaign to trigger. Find it with List campaigns. Example: 57 |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| number | string | Required | The recipient's Indian mobile number. See Phone number format. Example: 9812345678 |
| reference_id | string | Optional | Your own correlation key. Echoed back and included in webhooks. Example: lead-5567 |
Request
curl https://voice.agentive.co.in/api/public/v1/campaigns/57/trigger \
-H "x-api-key: pk_live_..." \
-H "x-api-secret: sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: lead-5567-call-1" \
-d '{ "number": "9812345678", "reference_id": "lead-5567" }'Response
{
"status": "success",
"code": 200,
"unique_id": "run_4822",
"reference_id": "lead-5567",
"details": "Call queued."
}Errors
| Status | Code | error_code | When | What to do |
|---|---|---|---|---|
| 404 | 404 | not_found | The campaign id does not exist on your account, or is not an API campaign.details: "Campaign not found." | Pick an id from List campaigns. |
| 400 | 400 | validation_error | The campaign is a verification-code campaign.details: "This is a verification-code campaign. Use the OTP endpoints to send a code." | Send the code with the Voice OTP API. |
| 400 | 400 | validation_error | The campaign type cannot be triggered for one number.details: "This campaign type cannot be triggered for a single number." | Use an Audio Blast or Interactive Voice campaign. |
| 400 | 400 | validation_error | The request sent variables to an Audio Blast or Interactive Voice campaign. Only an AI voice agent campaign takes them.details: "This campaign type does not take variables." | Leave variables out of the request. |
| 400 | 400 | validation_error | The request sent metadata to an Audio Blast or Interactive Voice campaign. Only an AI voice agent campaign takes it.details: "This campaign type does not take metadata." | Leave metadata out of the request. Use reference_id to match the call to your records. |
| 400 | 400 | validation_error | The campaign has no audio clip configured.details: "This campaign has no audio configured." | Attach an audio clip to the campaign in the dashboard. |
| 400 | 400 | validation_error | An AI voice agent campaign whose agent has been removed or paused. See the AI Voice Agent API.details: "This campaign has no active agent configured." | Reattach an active agent to the campaign in the dashboard. |
| 409 | 409 | ai_concurrency_limit | An AI voice agent campaign when the account's AI channels are all busy. This is a second, narrower cap inside your account's lines.details: "All your AI call channels are in use. Retry when a call finishes." | Retry-After is 5 seconds. Placing a non-AI call is unaffected. |
| 403 | 403 | whatsapp_locked | The campaign sends a WhatsApp message after the call, and WhatsApp is not part of this account's plan.details: "WhatsApp is locked on this account. Add WhatsApp to your plan, upgrade to a yearly phone plan, or move to Phone + CRM." | Add WhatsApp to the plan, upgrade to a yearly phone plan, or move to Phone + CRM. |
| 409 | 409 | whatsapp_not_connected | The plan allows WhatsApp, but no WhatsApp account is connected yet.details: "Connect Agentive Chat on the Integrations page before using WhatsApp in a campaign." | Connect Agentive Chat on the Integrations page. |
| 400 | 400 | whatsapp_template_required | The campaign uses the WhatsApp action but has no account and approved template saved on it.details: "Pick the WhatsApp account and an approved template for this campaign first." | Pick an account and an approved template on the campaign in the dashboard. |
Balance (402), calling-hours and idempotency conflicts (409, 422), rate limits and the line refusal (429) behave exactly as on Place a call. Like every billed POST endpoint, the trigger accepts an Idempotency-Key header (see Idempotency).
An AI voice agent campaign is triggered the same way and also takes variables and metadata; it is documented in the AI Voice Agent API. A recorded-message or Interactive Voice campaign refuses both: variables with HTTP 400 and "This campaign type does not take variables.", and metadata with HTTP 400 and "This campaign type does not take metadata."
Get call status
/calls/:unique_idCheck the current state of a call you placed. Pass the unique_id from the placement response. A raw call_id from List calls and a verification request_id both resolve here too, each with its own response shape, all three described below.
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| unique_id | string | Required | The unique_id returned when you placed the call, for example run_4821. Raw call ids also resolve here: the call_id values from List calls. Must belong to your account.Example: run_4821 |
Request
curl https://voice.agentive.co.in/api/public/v1/calls/run_4821 \
-H "x-api-key: pk_live_..." \
-H "x-api-secret: sk_live_..."Response (a run id, for example run_4821)
{
"status": "success",
"code": 200,
"unique_id": "run_4821",
"reference_id": "order-99213",
"details": "answered",
"call_status": "answered",
"duration_sec": 17,
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"end_reason": null,
"call": {
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "in_progress",
"end_reason": null,
"duration_sec": 17,
"campaign_id": 4821,
"reference_id": "order-99213",
"created_at": "2026-06-04T21:42:08+05:30",
"created_at_ist": "2026-06-04T21:42:08+05:30",
"created_at_utc": "2026-06-04T16:12:08Z"
}
}Response fields
| Field | Type | Description |
|---|---|---|
| call_status | string | The call's current state in the older, smaller vocabulary: queued, ringing, answered, completed or failed. Unchanged. The mapping to the full set is in Call status and end reasons. |
| duration_sec | number | null | Talk duration in seconds, once the call has been answered. |
| call_id | string | null | Our record id for the call this run placed. Use it for that leg's recording. |
| end_reason | string | null | Why the call ended, from the enumerated end reasons. null while the call is still running. Read this to tell a call_status of failed that rang unanswered from one that never connected. |
| reference_id | string | null | The reference_id you sent when you placed the call. |
| call | object | null | The full call object with the enumerated status and end_reason values, once the call exists. |
Errors
| Status | Code | error_code | When | What to do |
|---|---|---|---|---|
| 404 | 404 | not_found | The unique_id does not exist or does not belong to your account.details: "Call not found." or "Not found." | Check the id you stored from the placement response. |
Response (a raw call id)
When the path is a raw call id (a call_id from List calls), the call object carries four extra fields beside the standard call object.
{
"status": "success",
"code": 200,
"unique_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"reference_id": "order-99213",
"details": "completed",
"call_status": "completed",
"duration_sec": 42,
"call": {
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "completed",
"end_reason": "completed",
"duration_sec": 42,
"campaign_id": 4821,
"reference_id": "order-99213",
"created_at": "2026-06-04T21:42:08+05:30",
"created_at_ist": "2026-06-04T21:42:08+05:30",
"created_at_utc": "2026-06-04T16:12:08Z",
"agent_id": null,
"transcript_available": false,
"analysis_available": false
}
}Extra fields on call
| Field | Type | Description |
|---|---|---|
| duration_sec | number | null | Talk duration in seconds. Also returned beside the envelope. |
| agent_id | number | null | The AI voice agent that handled the call. null for a recorded or Interactive Voice call, and for any call a person handled. |
| transcript_available | boolean | Whether a transcript of this call has been produced. |
| analysis_available | boolean | Whether an analysis of this call has been produced. |
A verification request id from the Voice OTP API resolves here too. For those the response carries verification_status (the closed set pending, verified, failed, locked, expired, undeliverable), the original call_status, plus verified, attempts, max_attempts and expires_at_ist. The Voice OTP reference documents that shape.
List calls
/callsPage through the calls on your account, most recent first. This returns the full call object for each call, with the enumerated status and end_reason values, so you can reconcile call results in bulk instead of polling each call by id.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| page | number | Optional | The page of results to return. Default 1. |
| per_page | number | Optional | Results per page. Default 50, maximum 100. |
| direction | string | Optional | Filter by inbound or outbound. |
| status | string | Optional | Filter by a call status: queued, ringing, in_progress, completed, failed, or missed. |
| from | string | Optional | Include calls created on or after this ISO 8601 date or timestamp. Example: 2026-06-01 |
| to | string | Optional | Include calls created before this ISO 8601 date or timestamp. Example: 2026-06-30T23:59:59+05:30 |
| number | string | Optional | Match calls where this number appears as either the caller or the recipient. Example: 9812345678 |
Request
curl "https://voice.agentive.co.in/api/public/v1/calls?per_page=50&direction=outbound&status=completed" \
-H "x-api-key: pk_live_..." \
-H "x-api-secret: sk_live_..."Response
{
"status": "success",
"code": 200,
"data": [
{
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "completed",
"end_reason": "completed",
"duration_sec": 42,
"campaign_id": 4821,
"reference_id": "order-99213",
"created_at": "2026-06-04T21:42:08+05:30",
"created_at_ist": "2026-06-04T21:42:08+05:30",
"created_at_utc": "2026-06-04T16:12:08Z"
}
],
"pagination": {
"page": 1,
"per_page": 50,
"total": 137,
"total_pages": 3
}
}Response fields
| Field | Type | Description |
|---|---|---|
| data | object[] | A page of call objects. |
| pagination.page | number | The current page. |
| pagination.per_page | number | Results per page for this response. |
| pagination.total | number | Total calls matching your filters. |
| pagination.total_pages | number | Total number of pages. |
The response carries only the public call object fields. end_reason is always one of the enumerated end reasons.
Download a call recording
/calls/:unique_id/recordingDownload the audio recording of a call. The API streams the file itself: HTTP 200 with the audio as the body and a Content-Type of audio/ogg or audio/wav, depending on how the recording was stored. It never redirects you to another address. Send a Range header to fetch part of the file (HTTP 206); a range outside the file answers HTTP 416. The samples authenticate with HTTP Basic, which an HTTP client never forwards to another host, and you should never send your secret anywhere but this API. Requires call recording to be enabled on your account.
Path parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| unique_id | string | Required | The unique_id returned when you placed the call, for example run_4821. Raw call ids also resolve here: the call_id values from List calls. Must belong to your account.Example: run_4821 |
Request
# The API streams the audio. Basic auth: key as user, secret as password.
curl "https://voice.agentive.co.in/api/public/v1/calls/run_4821/recording" \
-u "$AGENTIVE_KEY:$AGENTIVE_SECRET" \
--output call-4821.oggResponse (no recording available)
{
"status": "error",
"code": 404,
"unique_id": null,
"reference_id": null,
"details": "No recording is available for this call.",
"error": "No recording is available for this call.",
"error_code": "not_found"
}Errors
| Status | Code | error_code | When | What to do |
|---|---|---|---|---|
| 404 | 404 | not_found | The call never connected, or the audio is not available.details: "No recording is available for this call." | Wait for the recording.available event, or check that the call reached completed. |
| 403 | 403 | feature_disabled | Call recording is not enabled for your account.details: "This feature is not enabled for your account." | Contact support to enable call recording. |
recording_url.List campaigns
/campaignsList the campaigns on your account that can be triggered through the API. Use a campaign id from this list with Trigger a saved campaign.
Request
curl https://voice.agentive.co.in/api/public/v1/campaigns \
-H "x-api-key: pk_live_..." \
-H "x-api-secret: sk_live_..."Response
{
"status": "success",
"code": 200,
"data": [
{ "id": 57, "name": "March promo", "type": "audio_blast", "status": "active", "press1_action": null },
{ "id": 61, "name": "Renewal interactive voice", "type": "press1", "status": "active", "press1_action": "whatsapp" },
{ "id": 72, "name": "Signup verification", "type": "otp", "status": "active", "press1_action": null }
]
}Response fields
| Field | Type | Description |
|---|---|---|
| id | number | The campaign id. Use it with Trigger a saved campaign. |
| name | string | The name you gave the campaign. |
| type | string | One of audio_blast (Audio Blast), press1 (Interactive Voice), otp (verification) or ai_agent (an AI voice agent takes the conversation). audio_blast and press1 can be triggered from Trigger a saved campaign. otp campaigns are configured here but delivered with the Voice OTP API, and ai_agent campaigns are not available through the API at present. |
| status | string | The campaign's current state. |
| press1_action | string | null | For press1 campaigns: connect, interest or whatsapp. null for other types. |
Get account balance
/accountReturn your account's current wallet balance in INR. Useful as a pre-flight check before a large run. Requires the wallet feature on your account.
Request
curl https://voice.agentive.co.in/api/public/v1/account \
-H "x-api-key: pk_live_..." \
-H "x-api-secret: sk_live_..."Response
{
"status": "success",
"code": 200,
"data": {
"balance_inr": 1842.5,
"currency": "INR",
"lines": 10,
"lines_in_use": 2
}
}Response fields
| Field | Type | Description |
|---|---|---|
| data.balance_inr | number | Your current wallet balance, in Indian Rupees. |
| data.currency | string | Always "INR". |
| data.lines | number | The number of calls your account can have live at the same time. Inbound, outbound, campaign and API calls all count against it. |
| data.lines_in_use | number | How many of those lines are in use right now, including calls the API has just placed. |
Errors
| Status | Code | error_code | When | What to do |
|---|---|---|---|---|
| 403 | 403 | feature_disabled | The wallet feature is not enabled for your account.details: "This feature is not enabled for your account." | Contact support to enable the wallet. |
The call object
List calls returns a data array of call objects, and Get call status returns one under call when you fetch by call_id. The same shape appears in the call.* webhook events.
| Field | Type | Description |
|---|---|---|
| unique_id | string | The call's public id. For calls placed through an API run this looks like run_4821; otherwise it is the call id. |
| call_id | string | null | Our internal identifier for the call. |
| leg_id | string | null | Added September 2026. A second id for the same call, present only when the record is keyed on something other than the phone leg, and null otherwise. Either id resolves on Get call status; unique_id did not change when this field was added. |
| direction | string | outbound or inbound. |
| from_number | string | null | The caller number shown. |
| to_number | string | null | The recipient number. |
| status | string | null | The call state. One of the enumerated status values. |
| end_reason | string | null | Why the call ended. One of the enumerated end reasons, or null while the call is still in progress. |
| duration_sec | number | null | Talk duration in seconds. |
| campaign_id | number | null | The run this call belongs to, matching the digits in unique_id. |
| reference_id | string | null | The reference_id you supplied. |
| created_at | string | null | When the call was created. Kept exactly as it has always been returned; prefer the two fields below in new code. |
| created_at_ist | string | null | The same instant in Indian Standard Time, ISO 8601 with a +05:30 offset.Example: 2026-06-04T21:42:08+05:30 |
| created_at_utc | string | null | The same instant in UTC, ISO 8601 with a Z suffix.Example: 2026-06-04T16:12:08Z |
reference_id is filled on every read for any call we can resolve it for, so a list or a poll gives you back the key you sent. Calls placed before September 2026 may still show null there.
Call status and end reasons
status and end_reason are closed sets. New values are not added without a new payload version, so you can write exhaustive handling with no open-ended fallback.
status (where the call is now)
| Value | Meaning | Webhook event |
|---|---|---|
| queued | Accepted and waiting to be placed. | call.initiated |
| ringing | The recipient's phone is ringing. | call.ringing |
| in_progress | The recipient answered and the call is connected. | call.answered |
| completed | The call finished after being answered. | call.completed |
| missed | The call reached the recipient but was never answered. | call.no_answer, call.busy |
| failed | The call could not be placed at all. | call.failed |
Transitions
| From | To | When |
|---|---|---|
| queued | ringing | We handed the call to the network and the recipient's phone started ringing. |
| ringing | in_progress | The recipient answered. |
| in_progress | completed | The call ended after being answered. end_reason is completed or voicemail. |
| queued, ringing | missed | It rang and was never answered. end_reason is no_answer, busy, rejected or canceled. |
| queued, ringing | failed | It could not be placed. end_reason is failed. |
completed, missed and failed are terminal. A call never leaves one of them, and end_reason is null until it reaches one.
end_reason (set on a terminal status)
| Value | Meaning | With status |
|---|---|---|
| completed | The call connected and ran to its normal close: the clip finished, the interactive flow ended, or either side hung up. | completed |
| voicemail | A voicemail system answered. It was detected and the call was closed. | completed |
| no_answer | It rang and the recipient never picked up. Safe to retry later. | missed |
| busy | The recipient was on another call. The number is reachable, so a retry can succeed. | missed |
| rejected | The recipient actively declined the call. | missed |
| canceled | The call was cancelled before it connected. | missed |
| failed | The call could not be placed, for example an invalid number or a network problem. | failed |
The simpler call_status on a run id
GET /calls/run_<id> keeps an older, smaller call_status vocabulary, unchanged so existing integrations keep working. Read it through this mapping, or read the call object on the same response, which carries the full set above.
| call_status | Means | Note |
|---|---|---|
| queued | queued | Same meaning. |
| ringing | ringing | Same meaning. |
| answered | in_progress | The same state under an older name. |
| completed | completed | Same meaning. |
| failed | missed or failed | This one value covers both. Read end_reason, or the call object's status, to tell a call that rang unanswered from one that could not be placed. |
end_reason values, not statuses. A call that rang unanswered and one that was busy both carry the status missed, and the reason is in end_reason. The webhook event name tells you the same thing without reading either field.Identifiers
Six identifiers move between your system and ours. Only one of them is the handle you poll and match on; the rest are for correlation, for fetching a specific artefact, or for retry safety.
The six ids
| Identifier | What it is | Where you get it | What you poll with | In webhooks |
|---|---|---|---|---|
| unique_id | The handle for the thing you just created. This is the id to keep. | The unique_id field of the response that placed the call or sent the code. | Yes. GET /calls/:unique_id. | data.unique_id on every call.* and recording.available event. |
| run id | The unique_id shape a broadcast or Interactive Voice call takes: run_<campaign_id>. | Returned by POST /calls (types audio_blast and press1) and by POST /campaigns/:id/trigger, including an AI voice agent campaign. | Yes. It is a unique_id. | data.unique_id. On a broadcast run its digits are data.campaign_id; on an AI voice agent run data.campaign_id is the campaign you triggered. |
| request_id | A verification request. The same value as that request's unique_id. The alias otp_id is accepted in a verify body. | Returned by POST /otp/send and by POST /calls with type: "otp". | Yes. GET /calls/:request_id. | data.request_id on every otp.* event. |
| call_id | Our record id for one call leg. Informational. | GET /calls, and the call object on GET /calls/:unique_id. | Not the id to poll with, though it resolves on GET /calls/:unique_id too. Use it for the transcript, analysis and recording of a specific leg. | data.call_id, null until the leg exists. |
| leg_id | A second id for the same call, present only when the record is keyed on something other than the phone leg. On a call placed with POST /ai/calls it is the dial id, the same value as unique_id. Added September 2026 beside an unchanged unique_id. | GET /calls, the call object on GET /calls/:unique_id, and the call and recording objects in webhooks. null on most calls. | Not the id to poll with, though it resolves on GET /calls/:unique_id too. | data.leg_id. |
| reference_id | Your own correlation key. We never interpret it and it never de-duplicates anything. | You send it. Up to 120 characters; control characters are stripped. | No. It is echoed, not addressable. | On the envelope as reference_id and again inside data. |
| Idempotency-Key | Your retry key for one logical request. 1 to 128 visible characters (a longer key is refused), stored for 24 hours. | You send it as a request header. | No. It addresses a stored response, not a resource. | Never sent. |
Ids we generate for delivery
| Identifier | What it is | Where you get it | What you poll with | In webhooks |
|---|---|---|---|---|
| event id | One webhook event (evt_...). The same value reaches every endpoint subscribed to it, and repeats across retries. | The event body's id (aliased event_id). | No. De-duplicate your handler on it. | id in the body and X-Agentive-Event-Id in the headers. |
| delivery id | One event on its way to one endpoint (dlv_...). Stable across every retry of that delivery. | The X-Agentive-Delivery-Id header on the delivery itself. | No. Record it in your own log, and quote it when you ask us about a specific delivery. | X-Agentive-Delivery-Id in the headers. |
POST /ai/calls has no run, so we return the dial id when you place it while the record itself is keyed on the conversation. That dial id is the call's unique_id on every webhook event and on every read: the call list, GET /calls/:unique_id, the transcript, the analysis and the recording. call_id is the record id, and leg_id is the dial id again. Either id resolves on GET /calls/:unique_id, so an id you stored earlier keeps working.unique_id the placing response gave you, and match webhooks on data.unique_id. Everything else is either yours (reference_id, Idempotency-Key) or ours to hand you for a specific artefact (call_id, event id, delivery id).Which number the recipient sees
The campaign decides, and only the campaign. You pick it once when you set the campaign up, and every call that campaign places comes from what you picked. You cannot set it per request, and nothing overrides it or stands in for it later. It works the same whether you place the call with POST /calls or trigger the campaign directly.
- If the campaign names one of your numbers, that number is used and the recipient sees your own line. Only your numbers enabled for voice broadcast can be picked.
- If the campaign names a rotational pool, the call goes out from a number in that pool, and the pool rotates across its numbers and paces them on your behalf.
- If what the campaign picked stops being usable— the number is switched off for broadcast, quarantined, or the pool is withdrawn — the request is refused with HTTP 400 and
invalid_caller_id, telling you to open the campaign and pick again. Your calls are never quietly moved to some other number: you would not know it had happened, and neither would the customer seeing an unfamiliar caller. - If your account has no broadcast number and no pool at all, the request is refused with HTTP 400 and "No number on your account is enabled for voice broadcast. Ask your account manager to enable one, then retry." Retrying will not help until a number is enabled.
Phone number format
Send Indian mobile numbers in any of these forms; they are all normalised:
- 10-digit local:
9812345678 - With country code:
919812345678 - With plus prefix:
+919812345678
Reference IDs
reference_id is an optional string you attach to any request, such as an order number, ticket id, or signup id. It is:
- echoed back in the response
reference_idfield, and - included in every webhook for the call.
Use it to match our events back to your own records without storing our ids. Reference IDs are capped at 120 characters; control characters are stripped. A reference_id never deduplicates anything; use an Idempotency-Key for retry safety.
Calling hours
Calls that reach a customer with a message are placed between 9:00 AM and 9:00 PM IST, every day. This applies to Audio Blast and Interactive Voice calls, whether you place them with POST /calls or by triggering a saved campaign. A request that arrives outside those hours is refused with HTTP 409 and a message naming the hours, so your system can queue it and retry in the morning rather than believing the call was placed. If your account's calling-hours setting cannot be read at that moment, the request is refused too, never placed: HTTP 503 with service_unavailable and retry_after_sec 30. Retry after 30 seconds with the same Idempotency-Key.
otp type, and POST /otp/send in the Voice OTP API) work around the clock, because a login or payment code has to reach someone whenever they are signing in.If your account has been approved for extended hours, your calls are not restricted and you will not see the 409.
Idempotency
Four endpoints accept an Idempotency-Key header: POST /calls, POST /campaigns/:id/trigger, and POST /otp/send and POST /otp/verify in the Voice OTP API. Send a unique key with each logical request, and a network retry can never place a second billed call: if a request times out or the connection drops, resend the same request with the same key.
curl https://voice.agentive.co.in/api/public/v1/calls \
-H "x-api-key: pk_live_..." \
-H "x-api-secret: sk_live_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-99213-call-1" \
-d '{ "type": "audio_blast", "number": "9812345678", "campaign_id": 4821, "reference_id": "order-99213" }'The key is 1 to 128 visible ASCII characters; a longer key is refused with HTTP 400 rather than cut short. Use something unique per action, such as a UUID or your own order id plus an attempt marker. Keys are scoped to your account and live for 24 hours.
What is stored and replayed
A key is consumed only by a response that decided the request. Everything else releases it, so the same key is the right thing to retry with.
| Response | Stored? | What a retry with the same key does |
|---|---|---|
2xx | Yes | Replays the original response. No second call is placed. |
400 | Yes | Replays the same validation error for 24 hours, even after you fix the body or the campaign in the dashboard. Send the fixed request with a new key. |
401 on verify | Yes | Replays the incorrect-code response, because the attempt was already spent. |
404 | Yes | Replays the not-found response for 24 hours. Once the id or the dashboard setting is fixed, send the request with a new key. |
422 | Yes | Replays the conflict. The key is bound to the first body it saw. |
402 | No | Top up, then resend with the same key. The call is attempted. |
403 | No | Once the entitlement is in place, resend with the same key. |
409 | No | Wait for Retry-After, then resend with the same key. |
429 | No | Wait for Retry-After, then resend with the same key. |
5xx before we placed the call | No | Resend with the same key. Nothing was placed. |
502 call_state_unknown | Yes | Replays the same answer. Poll GET /calls/:unique_id, or choose a fresh key deliberately. |
error_code: "call_state_unknown", so a blind retry can never double-dial. Poll Get call status with the unique_id you were given; only place a second call with a fresh key once you have seen that the first one did not happen.Replay and in-flight
A replayed response carries two headers with the same meaning: Idempotent-Replayed: true, the platform-wide name, and idempotency-replayed: true, the original one, kept so existing clients keep working.
HTTP/1.1 200 OK
Content-Type: application/json
Idempotent-Replayed: true
idempotency-replayed: trueReusing a key while the first request is still running returns HTTP 409 with error_code: "idempotency_in_progress" and Retry-After: 2, for as long as that first request is in flight. If our side restarts mid-request the key is released at once, so a retry is never stuck behind a request that will never finish.
HTTP/1.1 409 Conflict
Retry-After: 2
{
"status": "error",
"code": 409,
"unique_id": null,
"reference_id": "order-99213",
"details": "A request with this Idempotency-Key is still being processed. Retry in a moment.",
"error": "A request with this Idempotency-Key is still being processed. Retry in a moment.",
"error_code": "idempotency_in_progress",
"retry_after_sec": 2
}The same key with a different request body returns HTTP 422 with error_code: "idempotency_conflict". A new request needs a new key.
And a repeat of a key whose first answer was an unknown outcome replays that answer with a second sentence appended, spelling out what to do before you dial again:
{
"status": "error",
"code": 502,
"unique_id": null,
"reference_id": "order-99213",
"details": "Could not place the call right now. Please retry. The call may or may not have been placed. Check the call with GET /calls/{unique_id} before retrying, or retry with a new Idempotency-Key.",
"error": "Could not place the call right now. Please retry. The call may or may not have been placed. Check the call with GET /calls/{unique_id} before retrying, or retry with a new Idempotency-Key.",
"error_code": "call_state_unknown"
}reference_id remains a correlation echo only; it never de-duplicates anything. Use the Idempotency-Key header for retry safety and reference_id to match calls back to your records.
id (evt_...) you can use to ignore repeats. See Ordering and idempotency in the webhook reference.Campaign webhooks
A campaign can push its own results to a URL of yours. You set that URL on the campaign in your dashboard, and it belongs to that campaign alone. It is separate from the account-level Telephony Webhooks, which cover every call on the account and have their own endpoints, retry schedule and delivery log. Campaign webhooks report campaign work: one event when an attempt on a contact is final, and one when a team member sets a disposition.
Campaigns you run from the dashboard fire these events. A campaign you start through Trigger a saved campaign does not: every API trigger runs as its own child of the campaign, and those children do not inherit the campaign's webhook URL. For API-driven calls, use the account-level Telephony Webhooks instead.
The two events
| Event | When it is sent |
|---|---|
| campaign.attempt_end | One per contact, when the attempt on that contact is final. Never on a retry, so a contact you dial three times produces one event, at the end. |
| campaign.disposition_set | When a team member sets a disposition on a call from this campaign. |
Headers
| Header | Type | Description |
|---|---|---|
| Content-Type | string | The body is always JSON. Example: application/json |
| User-Agent | string | Fixed, and the same string campaign webhooks have always sent, so an allow-list built on it keeps working. Do not use it as proof the event is ours: check X-Agentive-Signature-V2 for that.Example: agentive-voice-webhook/1.0 |
| X-Agentive-Event | string | The dotted event name, the same value as type in the body, so you can route without parsing the body first.Example: campaign.attempt_end |
| X-Agentive-Event-Id | string | The same value as id in the body. De-duplicate on it.Example: evt_9f3c1a7b40d25e86c1b4 |
| X-Agentive-Timestamp | string | Unix seconds at the moment we sent the event, alongside created in the body. It is stamped once per event, so a retry carries the same value as the first attempt, and it can be a second later than created. Do not treat the two as byte-equal.Example: 1780589528 |
| X-Agentive-Signature | string | The older, body-only signature: sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your account's webhook signing secret. It is still sent for receivers built before version 2, but it cannot prove when a body was sent, so do not accept it on its own. The secret is shown on the campaign page and is shared with your call menu key press webhook, so one account has one secret and one verification routine. Both signature headers are absent only when the account has no signing secret, which happens only on a database fault, so an unsigned delivery is never a normal one. See Verifying the signature.Example: sha256=4f1d...08e9 |
| X-Agentive-Signature-V2 | string | t= the same Unix seconds as X-Agentive-Timestamp, a comma, then v2= the hex HMAC-SHA256 of the timestamp, a dot and the raw body (t + "." + body), keyed with the same secret. Version 1 signs the body alone, so it is the same however many times the event is sent; version 2 binds the timestamp into what is signed, which is what makes a replay window mean anything. Verify this one only, reject a t more than five minutes from your clock, and refuse a delivery without it. See Verifying the signature.Example: t=1780589528,v2=9c41...7e05 |
campaign.attempt_end
Payload
| Field | Type | Description |
|---|---|---|
| id | string | The event id ( evt_ and 20 hex characters). Stable for this event. De-duplicate on it. The sample the Test button sends is prefixed evt_test_ instead, so it can never collide with a real event id. |
| type | string | The dotted event name, campaign.attempt_end. Branch on this. |
| event | string | The legacy name, which keeps the value attempt_end so existing handlers keep working. |
| created | number | Unix seconds at the moment we sent the event. |
| timestamp_ist | string | The same instant in Indian Standard Time, ISO 8601 with milliseconds and a +05:30 offset. Example: 2026-06-04T21:42:08.412+05:30 |
| test | boolean | false on a real attempt, true only for the sample the Test button on the campaign page sends. |
| org_id | number | Your account id. |
| campaign | object | id, name, mode (the pacing mode), dial_type and parent_campaign_id. dial_type is the internal campaign type (broadcast_dtmf, broadcast_audio, human_pool, ai_agent or auth_otp), not the type value that List campaigns returns. |
| contact | object | id, phone, name and email of the contact this attempt dialled. |
| attempt | object | attempt_no, outcome, amd_result, duration_sec, ended_at, call_uuid, disposition and end_reason. The first five are unchanged from the older unsigned payload. end_reason here is the raw carrier hangup cause recorded on the call row (for example NORMAL_CLEARING or USER_BUSY), not the shorter end reason of the call object, and it is null when no cause was recorded. |
| press | object | null | What the recipient chose: digit, action (connect, interest, whatsapp, decline or none) and via (keypad or voice). The whole object is null when nothing was pressed or said, and it is always null on a guided-flow campaign, where the answers are reported in flow_answers instead. action is none when this campaign gives the key no meaning: a recipient can press anything on their handset, and a digit that is not in the campaign's key map does nothing, so it is reported as a keypress that did nothing rather than as the campaign's own action. Handle none as a real value rather than a missing one, and do not create a lead from it. |
| flow_answers | object | null | For a guided flow, the answer to each question keyed q0, q1 and so on, each yes, no or none. none is a question that ran out of retries with no reply, so handle three values, not two. null when the campaign has no flow. |
| object | null | sent, suppressed and error, present only when a WhatsApp message was actually owed for this attempt. null otherwise, which is the common case even on a WhatsApp campaign: the campaign has no WhatsApp action, or the recipient did not choose it. A send that was owed but had not finished by the time we sent the event is still an object, reported as error "pending". |
event field keeps the value attempt_end. Branch on type for the dotted name.whatsapp is null for one reason only: no WhatsApp message was owed for this attempt. So do not read whatsapp.sent without checking that whatsapp is present. When it is present and sent is false, error names the reason: frequency_suppressed, not_connected, locked, no_template, bad_number, send_refused, network or pending. It is a closed set: send_refused means the message itself was refused, and the wording of that refusal is not published in the event.
pending is the one value that is not a final outcome. It means the send was still in flight when we sent the event: we wait up to 8 seconds after the attempt ends for the message to settle, and past that we report the attempt rather than hold the event back. The message may still go out afterwards, so read the final outcome for that contact on the campaign page.
{
"id": "evt_9f3c1a7b40d25e86c1b4",
"type": "campaign.attempt_end",
"event": "attempt_end",
"created": 1780589528,
"timestamp_ist": "2026-06-04T21:42:08.412+05:30",
"test": false,
"org_id": 42,
"campaign": {
"id": 412,
"name": "Renewal interactive voice",
"mode": "progressive",
"dial_type": "broadcast_dtmf",
"parent_campaign_id": null
},
"contact": {
"id": 90218,
"phone": "+919812345678",
"name": "Neha Sharma",
"email": "neha@example.com"
},
"attempt": {
"attempt_no": 1,
"outcome": "interested",
"amd_result": "HUMAN",
"duration_sec": 24,
"ended_at": "2026-06-04T16:12:08.412Z",
"call_uuid": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"disposition": "interested",
"end_reason": "NORMAL_CLEARING"
},
"press": { "digit": "1", "action": "whatsapp", "via": "keypad" },
"flow_answers": null,
"whatsapp": { "sent": true, "suppressed": false, "error": null }
}campaign.disposition_set
Sent when a team member sets a disposition on a call from this campaign. It carries the same envelope fields (id, type, event, created, timestamp_ist, test and org_id), the same campaign and contact objects as campaign.attempt_end (contact is { "id": null } when the attempt has no contact row), a call object with uuid, disposition, disposition_by and notes, and set_at. Its legacy event value stays disposition_set.
{
"id": "evt_2d70b915ee4a3c68f0a1",
"type": "campaign.disposition_set",
"event": "disposition_set",
"created": 1780590114,
"timestamp_ist": "2026-06-04T21:51:54.077+05:30",
"test": false,
"org_id": 42,
"campaign": {
"id": 412,
"name": "Renewal interactive voice",
"mode": "progressive",
"dial_type": "broadcast_dtmf",
"parent_campaign_id": null
},
"contact": {
"id": 90218,
"phone": "+919812345678",
"name": "Neha Sharma",
"email": "neha@example.com"
},
"call": {
"uuid": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"disposition": "callback",
"disposition_by": 41,
"notes": "Wants a call back on Monday"
},
"set_at": "2026-06-04T16:21:54.077Z"
}Verifying the signature
Verify before you trust an event, using X-Agentive-Signature-V2 only. It carries t=<unix seconds>,v2=<hex>. Split it on the comma, take the value of t, and reject the event if t is more than five minutes from your own clock. Then compute the HMAC-SHA256 of the string t, a dot, and the raw request body, keyed with your account's webhook signing secret, hex-encode it and compare it to v2 with a timing-safe comparison, after checking the two are the same length in bytes. Parse the body only after the comparison passes, and read the raw bytes rather than a re-serialised object: any change to spacing or key order changes the signature.
Refuse a delivery that has no version 2 header, and do not fall back to X-Agentive-Signature. That older header signs the body alone, so anyone who captured one delivery could send the same body again later with a fresh timestamp and it would still check out; version 2 puts the timestamp inside what is signed, so the five minute window holds. Every delivery carries both headers, so a version 2 only receiver misses nothing. Never run the check with an empty secret: the samples refuse to start without one.
import crypto from "crypto";
import express from "express";
const app = express();
// Capture the RAW body: the signature covers the exact bytes we sent.
app.use("/webhooks/agentive-campaign", express.raw({ type: "application/json" }));
const TOLERANCE_SEC = 300; // reject anything older than five minutes
// Compare byte lengths first: timingSafeEqual throws on unequal lengths, and a
// multi-byte character can make two strings of equal length differ in bytes.
function safeEqual(a, b) {
const x = Buffer.from(String(a), "utf8");
const y = Buffer.from(String(b), "utf8");
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
// Version 2: t=<unix seconds>,v2=<hex>. HMAC-SHA256 over t, a dot, then the raw body.
function verifyV2(secret, rawBody, header) {
if (!secret) return false; // never verify with an empty key
const parts = {};
for (const piece of String(header || "").split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v2) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SEC) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(String(t) + ".")
.update(rawBody)
.digest("hex");
return safeEqual(parts.v2, expected);
}
// Version 2 only. A delivery without X-Agentive-Signature-V2 is refused: the
// older X-Agentive-Signature signs the body alone and cannot prove freshness.
function verifySignature(secret, rawBody, headers) {
return verifyV2(secret, rawBody, headers["x-agentive-signature-v2"]);
}
const SECRET = process.env.AGENTIVE_WEBHOOK_SECRET;
if (!SECRET) throw new Error("AGENTIVE_WEBHOOK_SECRET is not set");
app.post("/webhooks/agentive-campaign", (req, res) => {
if (!verifySignature(SECRET, req.body, req.headers)) {
return res.status(401).send("bad signature");
}
const evt = JSON.parse(req.body.toString("utf8"));
// De-duplicate on evt.id, then branch on evt.type.
if (evt.type === "campaign.attempt_end") {
console.log(evt.contact.phone, evt.attempt.outcome, evt.whatsapp?.sent);
} else if (evt.type === "ivr.keypress") {
// from_number is the caller's number as the carrier presented it:
// match on its last ten digits.
console.log(evt.data.from_number.slice(-10), evt.data.key_path, evt.data.key_label);
}
res.sendStatus(200);
});
app.listen(3000);Delivery
We POST once and retry once more after 2 seconds if your endpoint returns 5xx, does not answer within 6 seconds, or cannot be reached at all. A 4xx is not retried. Only a 2xx counts as delivered. There is no delivery log for campaign webhooks; the campaign page shows the last delivery result and a Test button that sends one signed sample with test: true.
So answer with any 2xx as soon as you have stored the event, and do your own work afterwards. Because an event can arrive twice, key your handler on id.
Redirects. We follow at most one. If your address answers 3xx we read Location, resolve it against the address we called, and put the result through the same address check every delivery makes: it must be a public http or https address. When it passes we repeat the same POST to it, with the same headers and the same signed body, so what you receive still verifies. Anything else ends the delivery, and it is not retried: a Location we cannot deliver to, a 3xx that gives us no usable Location, or a second redirect. The campaign page names which of the three it was. Each request gets its own 6 second window, so a followed redirect does not eat the first request's time.
Save the final address rather than one that redirects. A hop costs a round trip on every event, and any change to where it points stops delivery until you update the campaign.
Call menu key press webhook
A call menu can post every key a caller presses to a URL of yours: one event per key press, as the caller moves through the menu. The event is sent off the call path. The caller is already on their way to the department, the message or the WhatsApp template that key leads to, and a slow or failing endpoint never delays the call.
You set the URL on the call menu itself, under Call menu > Advanced > Key press webhook. The same page shows your account's webhook signing secret, which this hook shares with campaign webhooks: one account, one secret, one verification routine. It is not one of the events on the account-level Telephony Webhooks page, and it does not appear in that delivery log.
ivr.keypress
Payload
| Field | Type | Description |
|---|---|---|
| id | string | The event id ( evt_ and 20 hex characters). Stable for this event. De-duplicate on it. The sample the Test button sends is prefixed evt_test_ instead, so it can never collide with a real event id. |
| type | string | The dotted event name, ivr.keypress. Branch on this. |
| event | string | The same value, ivr.keypress, kept alongside type for a handler that reads event. |
| created | number | Unix seconds at the moment we sent the event, the same value as X-Agentive-Timestamp. |
| timestamp_ist | string | The same instant in Indian Standard Time, ISO 8601 with milliseconds and a +05:30 offset. Example: 2026-06-04T22:12:22.518+05:30 |
| test | boolean | false on a real key press, true only for the sample the Test button on the call menu page sends. |
| data | object | The key press. Its fields are in the next table. |
The data object
| Field | Type | Description |
|---|---|---|
| call_uuid | string | The call the key was pressed on, as the uuid on its call record. All zeros on the test sample. Example: c7d2e1f0-4a3b-4c5d-8e9f-0a1b2c3d4e5f |
| from_number | string | The number the caller called from, as the carrier presented it. It can arrive as +91 and ten digits, 91 and ten digits, 0091 and ten digits, 0 and ten digits, or the ten digits alone (today the wire carries 91 and the ten digits), so match on the last ten digits rather than on the whole string. +919876543210 on the test sample.Example: 919812345678 |
| to_number | string | The number the caller dialled, as the carrier presented it and the edge passed it on (whitespace trimmed). Today that is 91 and the ten digits. Never match on the whole string; match on the last ten digits. On the test sample only it is your account's first number in the stored national form (0 and the ten digits), or empty when the account holds no number yet.Example: 911100000042 |
| flow_id | number | The id of the call menu the key was pressed on. Example: 128 |
| flow_name | string | The name of that call menu, as shown in your dashboard. Example: Main office menu |
| key_path | string | Which key was pressed. On the main menu it is the digit alone, for example 1. On a sub-menu the path is dash-joined from the top, so 2-1 is key 1 on the menu that key 2 opened. One event is sent for each press, so a caller who reaches 2-1 produces an event for 2 and then one for 2-1. 1 on the test sample.Example: 2-1 |
| key_label | string | The label you gave that key on the call menu. Empty when the key has no label. Sales on the test sample.Example: Support |
{
"id": "evt_5b8e02d94c17a3f6e0d2",
"type": "ivr.keypress",
"event": "ivr.keypress",
"created": 1780591342,
"timestamp_ist": "2026-06-04T22:12:22.518+05:30",
"test": false,
"data": {
"call_uuid": "c7d2e1f0-4a3b-4c5d-8e9f-0a1b2c3d4e5f",
"from_number": "919812345678",
"to_number": "911100000042",
"flow_id": 128,
"flow_name": "Main office menu",
"key_path": "2-1",
"key_label": "Support"
}
}The test sample
The Test button on the call menu page sends one signed sample to the address in the box (whether or not it has been saved yet) so you can check your receiver end to end. It is the same body with test true, an id prefixed evt_test_, a call_uuid of all zeros, the caller +919876543210, to_number your account's first number (or empty if you have not chosen one yet), and key 1 labelled Sales. It is one attempt with an 8 second window, and it does not touch the last delivery result shown on the page.
{
"id": "evt_test_3a9c5e17b0d4f2861c7e",
"type": "ivr.keypress",
"event": "ivr.keypress",
"created": 1780591402,
"timestamp_ist": "2026-06-04T22:13:22.000+05:30",
"test": true,
"data": {
"call_uuid": "00000000-0000-0000-0000-000000000000",
"from_number": "+919876543210",
"to_number": "01100000042",
"flow_id": 128,
"flow_name": "Main office menu",
"key_path": "1",
"key_label": "Sales"
}
}Headers, verification and delivery
- Headers. The same set as a campaign webhook, including both signatures. See Headers.
- Verification. The same secret and the same routine: HMAC-SHA256 of the raw body for version 1, and of the timestamp, a dot and the raw body for version 2. See Verifying the signature.
- Delivery. The same rule: one POST, one retry after 2 seconds on a 5xx, a timeout or an unreachable address, a 4xx is final, only a 2xx counts, and at most one redirect is followed. See Delivery. The call menu page shows the last delivery result: the time, the HTTP status or that there was no answer, and the reason when it failed.
Voice OTP
Sending a one-time code over a phone call and verifying what the recipient enters is documented in its own reference, the Voice OTP API. It uses the same base URL, credentials, envelope, error codes and rate limits as this page and adds two endpoints:
POST /otp/sendplaces a call that reads the code out and returns arequest_id.POST /otp/verifychecks the code the recipient gives you, with expiry and attempt limits handled for you.
The otp type on Place a call is a shortcut to the same verification call.
Quick start
- Copy your credentialsOpen the API tab in your Voice dashboard and copy your publishable key and secret. Reveal the secret once and store it securely.
- Pick an audio clipUpload or choose a clip in your audio library and note its id.
- Place a test call to your own numbercURL
curl https://voice.agentive.co.in/api/public/v1/calls \ -H "x-api-key: pk_live_..." \ -H "x-api-secret: sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "type": "audio_blast", "number": "98XXXXXXXX", "campaign_id": 4821, "reference_id": "test-1" }' - Poll the resultNote the
unique_idfrom the response and check its status:cURLcurl https://voice.agentive.co.in/api/public/v1/calls/run_XXXX \ -H "x-api-key: pk_live_..." \ -H "x-api-secret: sk_live_..." - Go event-drivenTo receive call results automatically instead of polling, set up a webhook. Then send an Idempotency-Key on every billed request before you go live.
Next steps
Continue with the references that share this base URL and account.