Voice OTP API

Voice OTP API

Read a one-time code out to a customer over a phone call, then verify what they typed. Two requests, with expiry and attempt limits enforced for you.

Base URL
https://voice.agentive.co.in/api/public/v1
Format
JSON over HTTPS
Auth
x-api-key + x-api-secret

Introduction

The Voice OTP API places a phone call that reads a numeric one-time code out to your customer, then checks the code they type back into your app or website. Use it to verify a mobile number at signup, confirm a login or a payment, or as the fallback when an SMS code does not arrive.

Two requests cover the whole flow. POST /otp/send generates the code (or takes yours), places the call and returns a request_id. POST /otp/verify checks what the customer entered against that request and tells you whether it matched. Expiry, the attempt cap and single use are enforced on our side, and the plain code is never returned to you, never logged and never sent on a webhook.

The campaign holds the settings
Before your first send, create an Authentication campaign in your Voice dashboard. It is where the code length, the validity, the spoken message and the caller number live, and it is the only place they live. Your request carries the number to call and, optionally, a code of your own. Everything that campaign sends also shows up under it, so one screen answers what went out, from which number and what happened.

When to use a voice code

  • SMS is delayed or filtered. A call rings through on numbers with DND active and on handsets where promotional SMS is blocked, and the customer hears the code the moment they answer.
  • No template to register. There is no sender header or message template to get approved. The default sentence is read as is, or you pass your own message.
  • Any hour of the day. Verification calls are not restricted to the 9:00 AM to 9:00 PM IST calling window that applies to broadcast calls, because a login code has to reach someone whenever they are signing in.
  • Easy to hear. The code is read digit by digit and repeated once, so a customer who cannot read a small screen can still type it in.

Keep SMS for silent verification where a ringing phone would be intrusive, or send both and accept whichever the customer completes first. Verification calls are placed to Indian mobile numbers only; a landline is rejected.

Billing

Verification calls are billed in Indian Rupees (INR) against your account balance at your account's rate, with a one-minute minimum per answered call and per-second billing after the first minute. A send request your balance cannot cover returns HTTP 402. Every timestamp is in Indian Standard Time (IST), in ISO 8601 format.

API access is enabled per account
If you get a 403 saying the API is not enabled, contact support. Verification also has to be enabled for your account; a request against a product that is not enabled returns HTTP 403 with "This feature is not enabled for your account."

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.

Base URL

Every path on this page is relative to the Voice API base URL. It is the same base the Voice Broadcast API uses, so one set of credentials covers both.

Base URL
https://voice.agentive.co.in/api/public/v1

Authentication

Verification endpoints use the same credentials as the rest of the Voice API: a publishable key and a secret, issued from the API tab in your Voice dashboard and sent as two request headers.

Headers

HeaderTypeRequiredDescription
x-api-keystringRequired
Your publishable key. Starts with pk_live_. Treat it as confidential, like the secret.
x-api-secretstringRequired
Your secret key. Starts with sk_live_. Treat it like a password.
cURL
curl https://voice.agentive.co.in/api/public/v1/otp/send \
  -H "x-api-key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "x-api-secret: sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "number": "9812345678" }'

The secret is shown in full exactly once, when it is generated or rotated, and is stored hashed on our side. If you lose it, rotate to get a new pair; the old secret stops working immediately. As an alternative to the two headers you may send the pair with HTTP Basic auth (the curl -u form, publishable key as the username and secret as the password), or put the secret in a secret field of the JSON body. When an x-api-key header is present it takes precedence.

cURL (HTTP Basic)
curl https://voice.agentive.co.in/api/public/v1/otp/send \
  -u pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
  -H "Content-Type: application/json" \
  -d '{ "number": "9812345678" }'
Keep the secret server-side
Never embed the secret in a browser, a mobile app or any client a customer can inspect. Your app should call your own backend, and your backend should call this API. All requests must be over HTTPS. If a key is exposed, rotate it from the dashboard at once.

Authentication failures

SituationHTTPdetails message
No publishable key sent401Missing API key. Send your publishable key in the x-api-key header.
Unknown publishable key401Invalid API credentials.
Known key, wrong or missing secret401Invalid API credentials.
Account is not active403This account is not active. Please contact support.
API access not enabled for the account403The API is not enabled for this account. Please contact support.
Verification not enabled for the account403This feature is not enabled for your account.

The wording and the status are the same for an unknown key and a wrong secret, so credentials cannot be probed. 401 always means the credentials were rejected; 403 always means they were accepted and the account or the product is not entitled. Before September 2026 a known key with a wrong secret returned 403; move any branch that read 403 as "bad secret" to 401. Key rotation and the full credential model are covered in the Voice Broadcast API authentication section.

Response format

Every response is JSON and uses one consistent envelope.

JSON
{
  "status": "success",
  "code": 200,
  "unique_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "reference_id": "signup-7781",
  "details": "Verification call queued."
}
FieldTypeDescription
statusstring
"success" or "error".
codenumber
A numeric result code that mirrors the HTTP status. See Errors.
unique_idstring | null
Our identifier for the resource this request created. For a verification request it is the same value as request_id, and it is what you pass to Get verification status.
reference_idstring | null
Echoes the reference_id you sent, so you can match the response to your own records. null if you did not send one.
detailsstring
A short human-readable description of the result.

Successful responses add endpoint-specific fields alongside the envelope: request_id, expires_in_sec, expires_at_ist and sometimes warnings on send; verified and verification_status on verify; and verification_status, call_status, verified, attempts, max_attempts and expires_at_ist on status. Error responses add error_code and error. Each is documented with its endpoint below.

HTTP status
A successful request returns HTTP 200 with status: "success". A failed request returns the matching HTTP error status (400, 401, 402, 403, 404, 409, 422, 429 or 5xx) with status: "error" and the same value in the code field.

Errors

Errors follow the envelope with status: "error", the HTTP status repeated in code, a stable machine string in error_code, and a human-readable details message (repeated in error). Branch on error_code; show details to your operators, not to your customers.

StatusCodeerror_codeWhenWhat to do
400400invalid_numberThe number is missing, malformed, a landline, or not an Indian mobile number.Send a 10-digit mobile number beginning with 6, 7, 8 or 9.
400400invalid_codeOn send: a supplied code that is not 4 to 8 digits.Send 4 to 8 digits, or omit code on send and let one be generated.
400400validation_errorOn verify: a body with no code, or one missing both request_id and number.Send the code plus one of the two identifiers.
401401missing_credentialsNo API key was sent at all.Send your publishable key in x-api-key and your secret in x-api-secret, or use HTTP Basic.
401401invalid_credentialsThe publishable key is unknown, or the secret does not match it.Check both headers. Before September 2026 a known key with a wrong secret returned 403; it now returns 401.
401401incorrect_codeOn verify only: the submitted code is wrong and at least one attempt remains. Carries verified: false and attempts_left.Let the customer try again while attempts_left is above zero.
401401max_attemptsOn verify only: the wrong code that spent the last attempt. Carries attempts_left: 0.Send a fresh code. This request can no longer be verified.
402402insufficient_balanceYour balance cannot cover the verification call. Checked before any code is created, so nothing was issued and no validity clock started. The body carries balance_inr and minimum_balance_inr, which is the per-call rate on your account, not a flat figure.Top up your wallet and resend with the same Idempotency-Key.
403403feature_disabledThe credentials were accepted but verification calls are not enabled for the account.Contact support to enable them.
403403account_inactiveThe account is not active.Contact support.
403403trial_restrictedA trial account sending to a number that is not its registered mobile.Complete business verification, or send to the registered number.
404404not_foundThe verification request in the path could not be found for your account.Use the unique_id returned by send; ids are scoped to the account that created them.
409409idempotency_in_progressA request with the same Idempotency-Key is still being processed.Wait for Retry-After (2 seconds) and retry with the same key.
422422idempotency_conflictThe same Idempotency-Key was sent with a different request body.Use a fresh key for a new request.
429429rate_limitedYou went past the per-account request budget, or past the verify limit of 60 a minute.Retry-After is up to 60 seconds.
429429number_floodTwo sends to one number closer than 30 seconds apart, more than 3 in 10 minutes, or more than 10 in 24 hours.Retry-After counts the seconds until the next send to that number is allowed.
429429number_lockedTen wrong codes for one number within an hour. Both send and verify are locked for that number.Retry-After counts the seconds until the lock lifts. Nothing you send in between will reach that number.
429429concurrency_limitYour account has no free line for the verification call. The body carries lines and lines_in_use.Retry-After is 5 seconds.
502502upstream_errorThe calling network refused the request outright.Retry after a short pause with the same Idempotency-Key. If that reply comes back as call_state_unknown, follow that row instead: a 502 is never proof on its own that no call was placed.
502502call_state_unknownWe asked the network to place the verification call and lost track of it. Replaying the same Idempotency-Key returns this same answer with a sentence appended telling you to check before retrying.Poll Get verification status before you resend, or choose a fresh key deliberately.
503503service_unavailableBriefly unavailable on our side.Retry after Retry-After with the same Idempotency-Key.
Two kinds of 401
On POST /otp/verify, HTTP 401 is also how an incorrect code is reported. Tell the two apart by error_code: a wrong code carries incorrect_code or max_attempts plus verified: false and attempts_left; an authentication failure carries invalid_credentials or missing_credentials and neither of those fields.

The split between 429 and 5xx matters for retry logic: a 429 means a limit was hit, so wait before retrying; a 5xx means a temporary problem on our side, so an immediate retry after a short pause is safe and, with the same Idempotency-Key, can never place a second call. Error responses never include internal infrastructure detail.

Example error response

JSON
{
  "status": "error",
  "code": 400,
  "unique_id": null,
  "reference_id": "signup-7781",
  "details": "A valid Indian mobile number is required.",
  "error_code": "invalid_number",
  "error": "A valid Indian mobile number is required."
}

Rate limits

Verification is the highest-risk thing this API does, so it carries more limits than any other endpoint. Each one refuses with HTTP 429, names itself in error_code, and says how long to wait in both the Retry-After header and the body's retry_after_sec.

LimitWindowScopeerror_codeRetry-After
240 failed requests1 minuteClient addressrate_limitedUp to 60 seconds. Only requests refused for their credentials or account (401 or 403) count.
20 failed credential checks1 minutePublishable key and client addressrate_limitedUp to 60 seconds. Guards a key against someone guessing its secret. A request with the correct secret is always let through.
120 requests1 minuteAccountrate_limitedUp to 60 seconds. Reads and writes have separate budgets of 120 each.
60 verify requests1 minuteAccountrate_limitedUp to 60 seconds. Independent of any one request id, so guessing across many ids is capped too.
1 send every 30 secondsRollingAccount and numbernumber_floodThe seconds left in the cooldown.
3 sends to one number10 minutesAccount and numbernumber_floodUp to 600 seconds.
10 sends to one number24 hoursAccount and numbernumber_floodThe seconds until the window rolls.
10 wrong codes60 minutesAccount and numbernumber_lockedThe seconds until the lock lifts. Send and verify are BOTH locked for that number.
Your linesWhile calls are liveAccountconcurrency_limit5 seconds.

Standard rate-limit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, plus X-RateLimit-* aliases of the same three values) are returned on responses so you can pace your requests. They describe the per-account bucket for that request's method: a GET reports the read bucket, a POST the write bucket.

JSON
{
  "status": "error",
  "code": 429,
  "unique_id": null,
  "reference_id": null,
  "details": "Too many requests. Please slow down and retry shortly.",
  "error": "Too many requests. Please slow down and retry shortly.",
  "error_code": "rate_limited",
  "retry_after_sec": 60
}

Too soon, or too many, to one number

HTTP
HTTP/1.1 429 Too Many Requests
Retry-After: 18

{
  "status": "error",
  "code": 429,
  "unique_id": null,
  "reference_id": "signup-7781",
  "details": "Too many verification calls to this number. Please wait a few moments before sending another.",
  "error": "Too many verification calls to this number. Please wait a few moments before sending another.",
  "error_code": "number_flood",
  "retry_after_sec": 18
}

Locked after repeated wrong codes

HTTP
HTTP/1.1 429 Too Many Requests
Retry-After: 2280

{
  "status": "error",
  "code": 429,
  "unique_id": null,
  "reference_id": "signup-7781",
  "details": "Too many incorrect codes for this number. It is locked for a short while.",
  "error": "Too many incorrect codes for this number. It is locked for a short while.",
  "error_code": "number_locked",
  "retry_after_sec": 2280
}

Responses to POST /otp/send also carry X-Agentive-Lines and X-Agentive-Lines-In-Use: how many calls your account can have live at once, and how many are live right now, whether the call was admitted or refused. Read endpoints do not send them. A send your account has no free line for is refused rather than queued, with HTTP 429, error_code: "concurrency_limit", lines, lines_in_use and retry_after_sec in the body and a Retry-After of 5 seconds, so you always know whether a code went out.

Build a resend button, not a resend loop
Two sends to one number must be at least 30 seconds apart, and there are at most 3 in 10 minutes and 10 in 24 hours. Disable your resend control for 30 seconds after each send and show the customer a countdown; never resend automatically. Ten wrong codes for one number inside an hour lock both sending and verifying for that number.

Endpoints

Send a code

POST/otp/send

Place a call that reads a numeric verification code out to the recipient. The code is read digit by digit and spoken twice, then the call ends. There is no agent and no human on the call. The response is returned as soon as the call is queued; the customer does not have to answer for the request to be created.

Headers

HeaderTypeRequiredDescription
x-api-keystringRequired
Your publishable key. Starts with pk_live_. Treat it as confidential, like the secret.
x-api-secretstringRequired
Your secret key. Starts with sk_live_. Treat it like a password.
Content-TypestringRequired
Always application/json.
Idempotency-KeystringOptional
A unique key per logical request so a network retry can never place a second billed call. See Idempotency.
Example: signup-7781-attempt-1

Request body

FieldTypeRequiredDescription
numberstringRequired
The recipient's Indian mobile number. See Phone number format.
Example: 9812345678
codestringOptional
Supply your own code: 4 to 8 digits and nothing else. Anything shorter, longer or non-numeric is refused with HTTP 400 and invalid_code. Omit it and a code is generated for you.
Example: 204815
campaign_idnumberOptional
Which authentication campaign to use. Omit it and your account's authentication campaign is used, which is what most integrations want. Name one when you run more than one, for example a different spoken message for sign-in and for payment approval.
Example: 622
max_attemptsnumberOptional
How many verify attempts are allowed. Default 3. Values outside 1 to 10 are clamped into range.
reference_idstringOptional
Your own correlation key, such as a signup or order id. Echoed back and carried on every webhook. See Reference IDs.
Example: signup-7781

Request

curl https://voice.agentive.co.in/api/public/v1/otp/send \
  -H "x-api-key: pk_live_..." \
  -H "x-api-secret: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "number": "9812345678",
    "max_attempts": 3,
    "reference_id": "signup-7781"
  }'

Response

JSON
{
  "status": "success",
  "code": 200,
  "unique_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "reference_id": "signup-7781",
  "details": "Verification call queued.",
  "request_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "expires_in_sec": 600,
  "verification_status": "pending",
  "campaign_id": 48213
}

Response fields

FieldTypeDescription
request_idstring
The verification request id. Pass it (or the number) to Verify a code. Same value as unique_id.
expires_in_secnumber
Seconds until the code expires. For the exact moment, poll Get verification status, which returns expires_at_ist.
verification_statusstring
Always pending here: the code has gone out and nothing has been tried against it yet. See Statuses.
campaign_idnumber | null
The delivery run this verification call went out on. Informational; poll with request_id, not with this.
warningsstring[]
Present only when there is something to flag. Today the one value is weak_code: the code you supplied is guessable (one digit repeated, a run of consecutive digits, or a two-digit pattern). The code was still accepted and the call was still placed.

Errors

StatusCodeerror_codeWhen
400400invalid_number"A valid Indian mobile number is required." The number is missing, a landline, or not a mobile number.
400400invalid_codeYour own code is not numeric, or is not 4 to 8 digits. Also returned, as validation_error, when the request tries to set something the campaign owns: length, ttl, message or caller_id.
402402insufficient_balanceBalance too low to place the call, checked against this call's own rate. Nothing was issued. Top up and resend.
400400validation_errorThere is no authentication campaign on your account, the campaign_id you named is not one (a deleted campaign reads the same way), or the campaign is switched off. Pausing, stopping or deleting a campaign stops the codes it sends; a campaign sitting in draft is normal and keeps working.
403403feature_disabled"This feature is not enabled for your account." Verification calls are not enabled. Contact support.
429429number_floodTwo sends to one number less than 30 seconds apart, more than 3 in 10 minutes, or more than 10 in 24 hours. No code was issued.
429429number_lockedTen wrong codes for this number in the last hour. Sending is locked for that number until the window passes.
429429rate_limitedThe account request budget was hit. No code was issued. Wait for Retry-After, then resend.
429429concurrency_limitYour account has no free line for the call right now. Retry-After is 5 seconds.
502502upstream_error"Could not place the call right now. Please retry." Retry after a short pause, with the same Idempotency-Key.
The code is never returned
The code is generated for you, or you may supply your own. It is stored hashed; the plain code is never returned in any response, never written to any log and never included in a webhook. To check what the recipient entered, use Verify a code.

The campaign decides how the call is made. How many digits the code has, how long it stays valid, what is spoken and which number the recipient sees are all set once on your authentication campaign, in the dashboard. Your request carries the number to call and, if you want, a code of your own. Sending length, ttl, message or caller_id is refused with HTTP 400 rather than quietly ignored, so a setting can never appear to be in two places at once.

Defaults: a new campaign starts at code length 4, validity 10 minutes, 3 verify attempts, and reads "Your verification code is 2 0 4 8 1 5. Again, 2 0 4 8 1 5." Open the campaign to change any of it. Set six digits for anything that guards money or an account, and name your product in the spoken message so the customer knows who is calling; see Choosing the code.

Works around the clock
POST /otp/send is not subject to the 9:00 AM to 9:00 PM IST calling window that applies to broadcast calls. It never returns the calling-hours 409.

Verify a code

POST/otp/verify

Check a code the recipient gives you against a verification request. Identify the request by its request_id (returned from send) or by the recipient's number, in which case the most recent pending request for that number is used. Each verify attempt is counted whether right or wrong; once the attempt cap is reached the request can no longer be verified and you must send a fresh code.

Headers

HeaderTypeRequiredDescription
x-api-keystringRequired
Your publishable key. Starts with pk_live_. Treat it as confidential, like the secret.
x-api-secretstringRequired
Your secret key. Starts with sk_live_. Treat it like a password.
Content-TypestringRequired
Always application/json.
Idempotency-KeystringOptional
A unique key per logical request so a network retry can never place a second billed call. See Idempotency.
Example: signup-7781-attempt-1

Request body

FieldTypeRequiredDescription
codestringRequired
The code the recipient entered.
Example: 204815
request_idstringOptional
One of request_id or number. The id returned by send. The alias otp_id is accepted for the same value.
Example: 5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e
numberstringOptional
One of request_id or number. The recipient's number, which resolves to the newest request for that number that has not been verified.
Example: 9812345678
reference_idstringOptional
Your own correlation key.

Request

curl https://voice.agentive.co.in/api/public/v1/otp/verify \
  -H "x-api-key: pk_live_..." \
  -H "x-api-secret: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "request_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
    "code": "204815"
  }'

Response: correct code

JSON
{
  "status": "success",
  "code": 200,
  "unique_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "reference_id": "signup-7781",
  "details": "Code verified.",
  "request_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "verified": true,
  "verification_status": "verified"
}

Response: incorrect code

JSON
{
  "status": "error",
  "code": 401,
  "details": "Incorrect code.",
  "error": "Incorrect code.",
  "error_code": "incorrect_code",
  "verified": false,
  "attempts_left": 2,
  "verification_status": "pending"
}

Response fields

FieldTypeDescription
verifiedboolean
Always present. true when the code matched, false otherwise.
request_idstring
The verification request that was checked. Present on success.
attempts_leftnumber
How many tries remain. Present on an incorrect code, and 0 on the attempt that spends the last one.
verification_statusstring
Where the request stands after this attempt, from the closed set in Statuses.
error_codestring
On a failed attempt: incorrect_code, max_attempts, code_expired, code_used or verification_not_found.

Outcomes

StatusCodeerror_codeWhen
200200not set"Code verified." The code is correct. verified: true.
401401incorrect_code"Incorrect code." Wrong code, with at least one attempt still to come. verified: false and attempts_left of 1 or more.
401401max_attemptsThe wrong code that spends the last attempt. attempts_left: 0. Both otp.failed and otp.max_attempts fire for this one request.
400400max_attempts"Too many incorrect attempts." A further verify after the cap was already reached. Send a new code.
400400code_expired"This code has expired." The validity window passed. Send a new code.
400400code_used"This code has already been used." The request was verified earlier. A code verifies once.
400400verification_not_found"Verification failed." No matching request: the request_id is unknown, or there is no unverified request for the number.
400400validation_error"A code and either request_id or number are required." The body is missing the code or the identifier.
429429rate_limitedMore than 60 verify requests in a minute for this account.
429429number_lockedTen wrong codes for this number within an hour. Verification is locked for that number until the window passes.

The verified field is always present, true or false. Treat only verified: true as success. A 401 with attempts_left above zero means the customer can try again. A 401 with attempts_left: 0 is the attempt that spent the last try, and every 400 means this request is finished and a new code has to be sent.

The 400 outcomes keep their distinct sentences: an expired code, a used code and an unknown request each say so. That is deliberate. The caller already holds your account credentials and can read the same state from Get verification status, so a single vague message would protect nothing and would make your support queue harder. They share one machine class through the HTTP status, and error_code tells them apart.

Get verification status

GET/calls/:unique_id

Read the current state of a verification request you sent. This is the same endpoint that reports a placed call on the Voice Broadcast API; when the id is a verification request the response takes the shape below. Useful for a dashboard or a support tool. For a live signup flow, the verify response already tells you the outcome, and webhooks push every transition without polling.

Headers

HeaderTypeRequiredDescription
x-api-keystringRequired
Your publishable key. Starts with pk_live_. Treat it as confidential, like the secret.
x-api-secretstringRequired
Your secret key. Starts with sk_live_. Treat it like a password.

Path parameters

ParameterTypeRequiredDescription
unique_idstringRequired
The unique_id (equal to request_id) returned by Send a code.
Example: 5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e

Request

curl https://voice.agentive.co.in/api/public/v1/calls/5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e \
  -H "x-api-key: pk_live_..." \
  -H "x-api-secret: sk_live_..."

Response: verification request

JSON
{
  "status": "success",
  "code": 200,
  "unique_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "reference_id": "signup-7781",
  "details": "verified",
  "call_status": "verified",
  "verification_status": "verified",
  "verified": true,
  "attempts": 1,
  "max_attempts": 3,
  "attempts_left": 2,
  "locked": false,
  "expires_at_ist": "2026-06-04T21:52:08+05:30",
  "campaign_id": 48213
}

Response fields

FieldTypeDescription
verification_statusstring
The request's current state in the closed set pending, verified, failed, locked, expired, undeliverable. Prefer this. See Statuses.
call_statusstring
The original, smaller vocabulary: pending, verified, expired or failed. Unchanged, and kept for compatibility.
verifiedboolean
true once the correct code was confirmed.
attemptsnumber
Verify attempts used so far, right or wrong.
max_attemptsnumber
Verify attempts allowed for this request.
expires_at_iststring
When the code stops being valid, ISO 8601 in Indian Standard Time with a +05:30 offset.
Example: 2026-06-04T21:52:08+05:30
attempts_leftnumber
Verify attempts still available, never below zero.
lockedboolean
true once every attempt has been spent without a correct code.
campaign_idnumber | null
The delivery run this verification call went out on.
detailsstring
Mirrors call_status.

Errors

StatusCodeerror_codeWhen
404404not_found"Not found." The id does not belong to your account.

The verification object

One verification request is described by the same set of fields wherever it appears: the extra fields on the send, verify and status responses, and the data object of every otp.* webhook, where data.object is "verification". The table notes where each field is present.

FieldTypeDescription
objectstring
Always "verification". Webhooks only.
request_idstring
The verification request id. Returned by send as request_id and unique_id, accepted by verify and status, and carried on every webhook.
numberstring
The recipient's number in E.164 form. Webhooks only.
Example: +919812345678
verification_statusstring
The one closed set that means the same thing everywhere: pending, verified, failed, locked, expired, undeliverable. Present on the status response and on every otp.* event.
statusstring
The original vocabulary, unchanged. On Get verification status (as call_status): pending, verified, expired, failed. On webhooks: queued, verified, failed, max_attempts.
verifiedboolean | null
true once the correct code was confirmed, false after a wrong code or a lock, null on otp.sent when nothing has been tried yet.
attemptsnumber
Verify attempts used so far. Status endpoint only.
attempts_leftnumber | null
Remaining verify attempts. Present on an incorrect verify response, on otp.failed (1 or more) and on otp.max_attempts (0).
max_attemptsnumber | null
Verify attempts allowed. On the status endpoint, and on otp.sent, otp.failed and otp.max_attempts.
expires_in_secnumber | null
Seconds until the code expires. Returned by send and present on otp.sent.
campaign_idnumber | null
The delivery run this verification call went out on. null on otp.sent, which fires before the run exists; a number on otp.verified, otp.failed and otp.max_attempts. Also returned by send and by the status endpoint.
reference_idstring | null
Your correlation key, as sent.

Example: data on an otp.failed webhook

JSON
{
  "object": "verification",
  "request_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "number": "+919812345678",
  "status": "failed",
  "verification_status": "pending",
  "verified": false,
  "attempts_left": 2,
  "max_attempts": 3,
  "campaign_id": 48213,
  "reference_id": "signup-7781"
}

Statuses

verification_status is the closed set for one verification request. It appears on the send response, on the poll response, on the verify response and on the data object of every otp.* event, so one vocabulary describes a verification wherever you read it. Five values are states a request can be in; failed is the sixth and answers a verify that matched no request at all.

verification_status

ValueMeaningEvent that fires
pendingThe code was sent and is still awaiting a correct entry. A wrong code leaves the request here while attempts remain.otp.sent on the send; otp.failed on each wrong code.
verifiedThe correct code was confirmed. A code verifies once.otp.verified
lockedNo attempts remain. The code can no longer be verified; send a new one.otp.max_attempts
expiredThe validity window passed without a correct entry.None. Expiry is time passing, not an event.
undeliverableThe verification call could not be placed at all, so no code ever reached the recipient.None. The send response already told you.
failedNo open verification matched the verify you sent: the request id or the number is not one we hold for your account. Nothing to check a code against, so nothing changed. This value appears on a verify response, never as a stored state.None. The verify itself answers 400 with verification_not_found.

Transitions

FromToWhen
pendingverifiedThe recipient entered the correct code while attempts remained and the window was open.
pendingpendingA wrong code was entered and at least one attempt remains. The verify response carries attempts_left.
pendinglockedThe last remaining attempt was spent on a wrong code.
pendingexpiredThe validity window passed first.
pendingundeliverableWe could not place the verification call.
The older call_status is still returned
For compatibility the poll response also carries call_status with its original four values (pending, verified, expired, failed), and the webhook data.status keeps its original four (queued, verified, failed, max_attempts). Both are unchanged. verification_status is the one set that means the same thing in both places, so prefer it in new code.

Expiry and attempts

  • Validity. A code stays valid for the window set on your authentication campaign, 600 seconds (10 minutes) unless you change it. Send returns that window as expires_in_sec and as expires_at_ist. After it passes, verify returns HTTP 400 with code_expired and the request's verification_status is expired.
  • Attempt cap. max_attempts verify calls are allowed per request, default 3. Every attempt is counted, right or wrong.
  • Lock. The wrong code that spends the last attempt answers HTTP 401 with attempts_left: 0 and error_code: "max_attempts", and fires both otp.failed and otp.max_attempts. From then on the request is locked and any further verify answers HTTP 400 with max_attempts. Send a new code to retry.
  • Single use. A correct code verifies the request once, atomically, so two verify requests racing with the same correct code can never both succeed. The loser gets HTTP 400 with code_used.
  • Verifying by number. Passing number instead of request_id checks the newest request for that number that has not been verified. If you sent two codes, only the later one can be verified this way; pass request_id to target a specific request.
  • Resends. Each send is a new request with its own id, window and attempts. Resends count toward every per-number limit: the 30-second spacing, 3 in 10 minutes, and 10 in 24 hours.
  • The spoken clip. The audio that reads the code out is deleted once the request expires or reaches a final state, so a recording of the digits never lingers in your audio library.

A worked sequence, with max_attempts of 3

RequestHTTPBodyEvents
1st wrong code401incorrect_code, attempts_left: 2, verification_status: "pending"otp.failed
2nd wrong code401incorrect_code, attempts_left: 1, verification_status: "pending"otp.failed
3rd wrong code401max_attempts, attempts_left: 0, verification_status: "locked"otp.failed and otp.max_attempts
4th verify, any code400max_attempts, verification_status: "locked"None. Send a new code.

A wrong code with tries left

JSON
{
  "status": "error",
  "code": 401,
  "details": "Incorrect code.",
  "error": "Incorrect code.",
  "error_code": "incorrect_code",
  "verified": false,
  "attempts_left": 2,
  "verification_status": "pending"
}

The wrong code that locks it

JSON
{
  "status": "error",
  "code": 401,
  "details": "Incorrect code.",
  "error": "Incorrect code.",
  "error_code": "max_attempts",
  "verified": false,
  "attempts_left": 0,
  "verification_status": "locked"
}

Choosing the code

A new campaign generates 4 digits, kept so existing integrations that parse four digits keep working. It is not enough for anything that guards money or an account: four digits is 10,000 possibilities, and an attacker who can start fresh requests gets three tries at each one. Set six digits on the campaign for a login, a payment, a password reset or a high-value action. It is one field on the campaign, and it applies to every code that campaign sends.

If some codes need four digits and others six, make two authentication campaigns and name the one you want per request with campaign_id. That way the setting still lives in one place per campaign, and the dashboard shows you which is which.

You may also supply the code yourself. It must be 4 to 8 digits and nothing else; anything shorter, longer or non-numeric is refused with HTTP 400 and invalid_code, because a code we cannot read out cleanly would leave your customer hearing something different from what you are checking.

A guessable code is accepted, and it is yours to avoid
If the code you supply is trivially guessable, one digit repeated, a run of consecutive digits, or a two-digit pattern, we still place the call and the 200 carries warnings: ["weak_code"]. We do not refuse it, because refusing would break integrations that generate their own codes. Generating a code that cannot be guessed is your responsibility: use a cryptographic random source, or omit code and let us generate one.

Response when a supplied code is guessable

JSON
{
  "status": "success",
  "code": 200,
  "unique_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "reference_id": "signup-7781",
  "details": "Verification call queued.",
  "request_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e",
  "expires_in_sec": 600,
  "verification_status": "pending",
  "campaign_id": 48213,
  "warnings": ["weak_code"]
}

Webhook events

Register a webhook endpoint and every verification transition is pushed to you as a signed event, with data.object set to "verification" and your reference_id on the envelope. Four events cover the lifecycle.

EventFires whendata.statusdata.verified
otp.sentA voice verification call is queued for delivery.queuednull
otp.verifiedA verification code is confirmed correct.verifiedtrue
otp.failedA recipient entered an incorrect code. The verification is still open until it expires or runs out of attempts.failedfalse
otp.max_attemptsToo many incorrect attempts. The code is now locked and can no longer be verified. Send a new code to retry.max_attemptsfalse

Payload samples, the field-by-field verification object, signature verification, retries and event de-duplication are in the Telephony Webhooks reference.

The code is never sent
The verification code is never included in any webhook. An endpoint subscribed to no specific events receives all of them; branch on type (or data.object) when one handler covers calls and verifications alike.

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).

Which number the recipient sees

The campaign decides, and only the campaign. You pick it once when you set the campaign up, and every code that campaign sends comes from what you picked. You cannot set it per request, and nothing overrides it or stands in for it later.

  • If the campaign names one of your numbers, that number is used and the recipient sees your own line. Only your numbers enabled for voice broadcast can be picked.
  • If the campaign names a rotational pool, the call goes out from a number in that pool, and the pool rotates across its numbers and paces them on your behalf.
  • If what the campaign picked stops being usable— the number is switched off for broadcast, quarantined, or the pool is withdrawn — the request is refused with HTTP 400 telling you to open the campaign and pick again. Your codes are never quietly moved to some other number: you would not know it had happened, and neither would the customer seeing an unfamiliar caller.
  • If your account has no broadcast number and no pool at all, the request is refused with HTTP 400 and "No number on your account is enabled for voice broadcast. Ask your account manager to enable one, then retry." Retrying will not help until a number is enabled.
A team line is never used
A number your team answers calls on is never used to place a verification call, even if it is the only number on the account. If that is all you have, ask your account manager for a broadcast number or a rotational pool.

Phone number format

Send Indian mobile numbers in any of these forms; they are all normalised:

  • 10-digit local: 9812345678
  • With country code: 919812345678
  • With plus prefix: +919812345678
Mobile numbers only
The mobile number must begin with 6, 7, 8 or 9. Landline and clearly invalid numbers are rejected with HTTP 400 and "A valid Indian mobile number is required." Webhooks return the number in E.164 form (+91...) whatever form you sent.

Reference IDs

reference_id is an optional string you attach to a send or verify request, such as a signup id, a session id or an order number. It is echoed back in the response reference_id field and included on every webhook for the verification, so you can match our events to your own records without storing our ids.

Reference IDs are capped at 120 characters; control characters are stripped. A reference id never de-duplicates anything; use the Idempotency-Key header for retry safety.

Idempotency

Four endpoints accept an Idempotency-Key header: POST /otp/send and POST /otp/verify here, and POST /calls and POST /campaigns/:id/trigger in the Voice Broadcast API. Send a unique key with each logical request and a network retry can never place a second billed call, or spend a second verify attempt: if a request times out or the connection drops, resend the same request with the same key.

cURL
curl https://voice.agentive.co.in/api/public/v1/otp/send \
  -H "x-api-key: pk_live_..." \
  -H "x-api-secret: sk_live_..." \
  -H "Idempotency-Key: signup-7781-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{ "number": "9812345678", "reference_id": "signup-7781" }'

The key is 1 to 128 visible ASCII characters; a longer key is refused with HTTP 400 rather than cut short. Use something unique per action, such as a UUID or your signup id plus an attempt marker. Keys are scoped to your account and live for 24 hours.

A key is consumed only by a response that decided the request. Everything else releases it, so the same key is the right thing to retry with. The rule is identical on every Voice endpoint:

  • Stored and replayed: 2xx, 400 (validation), the 401 that reports an incorrect code (because the attempt was already spent), 404, and 422. A stored 400 or 404 is replayed for the key's 24 hours even after you fix the request or the dashboard setting behind it, so send the fixed request with a new key.
  • Not stored: 402 (balance), 403, 409 (still processing), 429 (any limit), and a 5xx raised before we asked the network to place the call. Once the condition clears, resend with the same key and the call is attempted.
  • Stored, and it needs a decision from you: a 5xx raised after we asked the network to place the call. It is replayed as HTTP 502 with error_code: "call_state_unknown", because we cannot say whether the code went out. Poll Get verification status before you resend, or choose a fresh key deliberately.
  • Replay markers: a replayed response carries both Idempotent-Replayed: true (the platform-wide name) and idempotency-replayed: true (the original one, kept so existing clients keep working).
  • Same key, different body: HTTP 422 with error_code: "idempotency_conflict". A new request needs a new key.
  • Key still in flight: HTTP 409 with error_code: "idempotency_in_progress" and Retry-After: 2, until the first request finishes. If our side restarts mid-request the key is released at once.
Why verify takes a key too
Every verify attempt is counted, right or wrong. Without a key, a client that retries after a timeout spends a second attempt on the same code and can lock the customer out. With a key, the retry replays the first answer and no attempt is spent.

Quick start

From a new key to a verified number, using your own phone as the recipient.

  1. Create your authentication campaign
    In your Voice dashboard, create a campaign of type Authentication. It holds everything about how the call is made: how many digits the code has, how long it stays valid, what is spoken, and which of your numbers (or which rotational pool) the recipient sees. Sending is refused until this exists, because there would be nothing to read out and no number to call from.
  2. Get your keys
    Open the API tab in your Voice dashboard and copy the publishable key and secret. The secret is revealed once; store it in your server's environment, never in client code.
  3. Send a code to your own number
    Replace 98XXXXXXXX with your mobile number. Note the request_id in the response.
    cURL
    curl https://voice.agentive.co.in/api/public/v1/otp/send \
      -H "x-api-key: pk_live_..." \
      -H "x-api-secret: sk_live_..." \
      -H "Content-Type: application/json" \
      -d '{ "number": "98XXXXXXXX", "reference_id": "test-1" }'
  4. Answer the call
    Your phone rings within moments. The code is read digit by digit, then repeated. Write it down.
  5. Verify the code
    Send the code back with the request_id. A correct code returns verified: true; a wrong one returns HTTP 401 with attempts_left, so try a wrong code first to see both.
    cURL
    curl https://voice.agentive.co.in/api/public/v1/otp/verify \
      -H "x-api-key: pk_live_..." \
      -H "x-api-secret: sk_live_..." \
      -H "Content-Type: application/json" \
      -d '{ "request_id": "REQUEST_ID_FROM_SEND", "code": "CODE_YOU_HEARD" }'
  6. Check the request, or subscribe to events
    Read the request's state at any time, or register a webhook to have otp.sent, otp.verified, otp.failed and otp.max_attempts pushed to you.
    cURL
    curl https://voice.agentive.co.in/api/public/v1/calls/REQUEST_ID_FROM_SEND \
      -H "x-api-key: pk_live_..." \
      -H "x-api-secret: sk_live_..."

Complete example

A send-then-verify pair you can drop into a signup or login handler. Call sendOtp when the customer submits their number and keep the returned request_id with their session; call verifyOtp with the code they type. A return of true means verified; anything else is the message to show them.

# 1. Send the code. Note request_id from the response.
curl https://voice.agentive.co.in/api/public/v1/otp/send \
  -H "x-api-key: pk_live_..." \
  -H "x-api-secret: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "number": "9812345678", "reference_id": "signup-7781" }'

# 2. Verify what the customer typed.
curl https://voice.agentive.co.in/api/public/v1/otp/verify \
  -H "x-api-key: pk_live_..." \
  -H "x-api-secret: sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "request_id": "5f3c8b2a-1d4e-4a9c-9e7b-2c6f0a1b3d8e", "code": "204815" }'
Good defaults for a signup form
On the campaign: six digits and a 10-minute window. In your form: disable the resend control for 30 seconds after each send (it is a hard limit, not a suggestion), show attempts_left after a wrong code, and start a fresh request on any 400 rather than letting the customer keep typing.

Next steps