Telephony Webhooks
Telephony Webhooks
Receive call, recording and verification events the moment they happen instead of polling. Every delivery is a signed JSON event with a stable envelope.
- Payload version
- 2026-06-01
- Format
- JSON over HTTPS POST
- Signature
- HMAC-SHA256
- Timestamps
- IST and UTC
Introduction
Webhooks push events from Agentive cloud telephony to your own server. When a call or a verification code moves through its lifecycle, we send a signed HTTPS POST to each endpoint you register, so your CRM, order system or dashboard learns the outcome within seconds and you never have to poll.
Use webhooks when you need to react to a call: mark an order as confirmed when the recipient presses 1, retry a lead tomorrow after call.no_answer, unlock a signup on otp.verified, or file a recording the moment it is ready. Every event describes an object from the Voice API at https://voice.agentive.co.in/api/public/v1, so anything you learn here can be reconciled against Get call status.
Webhook delivery is free. You pay only for the calls and verification codes that generate the events, billed in INR against your Voice wallet at the rates shown in the Voice Broadcast and Voice OTP references. All timestamps are given in both Indian Standard Time and UTC.
2026-06-01, sent as api_version on every event. See Versioning for the compatibility promise.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.
How webhooks work
- Register an endpointIn the Voice dashboard, add up to 10 webhook endpoints, each with an
https://URL and a signing secret. - Choose eventsPick the events each endpoint should receive, or leave the list empty to receive every event.
- We POST the eventWhen a matching event occurs we send a JSON event object to your URL, signed in the
X-Agentive-Signature-V2header. - You verify and respondYour server checks the signature, records the event and replies with a 2xx status within 6 seconds.
- Every attempt is countedThe dashboard lists recent deliveries for each endpoint with the event, the latest result, the number of tries so far and the time in IST. A delivery that does not get a 2xx is retried for about 24 hours; see Retries and delivery.
Events are sent for calls and verification requests created through the API, and for other relevant calls on your account, whenever the event matches an endpoint's subscription.
Setting up an endpoint
Open the Webhooks tab in the Voice dashboard. From there you can:
- Add an endpoint: enter an
https://URL and either supply your own signing secret or let one be generated. - Choose events: tick the events you want, or leave them all unticked to receive every event.
- Inspect a sample payload for any event, so you can build your handler before the first live call.
- Toggle an endpoint active or inactive without deleting it.
- Send a test event to confirm your receiver and signature check work end to end.
- View recent deliveries: the event, the latest result with its response code, the number of tries so far and the time in IST.
The signing secret is shown in full once, when the endpoint is created or when you rotate it. Afterwards it is masked. Keep it server-side; it is what proves a request came from us.
https:// address on port 443 or 8443. Plain http://, other ports, and private, loopback, link-local and internal addresses are refused when you save the endpoint, and the address is checked again every time we deliver. A custom signing secret must be at least 32 characters, and an account can register up to 10 endpoints. The same address rule applies to every webhook address you give us, campaign and AI agent webhooks included, and it is checked on every delivery: an address saved earlier on plain http:// or another port receives nothing until you change it. Over HTTPS the event body and its signature cannot be read off the network; the version 2 signature then stops a captured delivery being sent back to you later. See Rejecting a replayed body.Agentive Chat as a subscriber
When your account is connected to Agentive Chat, call activity is delivered there on its own signed subscription: a call ending, a voicemail being left and a callback being requested. Your own endpoints are unaffected. Nothing is redirected or held back, and the events on this page keep arriving exactly as they do today.
Agentive Chat turns those into triggers for Ongoing WhatsApp campaigns, so a missed call or a finished call can send an approved template with nobody touching it. The events, the conditions you can filter on and the data each one carries are listed in WhatsApp Business API, When Agentive Voice is connected.
WhatsApp delivery status has a webhook of its own, on the Chat product, and it does not behave identically to this one: a bare hex signature with no prefix, different delivery headers, and three attempts over about ten seconds instead of nine over a day. The differences are set out row by row in Platform conventions above, so a shared handler can be written with its eyes open.
Events
There are twelve events, grouped by the resource they describe, plus call.analysed on accounts with the AI voice agent. The Object column is the value of data.object for that event; branch on it when one handler covers several events.
Call events describe the calls on your account, not only the ones you place through the API; via_api is true on those. An account with the AI voice agent and not voice broadcast receives events for its AI calls only; any other account, including one with neither product, receives events for all its calls.
Call lifecycle
| Event | Object | Fires when |
|---|---|---|
| call.initiated | call | A call you placed through the API is accepted and queued. |
| call.ringing | call | The recipient's phone starts ringing. Optional, and not sent for calls you place. |
| call.answered | call | The recipient answers and the call connects. |
| call.completed | call | An answered call finishes. Carries the talk duration and any keypad response. |
| call.no_answer | call | The call rang but the recipient never picked up. Safe to retry later. |
| call.busy | call | The recipient was on another call or declined. A retry can succeed. |
| call.failed | call | The call could not be placed at all (invalid number, carrier problem). Retrying is unlikely to help. |
call.analysed | (AI call results) | Accounts with the AI voice agent only. Once per answered AI call: the summary, outcome, collected answers and transcript. Documented in the AI Voice Agent API. |
Recording
| Event | Object | Fires when |
|---|---|---|
| recording.available | recording | The call recording has finished processing and is ready to download. |
Verification
| Event | Object | Fires when |
|---|---|---|
| otp.sent | verification | A voice verification call is queued for delivery. |
| otp.verified | verification | The recipient entered the correct code. |
| otp.failed | verification | The recipient entered an incorrect code. The verification stays open until it expires or runs out of attempts. |
| otp.max_attempts | verification | Too many incorrect attempts. The code is locked; send a new one to retry. |
call.no_answer when it rang unanswered, call.busy when it was busy or declined, and call.failed only for a true non-connect such as an invalid number or a carrier problem. The same reason is in the call object's end_reason.The event envelope
Every webhook body is a single JSON object with the same outer envelope. Only the data block changes between events.
{
"id": "evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d",
"object": "event",
"api_version": "2026-06-01",
"type": "call.completed",
"created": 1780589528,
"occurred_at": "2026-06-04T16:12:08.512Z",
"timestamp_ist": "2026-06-04T21:42:08.512+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "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",
"duration_sec": 42,
"end_reason": "completed",
"campaign_id": 4821,
"campaign_type": "audio_blast",
"agent_id": null,
"reference_id": "order-99213",
"response": {
"disposition": null,
"digit": null,
"via": null,
"answers": null,
"voicemail_left": null
}
},
"event": "call.completed",
"event_id": "evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d"
}Envelope fields
| Field | Type | Description |
|---|---|---|
| id | string | Unique id for this event, prefixed evt_. Use it to make your handler idempotent.Example: evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d |
| object | string | Always "event". |
| api_version | string | The payload version used to build this event. Example: 2026-06-01 |
| type | string | The event name in resource.action form.Example: call.completed |
| created | integer | When the event was generated, as a Unix epoch timestamp in seconds. Example: 1780589528 |
| occurred_at | string | The same instant in UTC, ISO 8601 with a Z suffix. Example: 2026-06-04T16:12:08.512Z |
| timestamp_ist | string | The same instant in Indian Standard Time, ISO 8601 with a +05:30 offset. Example: 2026-06-04T21:42:08.512+05:30 |
| org_id | integer | Your account id. |
| reference_id | string | null | The reference_id you supplied when you placed the call or sent the code. null if you did not send one. |
| livemode | boolean | Always true. There is no separate sandbox mode; see Testing. |
| data | object | The event-specific record. Its object field names the resource type: call, recording or verification. |
| event | string | Alias of type. Kept for compatibility; prefer type. |
| event_id | string | Alias of id. Kept for compatibility; prefer id. |
idandevent_idare the same value. Preferid.typeandeventare the same value. Prefertype.reference_idappears both at the top level and insidedata, so you can read it wherever is convenient.- New fields may be added over time. Ignore fields you do not recognise.
Headers
Each delivery carries these headers:
POST /webhooks/agentive HTTP/1.1
Host: example.com
Content-Type: application/json
X-Agentive-Event: call.completed
X-Agentive-Event-Id: evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d
X-Agentive-Delivery-Id: dlv_7c1e93a04b6d48f2b0a5d3e81c9f2740
X-Agentive-Attempt: 1
X-Agentive-Timestamp: 1780589528
X-Agentive-Signature-V2: t=1780589528,v2=9c41ab2f7e05d3c6b8a1f4e2d0c9b7a6e5f4d3c2b1a09f8e7d6c5b4a3f2e1d0c
X-Agentive-Signature: sha256=4f1d0c7b2a9e8d6f5c4b3a2918e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1908e9
User-Agent: Agentive-Webhooks/2026-06-01| Header | Type | Description |
|---|---|---|
| Content-Type | string | The body is always JSON. Example: application/json |
| X-Agentive-Event | string | The event name (the same value as type in the body), so you can route without parsing the body first.Example: call.completed |
| X-Agentive-Event-Id | string | The same value as id in the body. Shared by every endpoint that receives this event. De-duplicate on it.Example: evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d |
| X-Agentive-Delivery-Id | string | This event on its way to this endpoint. Stable across every retry of the delivery, so it is the right key for your own delivery log. Two endpoints receiving one event get two delivery ids and one event id. Example: dlv_7c1e93a04b6d48f2b0a5d3e81c9f2740 |
| X-Agentive-Attempt | string | Which attempt this is, counting from 1 and continuing through the whole retry schedule. Anything above 1 means an earlier attempt did not get a 2xx from you.Example: 1 |
| X-Agentive-Timestamp | string | Unix seconds at the moment of this attempt, so it moves between retries of the same event. It is the same value as t in X-Agentive-Signature-V2. On its own it proves nothing: check the age against the signed t, as described in Rejecting a replayed body.Example: 1780589528 |
| X-Agentive-Signature-V2 | string | t=<unix seconds>,v2=<hex>: HMAC-SHA256 of t, a dot and the raw body, keyed with the endpoint's signing secret, recomputed on every attempt. The one to verify. See Verifying the signature.Example: t=1780589528,v2=9c41...1d0c |
| X-Agentive-Signature | string | The older signature: HMAC-SHA256 of the raw body alone, with a sha256= prefix, identical on every attempt. Still sent for receivers built before version 2. Do not accept it on its own: it cannot tell a fresh delivery from a replayed one.Example: sha256=4f1d...08e9 |
| User-Agent | string | Identifies the delivery agent. The version is the payload version, so it changes only when api_version does.Example: Agentive-Webhooks/2026-06-01 |
Verifying the signature
Always verify the signature before trusting a webhook. It proves the request came from us, that the body was not altered and that it was sent in the last five minutes. Verify X-Agentive-Signature-V2: an HMAC-SHA256, with your endpoint's signing secret, of the timestamp t, a dot and the exact raw request body, hex-encoded.
X-Agentive-Signature-V2: t=1780589528,v2=9c41ab2f7e05d3c6b8a1f4e2d0c9b7a6e5f4d3c2b1a09f8e7d6c5b4a3f2e1d0c- Read the raw bodyTake the request body as bytes, before any JSON parsing or re-serialising. Re-serialising can change whitespace and break the check.
- Check the timeSplit the header on the comma. Reject the request if
tis not a number or is more than 300 seconds from your own clock. - Compute the digestCompute
HMAC-SHA256(secret, t + "." + rawBody)and hex-encode it. - Compare in constant timeCheck that it has the same length in bytes as
v2, then compare the two with a constant-time comparison. Reject the request on any mismatch, and when the header is missing.
X-Agentive-Signature-V2, respond with a 401 and do not process the event. Never run the check with an empty secret: the samples below refuse to start without one.Rejecting a replayed body
Every delivery carries two signatures, and they do different jobs.
X-Agentive-Signature(the older one) covers the raw body and nothing else. It proves a body came from us, but not when: all nine attempts of one delivery carry the same value, so someone who captured a delivery could send those bytes back later and it would still check out. Do not accept it on its own.X-Agentive-Signature-V2covers the timestamp and the body, ast=<unix seconds>,v2=<hex digest>where the digest is computed over"<t>.<body>". Thetis the same value asX-Agentive-Timestamp, and it is recomputed on every attempt.
X-Agentive-Timestamp to whatever they like, or strip the version 2 header and hope you fall back. Verify V2, reject anything older than five minutes, and refuse a delivery without it. Every delivery carries it.Then de-duplicate as well, never instead. Record the event id (the id field, mirrored in X-Agentive-Event-Id) and ignore anything you have already handled, so our own retries are harmless too. If one of your endpoints can lag behind another, de-duplicate on X-Agentive-Delivery-Id instead, which is unique per endpoint and stable across all nine attempts.
// Two checks, both needed. V2 signs the timestamp WITH the body, so an age
// check against it is meaningful. De-duplication then makes our own retries
// harmless too. rawBody is the Buffer from express.raw().
const parts = {};
for (const piece of String(req.header("X-Agentive-Signature-V2") || "").split(",")) {
const i = piece.indexOf("=");
if (i > 0) parts[piece.slice(0, i).trim()] = piece.slice(i + 1).trim();
}
const t = Number(parts.t);
// 1. Is it ours, and is it recent? Reject anything older than five minutes.
const fresh =
Number.isFinite(t) && Math.abs(Math.floor(Date.now() / 1000) - t) <= 300;
const expected = Buffer.from(
crypto
.createHmac("sha256", SIGNING_SECRET)
.update(String(t) + ".")
.update(rawBody) // the EXACT bytes received
.digest("hex"),
"utf8"
);
const given = Buffer.from(parts.v2 || "", "utf8");
if (!fresh || given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.status(401).send("bad signature");
}
// 2. Have we already handled it? Our retries carry the same id.
const evt = JSON.parse(rawBody.toString("utf8"));
const eventId = evt.id; // also X-Agentive-Event-Id
if (await seen.has(eventId)) {
return res.status(200).send("ok"); // already handled, do nothing
}
await seen.add(eventId, { ttlSeconds: 172800 }); // 48 h covers the retry tail
await handle(evt);
return res.status(200).send("ok");Together the two checks mean a request is acted on only when it is genuinely ours, genuinely recent, and genuinely new.
Example handlers
Each handler reads the raw body, verifies the version 2 signature with a five minute window in constant time, then switches on type. Store the secret in an environment variable such as AGENTIVE_WEBHOOK_SECRET; every handler refuses to start without it.
import express from "express";
import crypto from "crypto";
const SECRET = process.env.AGENTIVE_WEBHOOK_SECRET;
if (!SECRET) throw new Error("AGENTIVE_WEBHOOK_SECRET is not set"); // never verify with an empty key
const TOLERANCE_SEC = 300; // five minutes
const app = express();
// Capture the RAW body so the signature is computed over the exact bytes.
app.use("/webhooks/agentive", express.raw({ type: "application/json" }));
function verifyV2(rawBody, header) {
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");
const a = Buffer.from(parts.v2, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/agentive", (req, res) => {
// Version 2 only: a delivery without X-Agentive-Signature-V2 is refused.
if (!verifyV2(req.body, req.header("X-Agentive-Signature-V2"))) {
return res.status(401).send("bad signature");
}
const evt = JSON.parse(req.body.toString("utf8"));
// Idempotency: skip if you have already processed evt.id.
switch (evt.type) {
case "call.completed":
// evt.data is a call object
console.log("call done", evt.data.unique_id, evt.data.duration_sec);
break;
case "otp.verified":
console.log("verified", evt.data.request_id, evt.reference_id);
break;
default:
// Ignore events you do not handle.
break;
}
res.sendStatus(200);
});
app.listen(3000);Reproducing a signature by hand
To confirm your tooling, compute the version 2 signature for a known timestamp and body from the shell and compare it with what your handler produces. The secret is read from the environment, so it never appears in your shell history or the process list:
# AGENTIVE_WEBHOOK_SECRET must already be set in your environment.
T=1780589528
BODY='{"type":"call.completed","id":"evt_abc","created":1780589528,"data":{}}'
printf '%s.%s' "$T" "$BODY" | python3 -c '
import hashlib, hmac, os, sys
key = os.environb[b"AGENTIVE_WEBHOOK_SECRET"]
print(hmac.new(key, sys.stdin.buffer.read(), hashlib.sha256).hexdigest())'
# Compare the result with the v2= part of X-Agentive-Signature-V2 (t=$T).Call events
Every call.* event carries a call object in data. The same fields are present on each event; fields not yet known at that point in the lifecycle are null. The table under each event lists the fields whose value is specific to that event, followed by the full body we POST.
call.initiated
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "queued". |
| verification_status | string | Always "pending" — the code is out and nothing has been entered yet. |
| call_id | string | null | Our record id for the leg, present as soon as it exists. |
| duration_sec | null | Set on the terminal event. |
| end_reason | null | Set on the terminal event. |
{
"id": "evt_a17c2f9b4d6e4a1c8f0b3d5e7a9c1b2d",
"object": "event",
"api_version": "2026-06-01",
"type": "call.initiated",
"created": 1780589500,
"occurred_at": "2026-06-04T16:11:40.000Z",
"timestamp_ist": "2026-06-04T21:41:40.000+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "call",
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "queued",
"duration_sec": null,
"end_reason": null,
"campaign_id": 4821,
"campaign_type": "audio_blast",
"agent_id": null,
"reference_id": "order-99213",
"response": {
"disposition": null,
"digit": null,
"via": null,
"answers": null,
"voicemail_left": null
}
},
"event": "call.initiated",
"event_id": "evt_a17c2f9b4d6e4a1c8f0b3d5e7a9c1b2d"
}call.ringing
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "ringing". |
| call_id | string | Our record id for the leg. |
| duration_sec | null | Set on the terminal event. |
{
"id": "evt_b28d3f0c5e7f5b2d9a1c4e6f8b0d2c3e",
"object": "event",
"api_version": "2026-06-01",
"type": "call.ringing",
"created": 1780589505,
"occurred_at": "2026-06-04T16:11:45.000Z",
"timestamp_ist": "2026-06-04T21:41:45.000+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "call",
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "ringing",
"duration_sec": null,
"end_reason": null,
"campaign_id": 4821,
"campaign_type": "audio_blast",
"agent_id": null,
"reference_id": "order-99213",
"response": {
"disposition": null,
"digit": null,
"via": null,
"answers": null,
"voicemail_left": null
}
},
"event": "call.ringing",
"event_id": "evt_b28d3f0c5e7f5b2d9a1c4e6f8b0d2c3e"
}call.ringing is only emitted for calls handled on the central media node, which in practice means inbound calls to your numbers. Calls you place through campaigns, the API or verification do not produce it, and even an inbound call may skip it. Never gate your logic on it: a call can go straight from call.initiated to call.answered or to a terminal event without ringing ever arriving.call.answered
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "in_progress". |
| duration_sec | null | Set on completion. |
| end_reason | null | Set on completion. |
{
"id": "evt_c39e4a1d6f8a6c3e0b2d5f7a9c1e3d4f",
"object": "event",
"api_version": "2026-06-01",
"type": "call.answered",
"created": 1780589512,
"occurred_at": "2026-06-04T16:11:52.000Z",
"timestamp_ist": "2026-06-04T21:41:52.000+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "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",
"duration_sec": null,
"end_reason": null,
"campaign_id": 4821,
"campaign_type": "audio_blast",
"agent_id": null,
"reference_id": "order-99213",
"response": {
"disposition": null,
"digit": null,
"via": null,
"answers": null,
"voicemail_left": null
}
},
"event": "call.answered",
"event_id": "evt_c39e4a1d6f8a6c3e0b2d5f7a9c1e3d4f"
}call.completed
end_reason "voicemail".Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "completed". |
| duration_sec | integer | Talk time in seconds. May be 0. |
| end_reason | string | "completed", or "voicemail" when a voicemail system answered. |
| response | object | How the recipient responded on Interactive Voice calls; see the call object. Every field is null on calls with no interactive step. |
{
"id": "evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d",
"object": "event",
"api_version": "2026-06-01",
"type": "call.completed",
"created": 1780589528,
"occurred_at": "2026-06-04T16:12:08.512Z",
"timestamp_ist": "2026-06-04T21:42:08.512+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "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",
"duration_sec": 42,
"end_reason": "completed",
"campaign_id": 4821,
"campaign_type": "press1",
"agent_id": null,
"reference_id": "order-99213",
"response": {
"disposition": "interested",
"digit": "1",
"via": "keypad",
"answers": null,
"voicemail_left": null
}
},
"event": "call.completed",
"event_id": "evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d"
}The sample above is an Interactive Voice (press1) call where the recipient pressed 1.
call.no_answer
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "missed". |
| duration_sec | integer | Always 0. |
| end_reason | string | Always "no_answer". |
{
"id": "evt_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"object": "event",
"api_version": "2026-06-01",
"type": "call.no_answer",
"created": 1780589545,
"occurred_at": "2026-06-04T16:12:25.000Z",
"timestamp_ist": "2026-06-04T21:42:25.000+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "call",
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "missed",
"duration_sec": 0,
"end_reason": "no_answer",
"campaign_id": 4821,
"campaign_type": "audio_blast",
"agent_id": null,
"reference_id": "order-99213",
"response": {
"disposition": null,
"digit": null,
"via": null,
"answers": null,
"voicemail_left": null
}
},
"event": "call.no_answer",
"event_id": "evt_0a1b2c3d4e5f60718293a4b5c6d7e8f9"
}call.busy
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "missed". |
| duration_sec | integer | Always 0. |
| end_reason | string | "busy" when the line was busy, "rejected" when the recipient actively declined. |
{
"id": "evt_1b2c3d4e5f60718293a4b5c6d7e8f9a0",
"object": "event",
"api_version": "2026-06-01",
"type": "call.busy",
"created": 1780589548,
"occurred_at": "2026-06-04T16:12:28.000Z",
"timestamp_ist": "2026-06-04T21:42:28.000+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "call",
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "missed",
"duration_sec": 0,
"end_reason": "busy",
"campaign_id": 4821,
"campaign_type": "audio_blast",
"agent_id": null,
"reference_id": "order-99213",
"response": {
"disposition": null,
"digit": null,
"via": null,
"answers": null,
"voicemail_left": null
}
},
"event": "call.busy",
"event_id": "evt_1b2c3d4e5f60718293a4b5c6d7e8f9a0"
}call.failed
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "failed". |
| verification_status | string | Always "pending" — a wrong code does not close the verification, so this is NOT "failed". |
| duration_sec | integer | Always 0. |
| end_reason | string | "failed", or "canceled" when the call was cancelled before it connected. |
{
"id": "evt_d40f5b2e7a9b7d4f1c3e6a8b0d2f4e5a",
"object": "event",
"api_version": "2026-06-01",
"type": "call.failed",
"created": 1780589560,
"occurred_at": "2026-06-04T16:12:40.000Z",
"timestamp_ist": "2026-06-04T21:42:40.000+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "call",
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "failed",
"duration_sec": 0,
"end_reason": "failed",
"campaign_id": 4821,
"campaign_type": "audio_blast",
"agent_id": null,
"reference_id": "order-99213",
"response": {
"disposition": null,
"digit": null,
"via": null,
"answers": null,
"voicemail_left": null
}
},
"event": "call.failed",
"event_id": "evt_d40f5b2e7a9b7d4f1c3e6a8b0d2f4e5a"
}Recording events
The recording event carries a recording object in data. It is sent after the call ends, once the audio has been processed.
recording.available
Key fields in data
| Field | Type | Description |
|---|---|---|
| recording_url | string | An authenticated API URL. Fetch it with your API key and secret; see Download a recording. |
| format | string | "ogg" or "wav". |
| duration_sec | integer | The recorded length in seconds. |
| size_bytes | integer | The file size in bytes. |
| unique_id | string | The public id of the call this recording belongs to. |
| via_api | boolean | true when the call was placed through the API. |
| metadata | object | null |
{
"id": "evt_2c3d4e5f60718293a4b5c6d7e8f9a0b1",
"object": "event",
"api_version": "2026-06-01",
"type": "recording.available",
"created": 1780589575,
"occurred_at": "2026-06-04T16:12:55.000Z",
"timestamp_ist": "2026-06-04T21:42:55.000+05:30",
"org_id": 42,
"reference_id": "order-99213",
"livemode": true,
"data": {
"object": "recording",
"unique_id": "run_4821",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"recording_url": "https://voice.agentive.co.in/api/public/v1/calls/a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d/recording",
"format": "ogg",
"duration_sec": 42,
"size_bytes": 126976,
"campaign_id": 4821,
"campaign_type": "audio_blast",
"reference_id": "order-99213",
"metadata": null,
"via_api": true
},
"event": "recording.available",
"event_id": "evt_2c3d4e5f60718293a4b5c6d7e8f9a0b1"
}Verification events
Every otp.* event carries a verification object in data. These events pair with the Voice OTP API: send a code, then let the events tell you whether it was delivered, verified, or locked.
otp.sent
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "queued". |
| verified | null | Not yet known. |
| expires_in_sec | integer | Seconds until the code expires. 600 (10 minutes) unless you set ttl on the send. |
| max_attempts | integer | Allowed verify attempts. |
{
"id": "evt_e51a6c3f8b0c8e5a2d4f7b9c1e3a5f6b",
"object": "event",
"api_version": "2026-06-01",
"type": "otp.sent",
"created": 1780590000,
"occurred_at": "2026-06-04T16:20:00.000Z",
"timestamp_ist": "2026-06-04T21:50:00.000+05:30",
"org_id": 42,
"reference_id": "signup-8841",
"livemode": true,
"data": {
"object": "verification",
"request_id": "d29b8e7a-3c41-4f0a-9b2d-7e1c5a6f8b90",
"number": "+919812345678",
"status": "queued",
"verification_status": "pending",
"verified": null,
"attempts_left": null,
"expires_in_sec": 300,
"max_attempts": 3,
"campaign_id": 5567,
"reference_id": "signup-8841"
},
"event": "otp.sent",
"event_id": "evt_e51a6c3f8b0c8e5a2d4f7b9c1e3a5f6b"
}otp.verified
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "verified". |
| verification_status | string | Always "verified". |
| verified | boolean | Always true. |
{
"id": "evt_f62b7d4a9c1d9f6b3e5a8c0d2f4b6a7c",
"object": "event",
"api_version": "2026-06-01",
"type": "otp.verified",
"created": 1780590075,
"occurred_at": "2026-06-04T16:21:15.000Z",
"timestamp_ist": "2026-06-04T21:51:15.000+05:30",
"org_id": 42,
"reference_id": "signup-8841",
"livemode": true,
"data": {
"object": "verification",
"request_id": "d29b8e7a-3c41-4f0a-9b2d-7e1c5a6f8b90",
"number": "+919812345678",
"status": "verified",
"verification_status": "verified",
"verified": true,
"attempts_left": null,
"expires_in_sec": null,
"max_attempts": null,
"campaign_id": 5567,
"reference_id": "signup-8841"
},
"event": "otp.verified",
"event_id": "evt_f62b7d4a9c1d9f6b3e5a8c0d2f4b6a7c"
}otp.failed
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "failed". |
| verified | boolean | Always false. |
| attempts_left | integer | How many verify attempts remain (1 or more). |
| max_attempts | integer | Allowed verify attempts. |
{
"id": "evt_3d4e5f60718293a4b5c6d7e8f9a0b1c2",
"object": "event",
"api_version": "2026-06-01",
"type": "otp.failed",
"created": 1780590050,
"occurred_at": "2026-06-04T16:20:50.000Z",
"timestamp_ist": "2026-06-04T21:50:50.000+05:30",
"org_id": 42,
"reference_id": "signup-8841",
"livemode": true,
"data": {
"object": "verification",
"request_id": "d29b8e7a-3c41-4f0a-9b2d-7e1c5a6f8b90",
"number": "+919812345678",
"status": "failed",
"verification_status": "pending",
"verified": false,
"attempts_left": 2,
"expires_in_sec": null,
"max_attempts": 3,
"campaign_id": 5567,
"reference_id": "signup-8841"
},
"event": "otp.failed",
"event_id": "evt_3d4e5f60718293a4b5c6d7e8f9a0b1c2"
}otp.max_attempts
Values at this event
| Field | Type | Description |
|---|---|---|
| status | string | Always "max_attempts". |
| verification_status | string | Always "locked". |
| verified | boolean | Always false. |
| attempts_left | integer | Always 0. |
| max_attempts | integer | Allowed verify attempts. |
{
"id": "evt_4e5f60718293a4b5c6d7e8f9a0b1c2d3",
"object": "event",
"api_version": "2026-06-01",
"type": "otp.max_attempts",
"created": 1780590120,
"occurred_at": "2026-06-04T16:22:00.000Z",
"timestamp_ist": "2026-06-04T21:52:00.000+05:30",
"org_id": 42,
"reference_id": "signup-8841",
"livemode": true,
"data": {
"object": "verification",
"request_id": "d29b8e7a-3c41-4f0a-9b2d-7e1c5a6f8b90",
"number": "+919812345678",
"status": "max_attempts",
"verification_status": "locked",
"verified": false,
"attempts_left": 0,
"expires_in_sec": null,
"max_attempts": 3,
"campaign_id": 5567,
"reference_id": "signup-8841"
},
"event": "otp.max_attempts",
"event_id": "evt_4e5f60718293a4b5c6d7e8f9a0b1c2d3"
}The call object
data.object is "call" for every call.* event. The same fields are present on each call event; which values are known depends on where the call is in its lifecycle.
| Field | Type | Description |
|---|---|---|
| object | string | Always "call". |
| unique_id | string | The call's public id, the same value Get call status returns. For API-placed calls it looks like run_4821; for a call placed with POST /ai/calls it is the dial id that request returned, on every event of the call. |
| call_id | string | null | Our record id for the call leg. Present as soon as the leg exists, which for an API-placed call is usually already true on call.initiated. null only while it is not yet known. |
| 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: a call placed straight to an AI agent is one, because the id we returned when you placed it is the dial leg. null on every other call. Match on unique_id; either id resolves on Get call status. |
| direction | string | outbound or inbound. |
| from_number | string | The caller number shown to the recipient. |
| to_number | string | The recipient number in E.164 form. Example: +919812345678 |
| status | string | The call state at this event. A closed set; see Status and end reasons. |
| duration_sec | integer | null | Talk duration in seconds. Set on the terminal events ( call.completed, call.no_answer, call.busy, call.failed), null before. |
| end_reason | string | null | Why the call ended. A closed set; see Status and end reasons. Set on the terminal events, null before. |
| campaign_id | integer | null | The run this call belongs to; for an AI voice agent API campaign, the campaign you triggered. On any other run it matches the digits in unique_id. |
| campaign_type | string | null | audio_blast (Audio Blast), press1 (Interactive Voice), otp (verification) or ai_agent (an AI voice agent took the conversation). null for calls that are not part of an API run. |
| agent_id | integer | null | The AI voice agent that handled the call. null on a recorded or Interactive Voice call, and on any call a person handled. |
| reference_id | string | null | Your correlation key. |
| metadata | object | null | The metadata you sent when you placed the call through the API (a number comes back as text). null otherwise. |
| via_api | boolean | true when the call was placed through the API. Events also describe calls placed from the dashboard and incoming calls. |
| charge_inr | number | null | AI calls, terminal events only: what the call cost your wallet, in rupees. 0 when it never connected, null when an answered call is not billed yet and on every other event. |
| response | object | How the recipient responded. The block is present on every call event; every field inside it is null until there is a response to report, so it stays all-null on the earlier events and on calls with no interactive step. |
| response.disposition | string | null | interested or not_interested when the recipient responded to an interest-capture call. |
| response.digit | string | null | The first key the recipient pressed. |
| response.via | string | null | How the response was given: keypad or voice. |
| response.answers | object | null | Per-question answers for guided-flow calls. |
| response.voicemail_left | boolean | null | true when the recipient left a voicemail in the flow. |
queued (initiated), then ringing, then in_progress (answered), then completed. A call that rang without being answered ends as missed, and one that could not be placed ends as failed.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.You can branch either on the event type or on data.status and data.end_reason; they are kept in step, because data.status is derived from the event name rather than passed in by whatever raised it. The same enumerated values are returned by List calls.
status. A call that rang unanswered or was busy could arrive as "failed" instead of "missed", and a call that genuinely could not be placed could arrive as "missed" instead of "failed", depending on which part of the network raised the event. The values are now derived from the event name, so call.no_answer and call.busy are always missed and call.failed is always failed. If your handler worked around the old inconsistency by reading end_reason instead, it keeps working unchanged.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).The recording object
data.object is "recording" for the recording.available event.
| Field | Type | Description |
|---|---|---|
| object | string | Always "recording". |
| unique_id | string | The call's public id, the same value Get call status returns. |
| call_id | string | null | Our internal identifier for the call this recording belongs to. |
| leg_id | string | null | The same second id the call object carries, or null. Added September 2026. Match on unique_id. |
| recording_url | string | An authenticated API URL for the audio. Fetch it with your API key and secret; see Download a recording. |
| format | string | The audio format: "ogg" or "wav". |
| duration_sec | integer | null | The recorded length in seconds. |
| size_bytes | integer | null | The file size in bytes. |
| campaign_id | integer | null | The run this call belongs to; for an AI voice agent API campaign, the campaign you triggered. null when the call is not part of a run. |
| campaign_type | string | null | audio_blast (Audio Blast), press1 (Interactive Voice), otp (verification) or ai_agent (an AI voice agent took the conversation). The same value the call events carry. |
| reference_id | string | null | Your correlation key. |
| metadata | object | null | The metadata you sent when you placed the call through the API (a number comes back as text). null otherwise. |
| via_api | boolean | true when the call was placed through the API, the same value the call events carry. |
recording_url points at Download a recording. Call it with your API key and secret; it streams the audio and supports HTTP Range requests.The verification object
data.object is "verification" for otp.sent, otp.verified, otp.failed and otp.max_attempts.
| Field | Type | Description |
|---|---|---|
| object | string | Always "verification". |
| request_id | string | The verification request id returned by Send a code. |
| number | string | The recipient's number in E.164 form. Example: +919812345678 |
| status | string | queued on otp.sent, verified on otp.verified, failed on otp.failed, max_attempts on otp.max_attempts. |
| verification_status | string | Where the verification stands, independent of which event you are looking at: pending while it is still open (both otp.sent and otp.failed), verified once the correct code is entered, and locked when the attempt allowance is spent. Branch on this rather than on status if you want the state of the verification rather than the meaning of the event. |
| verified | boolean | null | null on otp.sent, true on otp.verified, false on otp.failed and otp.max_attempts. |
| attempts_left | integer | null | Remaining verify attempts. Present on otp.failed (1 or more) and otp.max_attempts (0). |
| expires_in_sec | integer | null | Seconds until the code expires. Present on otp.sent.Example: 600 |
| max_attempts | integer | null | Allowed verify attempts. Present on otp.sent, otp.failed and otp.max_attempts. |
| campaign_id | integer | null | The delivery run, if any. |
| reference_id | string | null | Your correlation key. |
Responding to a webhook
Reply with any 2xx status once you have safely received the event; 200 with an empty body is fine. Reply quickly and do the heavy work asynchronously: each attempt has a 6 second timeout, and a slow endpoint is treated as a failed attempt.
| Your response | What we do |
|---|---|
2xx | Marked delivered. No further attempts for this event. |
3xx | Not followed. Counted as a failed attempt and retried, so a signed body is never forwarded to another host. |
4xx or 5xx | Counted as a failed attempt and retried, for up to nine attempts over about 24 hours. |
| No reply within 6 s | Counted as a failed attempt and retried. |
| Connection error | Counted as a failed attempt and retried. |
Return 2xx only after you have durably accepted the event (written it to a queue or a database). If you return 2xx and then crash, the event is not resent.
Retries and delivery
A delivery is one event on its way to one endpoint. It gets nine attempts over about 24 hours: three quick ones for a receiver that is briefly busy, then six spread out for one that is down. Every attempt sends the same body, with a version 2 signature recomputed for that attempt's timestamp, under the same X-Agentive-Delivery-Id, with an increasing X-Agentive-Attempt and a fresh X-Agentive-Timestamp.
| Attempt | Sent after the previous attempt | Note |
|---|---|---|
| 1 | Immediately | As soon as the event is generated. |
| 2 | 0.5 seconds | |
| 3 | 2 seconds | End of the fast path. |
| 4 | 1 minute | |
| 5 | 5 minutes | |
| 6 | 30 minutes | |
| 7 | 2 hours | |
| 8 | 6 hours | |
| 9 | 12 hours | The last attempt. After this the delivery is marked failed. |
X-Agentive-Event: call.completed
X-Agentive-Event-Id: evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d
X-Agentive-Delivery-Id: dlv_7c1e93a04b6d48f2b0a5d3e81c9f2740
X-Agentive-Attempt: 3
X-Agentive-Timestamp: 1780589531
X-Agentive-Signature-V2: t=1780589531,v2=2d7e9a0c4f1b8e6d3a5c7f9b0e2d4a6c8f1b3e5d7a9c0f2e4b6d8a1c3e5f7b9d
X-Agentive-Signature: sha256=4f1d0c7b2a9e8d6f5c4b3a2918e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1908e9- Each attempt has a 6 second timeout. A slow endpoint is a failed attempt.
- A delivery succeeds as soon as your endpoint returns a 2xx, and no further attempts are made.
- The delivery is written down before the first attempt, so a restart on our side cannot lose it. A delivery left mid-flight by a restart is picked up and continued.
- After the ninth attempt the delivery is marked failed and no further attempt is made. Recover a missed event from the API: poll Get call status for a specific call, or page through List calls. Build your system so a missed webhook is not the only way you learn a call's outcome.
What the dashboard shows
The Webhooks tab in the Voice dashboard lists recent deliveries under each endpoint, one row per event per endpoint. Each row carries four columns:
| Column | What it tells you |
|---|---|
| Event | The event name. |
| Result | Delivered with the HTTP status your endpoint returned, or Failed with that status. A network error or a timeout shows as Failed with no response. |
| Tries | How many attempts have been made so far, out of nine. |
| When (IST) | When the event was generated, in Indian Standard Time. |
An event delivered to three endpoints is three rows under three endpoints, each retrying on its own schedule, so a healthy endpoint is never held back by a broken one. Match a row to what your receiver saw through the event name and the time; your own log, keyed on X-Agentive-Delivery-Id, is the precise record.
Ordering and idempotency
- Receiver-side dedup. The same
id(evt_...) is sent to every endpoint that receives a given event, and is repeated across all nine attempts of that delivery. Treat an event as already handled if you have seen itsidbefore. If one endpoint of yours is behind another, de-duplicate onX-Agentive-Delivery-Idinstead, which is unique per endpoint. - Ordering is not guaranteed. Events may arrive out of order, and the retry tail widens the gap: a
call.answeredyour endpoint refused could land hours after thecall.completedit accepted. Order by the envelope timestamp (created, oroccurred_at/timestamp_ist) and thestatusfield, never by arrival order. - A call typically produces this sequence:
call.initiated(API-placed calls only), thencall.ringing(inbound only), thencall.answeredandcall.completedif it connects, or one ofcall.no_answer,call.busyorcall.failedif it does not, followed byrecording.availableonce the audio is processed. Build your logic around the terminal event rather than the intermediate ones. - Request idempotency. The call-placing endpoints accept an
Idempotency-Keyheader, so a network retry with the same key replays the original response instead of placing a second call (and producing a second stream of events). See Idempotency in the Voice Broadcast reference.
Testing
From the Webhooks tab in the Voice dashboard:
- View sample payloads shows the exact body for each event, so you can build and unit-test your handler before any live traffic.
- Send test event delivers a sample
call.completedto a single endpoint. The body is built by the same code as a live delivery and signed the same way, right down to the event id shape, so you can confirm your receiver and signature verification end to end. The dashboard reports the response code and attempts.
To tell a test apart from real traffic it carries "test": true inside data and a reference_id of test-reference. Its unique_id is run_0 and its campaign_id is 0, neither of which can belong to a real call:
{
"id": "evt_9f8e7d6c5b4a39281706f5e4d3c2b1a0",
"object": "event",
"api_version": "2026-06-01",
"type": "call.completed",
"created": 1780591200,
"occurred_at": "2026-06-04T16:40:00.000Z",
"timestamp_ist": "2026-06-04T22:10:00.000+05:30",
"org_id": 42,
"reference_id": "test-reference",
"livemode": true,
"data": {
"object": "call",
"unique_id": "run_0",
"call_id": "a1b2c3d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
"leg_id": null,
"direction": "outbound",
"from_number": "01100000021",
"to_number": "+919812345678",
"status": "completed",
"duration_sec": 42,
"end_reason": "completed",
"campaign_id": 0,
"campaign_type": "audio_blast",
"agent_id": null,
"reference_id": "test-reference",
"response": {
"disposition": null,
"digit": null,
"via": null,
"answers": null,
"voicemail_left": null
},
"test": true
},
"event": "call.completed",
"event_id": "evt_9f8e7d6c5b4a39281706f5e4d3c2b1a0"
}livemode is always true, and the only marker on a manually sent test event is the data.test flag above (never present on real events). A request-bin style URL is a quick way to inspect the raw body, headers and signature during integration.Versioning
- Every event carries
api_version. The current version is2026-06-01. - We may add new fields to the envelope or to any
dataobject at any time without changing the version. Your handler must ignore fields it does not recognise. - We may add new event types. An endpoint subscribed to all events will start receiving them; switch on
typeand ignore events you do not handle. - A removed or renamed field is a breaking change and would come with a new
api_version. We do not change the meaning of an existing field in place.
Security checklist
Before you go live
- Verify
X-Agentive-Signature-V2on every request; reject anything that does not match, is older than five minutes or does not carry it. - Compare signatures in constant time, after checking their byte lengths match.
- De-duplicate on
idbefore you act on an event, so a retry changes nothing. Check age against the signedt, never againstX-Agentive-Timestampalone. - Use the raw request body for the HMAC, not a re-serialised object.
- Keep the signing secret server-side, and refuse to start a receiver without it; rotate it from the dashboard if it is ever exposed.
- Serve the endpoint over HTTPS in production. It is the only thing that stops a body and its signature being read off the network and sent back to you.
- Return 2xx only after you have durably accepted the event.
- Reconcile against List calls so a dropped delivery cannot leave a call unaccounted for.
Quick start
Five steps from an empty endpoint to a verified, live event stream. You need a Voice API key and secret from the Voice dashboard and a public HTTPS URL.
- Add an endpointIn the Voice dashboard, open Webhooks, add your
https://URL and copy the signing secret into an environment variable such asAGENTIVE_WEBHOOK_SECRET. Leave the event list empty to receive everything. - Deploy a receiverStart from one of the example handlers. It must read the raw body, verify the signature in constant time and return 200 within 6 seconds.
- Send a test eventClick Send test event on the endpoint. The delivery log should show a 200. If it shows 401, your signature check is reading a parsed body instead of the raw bytes.
- Place a real call to your own numberUse the Voice Broadcast API with a number you can answer. Within a minute your endpoint receives
call.initiated, a terminal event, andrecording.available, all sharing the samedata.unique_id.curl -X POST 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": "9812345678", "audio_id": 12, "reference_id": "webhook-test-1" }' - Store the event id and reconcilePersist each
idbefore you act on it, and poll Get call status for any call whose terminal event has not arrived, so a dropped delivery never leaves a call unresolved.
Next steps
The events on this page describe calls and verification codes created through the Voice APIs. WhatsApp message delivery has its own webhook, documented with the WhatsApp Business API.
Send approved WhatsApp template messages with media and buttons from your own systems, tag contacts, and get delivery status on a webhook.
Read the referencePlace recorded voice calls, trigger a saved campaign for one number, poll call status, list calls and download recordings.
Read the referenceHave your AI voice agent call a customer from your own code, pass the details it should use, and get the transcript, analysis and recording back.
Read the referenceRead a one-time code out to a customer over a phone call and verify it, with expiry and attempt limits handled for you.
Read the reference