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.

Current payload version
The current payload version is 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 APIsWhatsApp Business API
Base URLhttps://voice.agentive.co.in/api/public/v1https://app.agentive.co.in/api/v1
CoversVoice 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.
TransportHTTPS only, JSON in and JSON out.HTTPS only, JSON in and JSON out.

Authentication

Voice APIsWhatsApp Business API
CredentialOne 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 itx-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>.
ScopeAccount-wide. Every endpoint on the Voice base accepts it.Campaign-wide. The template and number are fixed on the campaign.
Treat asBoth 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.
RotationFrom 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 APIsWhatsApp Business API
Shape{ status, code: number, error_code: string, error, details, unique_id, reference_id }{ error, code: string, details: object }
What to branch onerror_code. The numeric code mirrors the HTTP status and stays for compatibility.code, which is itself the machine string.
Human sentencedetails, repeated in error so a handler written for the WhatsApp shape reads a Voice error too.error.
Extra contextNamed 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 APIsWhatsApp Business API
401Credentials missing, unknown, or rejected. The status never confirms whether a key exists.Token missing or wrong.
403Credentials 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.
404Not 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 APIsWhatsApp Business API
KeyIdempotency-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.
Window24 hours per account.The life of the campaign.
A repeatReplays 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 markerIdempotent-Replayed: true, plus the original idempotency-replayed: true header.duplicate: true in the body.
Same key, different bodyHTTP 422 with idempotency_conflict.The first send stands; the second is reported as a duplicate.
Still in flightHTTP 409 with idempotency_in_progress and Retry-After.Not applicable.

Rate limits and headers

Voice APIsWhatsApp Business API
BudgetPer 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 headersRateLimit-Limit, RateLimit-Remaining and RateLimit-Reset, with X-RateLimit-* aliases of the same three values.None.
How long to waitRetry-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 refusalHTTP 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 APIsWhatsApp Business API
Header to verifyX-Agentive-Signature-V2: t=<unix seconds>,v2=<hex>X-Agentive-Signature: <hex>, with no prefix.
What is signedHMAC-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.
FreshnessReject 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.
ComparisonConstant time, on bytes. Reject on any mismatch, and reject a delivery with no version 2 header.Constant time, on bytes. Reject on any mismatch.
Two different signatures
The Voice APIs sign the timestamp together with the body, and you verify only 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 APIsWhatsApp Business API
Event nameX-Agentive-EventX-Agentive-Event
Event idX-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 idX-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 numberX-Agentive-Attempt, 1-based, counting through the whole retry schedule.None.
Attempt timeX-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 agentAgentive-Webhooks/2026-06-01Agentive-Webhooks/2026-05-08, the Chat payload version.

Webhook retries

Voice APIsWhatsApp Business API
AttemptsNine: three fast, then six spread out.Three.
Schedule0.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 windowAbout 24 hours, after which the delivery is marked failed.About 10 seconds.
Timeout per attempt6 seconds.10 seconds.
What you seeThe 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 APIsWhatsApp Business API
Time zoneIndian 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 alongsidecreated_at_utc on a call, and occurred_at (UTC) plus created (Unix seconds) on a webhook envelope.The envelope timestamp is already UTC.
MoneyIndian 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 APIsWhatsApp Business API
API versionIn the path: /api/public/v1.In the path: /api/v1.
Payload versionapi_version on every webhook event. Currently 2026-06-01.apiVersion on every webhook event. Currently 2026-05-08.
CompatibilityFields 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 casesnake_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.
Report a vulnerability
If you find a security problem in the APIs, the webhooks or the dashboard, write to hello@agentive.co.in with the steps to reproduce it. Please do not test against accounts that are not yours. Our contact details are also published at /.well-known/security.txt.
Reading this as one platform
The habits that hold across both products: HTTPS only, JSON both ways, a stable machine code on every error, an idempotency key on every request a retry could repeat, HMAC-SHA256 on every webhook, Indian Rupees and Indian Standard Time. The habits that do not: the credential header, the envelope key names, the field case, the signature scheme and the retry schedule. Write one transport layer per product, then share everything above it.

How webhooks work

  1. Register an endpoint
    In the Voice dashboard, add up to 10 webhook endpoints, each with an https:// URL and a signing secret.
  2. Choose events
    Pick the events each endpoint should receive, or leave the list empty to receive every event.
  3. We POST the event
    When a matching event occurs we send a JSON event object to your URL, signed in the X-Agentive-Signature-V2 header.
  4. You verify and respond
    Your server checks the signature, records the event and replies with a 2xx status within 6 seconds.
  5. Every attempt is counted
    The 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.

Public HTTPS endpoints only
The endpoint URL must be a public 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

EventObjectFires when
call.initiatedcallA call you placed through the API is accepted and queued.
call.ringingcallThe recipient's phone starts ringing. Optional, and not sent for calls you place.
call.answeredcallThe recipient answers and the call connects.
call.completedcallAn answered call finishes. Carries the talk duration and any keypad response.
call.no_answercallThe call rang but the recipient never picked up. Safe to retry later.
call.busycallThe recipient was on another call or declined. A retry can succeed.
call.failedcallThe 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

EventObjectFires when
recording.availablerecordingThe call recording has finished processing and is ready to download.

Verification

EventObjectFires when
otp.sentverificationA voice verification call is queued for delivery.
otp.verifiedverificationThe recipient entered the correct code.
otp.failedverificationThe recipient entered an incorrect code. The verification stays open until it expires or runs out of attempts.
otp.max_attemptsverificationToo many incorrect attempts. The code is locked; send a new one to retry.
The call-menu keypress hook is separate
If your account also posts a keypress from a call menu to an automation hook, that is a per-account hook of its own with its own address, its own body shape, and a signing secret it shares with campaign webhooks rather than with the endpoints on this page. It is not one of the twelve events on this page, it does not appear in this delivery log, and subscribing an endpoint here does not turn it on or off. It is documented under Call menu key press webhook in the Voice Broadcast API reference.
A call that does not connect
A non-connect fires the specific event for the reason: 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.

JSON
{
  "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

FieldTypeDescription
idstring
Unique id for this event, prefixed evt_. Use it to make your handler idempotent.
Example: evt_4b8f2c1d6a3e4f0b9d7c2a1e5f6b8c0d
objectstring
Always "event".
api_versionstring
The payload version used to build this event.
Example: 2026-06-01
typestring
The event name in resource.action form.
Example: call.completed
createdinteger
When the event was generated, as a Unix epoch timestamp in seconds.
Example: 1780589528
occurred_atstring
The same instant in UTC, ISO 8601 with a Z suffix.
Example: 2026-06-04T16:12:08.512Z
timestamp_iststring
The same instant in Indian Standard Time, ISO 8601 with a +05:30 offset.
Example: 2026-06-04T21:42:08.512+05:30
org_idinteger
Your account id.
reference_idstring | null
The reference_id you supplied when you placed the call or sent the code. null if you did not send one.
livemodeboolean
Always true. There is no separate sandbox mode; see Testing.
dataobject
The event-specific record. Its object field names the resource type: call, recording or verification.
eventstring
Alias of type. Kept for compatibility; prefer type.
event_idstring
Alias of id. Kept for compatibility; prefer id.
Notes
  • id and event_id are the same value. Prefer id.
  • type and event are the same value. Prefer type.
  • reference_id appears both at the top level and inside data, 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:

HTTP
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
HeaderTypeDescription
Content-Typestring
The body is always JSON.
Example: application/json
X-Agentive-Eventstring
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-Idstring
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-Idstring
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-Attemptstring
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-Timestampstring
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-V2string
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-Signaturestring
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-Agentstring
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.

Header
X-Agentive-Signature-V2: t=1780589528,v2=9c41ab2f7e05d3c6b8a1f4e2d0c9b7a6e5f4d3c2b1a09f8e7d6c5b4a3f2e1d0c
  1. Read the raw body
    Take the request body as bytes, before any JSON parsing or re-serialising. Re-serialising can change whitespace and break the check.
  2. Check the time
    Split the header on the comma. Reject the request if t is not a number or is more than 300 seconds from your own clock.
  3. Compute the digest
    Compute HMAC-SHA256(secret, t + "." + rawBody) and hex-encode it.
  4. Compare in constant time
    Check 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.
Reject on mismatch
If the computed signature and the header do not match, or there is no 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-V2 covers the timestamp and the body, as t=<unix seconds>,v2=<hex digest> where the digest is computed over "<t>.<body>". The t is the same value as X-Agentive-Timestamp, and it is recomputed on every attempt.
Verify V2, and only V2
An age check means something only when the timestamp is inside the signed material. Against the older signature it is not a security control, because anyone replaying a body can set 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.

Node.js
// 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:

Shell
# 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.

Webhook event

call.initiated

Fires whenA call you placed through the API was accepted and queued for dialling. This is the first event for an API-triggered call.

Values at this event

FieldTypeDescription
statusstring
Always "queued".
verification_statusstring
Always "pending" — the code is out and nothing has been entered yet.
call_idstring | null
Our record id for the leg, present as soon as it exists.
duration_secnull
Set on the terminal event.
end_reasonnull
Set on the terminal event.
Sample 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"
}
Webhook event

call.ringing

Fires whenThe recipient's phone has started ringing, on the calls that report it. Optional: see the note below.

Values at this event

FieldTypeDescription
statusstring
Always "ringing".
call_idstring
Our record id for the leg.
duration_secnull
Set on the terminal event.
Sample 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"
}
Treat call.ringing as optional
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.
Webhook event

call.answered

Fires whenThe recipient picked up and the call is now connected.

Values at this event

FieldTypeDescription
statusstring
Always "in_progress".
duration_secnull
Set on completion.
end_reasonnull
Set on completion.
Sample event
{
  "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"
}
Webhook event

call.completed

Fires whenAn answered call has finished. Use this event for billing, analytics and "call done" logic. A call answered by a voicemail system also arrives here, with end_reason "voicemail".

Values at this event

FieldTypeDescription
statusstring
Always "completed".
duration_secinteger
Talk time in seconds. May be 0.
end_reasonstring
"completed", or "voicemail" when a voicemail system answered.
responseobject
How the recipient responded on Interactive Voice calls; see the call object. Every field is null on calls with no interactive step.
Sample event
{
  "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.

Webhook event

call.no_answer

Fires whenThe call rang but the recipient never picked up. Safe to retry later. This is a terminal event for the call.

Values at this event

FieldTypeDescription
statusstring
Always "missed".
duration_secinteger
Always 0.
end_reasonstring
Always "no_answer".
Sample event
{
  "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"
}
Webhook event

call.busy

Fires whenThe recipient was on another call or declined. The number is reachable, so a retry can succeed. This is a terminal event for the call.

Values at this event

FieldTypeDescription
statusstring
Always "missed".
duration_secinteger
Always 0.
end_reasonstring
"busy" when the line was busy, "rejected" when the recipient actively declined.
Sample event
{
  "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"
}
Webhook event

call.failed

Fires whenThe call could not be placed at all (an invalid number or a carrier problem). Retrying the same number is unlikely to help. A call that rang unanswered or was busy fires call.no_answer or call.busy instead.

Values at this event

FieldTypeDescription
statusstring
Always "failed".
verification_statusstring
Always "pending" — a wrong code does not close the verification, so this is NOT "failed".
duration_secinteger
Always 0.
end_reasonstring
"failed", or "canceled" when the call was cancelled before it connected.
Sample event
{
  "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.

Webhook event

recording.available

Fires whenThe call recording has finished processing and is ready to download.

Key fields in data

FieldTypeDescription
recording_urlstring
An authenticated API URL. Fetch it with your API key and secret; see Download a recording.
formatstring
"ogg" or "wav".
duration_secinteger
The recorded length in seconds.
size_bytesinteger
The file size in bytes.
unique_idstring
The public id of the call this recording belongs to.
via_apiboolean
true when the call was placed through the API.
metadataobject | null
The metadata you sent with the call, or null. See the recording object.
Sample event
{
  "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.

The code is never sent
The verification code itself is never included in any webhook.
Webhook event

otp.sent

Fires whenA voice verification call was queued for delivery.

Values at this event

FieldTypeDescription
statusstring
Always "queued".
verifiednull
Not yet known.
expires_in_secinteger
Seconds until the code expires. 600 (10 minutes) unless you set ttl on the send.
max_attemptsinteger
Allowed verify attempts.
Sample event
{
  "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"
}
Webhook event

otp.verified

Fires whenThe recipient entered the correct verification code.

Values at this event

FieldTypeDescription
statusstring
Always "verified".
verification_statusstring
Always "verified".
verifiedboolean
Always true.
Sample event
{
  "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"
}
Webhook event

otp.failed

Fires whenThe recipient entered an incorrect code. The verification is still open until it expires or runs out of attempts, so the recipient can try again.

Values at this event

FieldTypeDescription
statusstring
Always "failed".
verifiedboolean
Always false.
attempts_leftinteger
How many verify attempts remain (1 or more).
max_attemptsinteger
Allowed verify attempts.
Sample event
{
  "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"
}
Webhook event

otp.max_attempts

Fires whenToo many incorrect attempts. The code is now locked and can no longer be verified. Send a new code to retry.

Values at this event

FieldTypeDescription
statusstring
Always "max_attempts".
verification_statusstring
Always "locked".
verifiedboolean
Always false.
attempts_leftinteger
Always 0.
max_attemptsinteger
Allowed verify attempts.
Sample event
{
  "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.

FieldTypeDescription
objectstring
Always "call".
unique_idstring
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_idstring | 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_idstring | 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.
directionstring
outbound or inbound.
from_numberstring
The caller number shown to the recipient.
to_numberstring
The recipient number in E.164 form.
Example: +919812345678
statusstring
The call state at this event. A closed set; see Status and end reasons.
duration_secinteger | null
Talk duration in seconds. Set on the terminal events (call.completed, call.no_answer, call.busy, call.failed), null before.
end_reasonstring | null
Why the call ended. A closed set; see Status and end reasons. Set on the terminal events, null before.
campaign_idinteger | 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_typestring | 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_idinteger | 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_idstring | null
Your correlation key.
metadataobject | null
The metadata you sent when you placed the call through the API (a number comes back as text). null otherwise.
via_apiboolean
true when the call was placed through the API. Events also describe calls placed from the dashboard and incoming calls.
charge_inrnumber | 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.
responseobject
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.dispositionstring | null
interested or not_interested when the recipient responded to an interest-capture call.
response.digitstring | null
The first key the recipient pressed.
response.viastring | null
How the response was given: keypad or voice.
response.answersobject | null
Per-question answers for guided-flow calls.
response.voicemail_leftboolean | null
true when the recipient left a voicemail in the flow.
Lifecycle
Status values follow the call through its lifecycle: 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)

ValueMeaningWebhook event
queuedAccepted and waiting to be placed.call.initiated
ringingThe recipient's phone is ringing.call.ringing
in_progressThe recipient answered and the call is connected.call.answered
completedThe call finished after being answered.call.completed
missedThe call reached the recipient but was never answered.call.no_answer, call.busy
failedThe call could not be placed at all.call.failed

Transitions

FromToWhen
queuedringingWe handed the call to the network and the recipient's phone started ringing.
ringingin_progressThe recipient answered.
in_progresscompletedThe call ended after being answered. end_reason is completed or voicemail.
queued, ringingmissedIt rang and was never answered. end_reason is no_answer, busy, rejected or canceled.
queued, ringingfailedIt 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)

ValueMeaningWith status
completedThe call connected and ran to its normal close: the clip finished, the interactive flow ended, or either side hung up.completed
voicemailA voicemail system answered. It was detected and the call was closed.completed
no_answerIt rang and the recipient never picked up. Safe to retry later.missed
busyThe recipient was on another call. The number is reachable, so a retry can succeed.missed
rejectedThe recipient actively declined the call.missed
canceledThe call was cancelled before it connected.missed
failedThe 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_statusMeansNote
queuedqueuedSame meaning.
ringingringingSame meaning.
answeredin_progressThe same state under an older name.
completedcompletedSame meaning.
failedmissed or failedThis 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.
No status called no_answer or busy
Those are 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.

Corrected in September 2026
Two events used to carry the wrong 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

IdentifierWhat it isWhere you get itWhat you poll withIn webhooks
unique_idThe 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 idThe 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_idA 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_idOur 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_idA 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_idYour 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-KeyYour 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

IdentifierWhat it isWhere you get itWhat you poll withIn webhooks
event idOne 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 idOne 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.
Some calls carry two ids
A call placed with 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.
One rule to remember
Poll with the 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.

FieldTypeDescription
objectstring
Always "recording".
unique_idstring
The call's public id, the same value Get call status returns.
call_idstring | null
Our internal identifier for the call this recording belongs to.
leg_idstring | null
The same second id the call object carries, or null. Added September 2026. Match on unique_id.
recording_urlstring
An authenticated API URL for the audio. Fetch it with your API key and secret; see Download a recording.
formatstring
The audio format: "ogg" or "wav".
duration_secinteger | null
The recorded length in seconds.
size_bytesinteger | null
The file size in bytes.
campaign_idinteger | 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_typestring | 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_idstring | null
Your correlation key.
metadataobject | null
The metadata you sent when you placed the call through the API (a number comes back as text). null otherwise.
via_apiboolean
true when the call was placed through the API, the same value the call events carry.
Downloading the audio
The 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.

FieldTypeDescription
objectstring
Always "verification".
request_idstring
The verification request id returned by Send a code.
numberstring
The recipient's number in E.164 form.
Example: +919812345678
statusstring
queued on otp.sent, verified on otp.verified, failed on otp.failed, max_attempts on otp.max_attempts.
verification_statusstring
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.
verifiedboolean | null
null on otp.sent, true on otp.verified, false on otp.failed and otp.max_attempts.
attempts_leftinteger | null
Remaining verify attempts. Present on otp.failed (1 or more) and otp.max_attempts (0).
expires_in_secinteger | null
Seconds until the code expires. Present on otp.sent.
Example: 600
max_attemptsinteger | null
Allowed verify attempts. Present on otp.sent, otp.failed and otp.max_attempts.
campaign_idinteger | null
The delivery run, if any.
reference_idstring | 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 responseWhat we do
2xxMarked delivered. No further attempts for this event.
3xxNot followed. Counted as a failed attempt and retried, so a signed body is never forwarded to another host.
4xx or 5xxCounted as a failed attempt and retried, for up to nine attempts over about 24 hours.
No reply within 6 sCounted as a failed attempt and retried.
Connection errorCounted 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.

AttemptSent after the previous attemptNote
1ImmediatelyAs soon as the event is generated.
20.5 seconds
32 secondsEnd of the fast path.
41 minute
55 minutes
630 minutes
72 hours
86 hours
912 hoursThe last attempt. After this the delivery is marked failed.
Headers on the third attempt
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:

ColumnWhat it tells you
EventThe event name.
ResultDelivered with the HTTP status your endpoint returned, or Failed with that status. A network error or a timeout shows as Failed with no response.
TriesHow many attempts have been made so far, out of nine.
When (IST)When the event was generated, in Indian Standard Time.
A delivery still being retried reads as failed
The row shows the result of the latest attempt, so a delivery that is inside its retry schedule reads as failed until an attempt succeeds. Watch the Tries count climb, and refresh to see it settle. The row is not final until Tries stops moving.

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 its id before. If one endpoint of yours is behind another, de-duplicate on X-Agentive-Delivery-Id instead, which is unique per endpoint.
  • Ordering is not guaranteed. Events may arrive out of order, and the retry tail widens the gap: a call.answered your endpoint refused could land hours after the call.completed it accepted. Order by the envelope timestamp (created, or occurred_at / timestamp_ist) and the status field, never by arrival order.
  • A call typically produces this sequence: call.initiated (API-placed calls only), then call.ringing (inbound only), then call.answered and call.completed if it connects, or one of call.no_answer, call.busy or call.failed if it does not, followed by recording.available once 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-Key header, 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.completed to 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:

Test event
{
  "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"
}
No separate test mode
There is no separate test or sandbox mode: 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 is 2026-06-01.
  • We may add new fields to the envelope or to any data object 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 type and 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-V2 on 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 id before you act on an event, so a retry changes nothing. Check age against the signed t, never against X-Agentive-Timestamp alone.
  • 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.

  1. Add an endpoint
    In the Voice dashboard, open Webhooks, add your https:// URL and copy the signing secret into an environment variable such as AGENTIVE_WEBHOOK_SECRET. Leave the event list empty to receive everything.
  2. Deploy a receiver
    Start from one of the example handlers. It must read the raw body, verify the signature in constant time and return 200 within 6 seconds.
  3. Send a test event
    Click 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.
  4. Place a real call to your own number
    Use the Voice Broadcast API with a number you can answer. Within a minute your endpoint receives call.initiated, a terminal event, and recording.available, all sharing the same data.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"
      }'
  5. Store the event id and reconcile
    Persist each id before 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