API Documentation
Send a code, check it, and read a verification. Three endpoints, Twilio-shaped, with the behaviour differences called out.
Send a code
POST /v2/Services/{ServiceSid}/Verifications
Generates a six-digit code, charges your wallet, and queues delivery to the destination. Returns the verification SID you use to check the code and to follow what happened to it.
This is Twilio’s Create Verification, and it is also the only way to send a code again — see repeat sends below.
Path parameters
| Parameter | Description |
|---|---|
ServiceSid | Your Service SID, the VA… value on the dashboard’s Profile page. Required. A malformed value is a 400; another organization’s is a 404. |
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
To | string | Yes | The destination in E.164 format, including the leading +. Up to 20 characters, e.g. +9779800000000. |
Channel | string | No | The channel to try first: sms, whatsapp, viber, rcs or sna. |
TemplateSid | string | No | One of your template identifiers. Omit to use your organization’s default template. |
Every other parameter is accepted and ignored, including all of Twilio’s that we do not model.
Headers
| Header | Required | Description |
|---|---|---|
X-API-Key | Yes | Your API key. |
Idempotency-Key | No | A unique string of your choosing, up to 191 characters, scoped to your organization. |
What Idempotency-Key actually protects
| Situation | What happens |
|---|---|
| You send with a key that has been used before | The original verification is returned unchanged, and nothing is charged — whether or not the destination still has a pending verification. |
| You send without a key to a number that still has a pending verification | A new verification is created and charged. |
The key identifies the original request, not the destination. Reusing a key with a different To returns the original verification — for the original number. Generate a fresh key per verification attempt, never a per-user or per-session constant, and reuse it on every retry of that attempt.
Request
curl -X POST https://api.secondfactor.ai/v2/Services/VA0a1b2c…/Verifications \
-H "X-API-Key: sf_4f8a1c9b2e7d.xN3qR7vK2mZpL9wYcB4tH6jF8sD1aG5u" \
-H "Idempotency-Key: signup-4821-attempt-1" \
-d "To=+9779800000000" \
-d "Channel=sms"
JSON works equally well:
curl -X POST https://api.secondfactor.ai/v2/Services/VA0a1b2c…/Verifications \
-H "X-API-Key: sf_4f8a1c9b2e7d…" \
-H "Content-Type: application/json" \
-d '{"To": "+9779800000000", "Channel": "sms"}'
Response — 201 Created
{
"sid": "VE0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"service_sid": "VA0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"account_sid": "AC0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"to": "+9779800000000",
"channel": "sms",
"status": "pending",
"valid": false,
"date_created": "2026-09-12T09:14:22Z",
"date_updated": "2026-09-12T09:14:22Z",
"lookup": {},
"amount": null,
"payee": null,
"send_code_attempts": [
{"attempt_sid": "VE0a1b2c…", "channel": "SMS", "time": "2026-09-12T09:14:22Z"}
],
"sna": null,
"url": "https://api.secondfactor.ai/v2/Services/VA0a1b2c…/Verifications/VE0a1b2c…",
"attempts_remaining": 5
}
| Field | Description |
|---|---|
sid | The verification SID. Store it against your user’s session. |
service_sid, account_sid | Your two identifiers, echoed back. |
to | The destination, normalized to E.164. |
channel | The channel that confirmed delivery. See the caveat below. |
status | pending, approved, expired, max_attempts_reached or failed. |
valid | true only when status is approved. |
send_code_attempts | Always one entry: a verification is exactly one charged send. |
attempts_remaining | How many more times the code may be checked before the verification locks. Not a Twilio field. |
lookup, amount, payee, sna | Always empty or null, present for Twilio-client compatibility. |
channel reads sms until a delivery is confirmed. Delivery is still in flight when you get this response, so do not read the 201’s channel as the route taken. Wait for the webhook, or fetch the verification.
Repeat sends to the same number
There is no resend endpoint. If your user says the code never arrived, send again with the same To. Every send is a new verification: a new SID, a new code, a fresh check allowance, and a new charge. The previous verification is not cancelled, but a check that names only To resolves against the newest one.
- Every send is charged. Twilio bundles up to five sends into one verification charge; we charge each one.
- There is no resend cooldown of its own. What bounds repeat sends is the per-number burst window and hourly cap — the send past either answers
429and is not charged. - The code changes. Store the SID from the latest response and check against that one.
Because an enabled “send again” button is a billing problem as much as a UX one, gate it in your own UI.
Choosing a channel
Channel is a preference, not an instruction: it moves your chosen channel to the front of the routing order and never removes the others. The preference is honoured only for organizations priced per channel; on blended pricing it is ignored in favour of the cheapest route. email and call are accepted for compatibility and treated as no preference, as is any unrecognised value.
Errors
| HTTP | code | Cause |
|---|---|---|
| 400 | 60200 | To is missing or malformed, the ServiceSid is not VA-shaped, the number is not parseable E.164, or its country is not served. |
| 400 | 60200 | No TemplateSid given and your organization has no default template. |
| 400 | 60204 | No approved channel can reach this destination. |
| 400 | 60238 | This number is suppressed after failing on every channel. |
| 400 | 20003 | Your wallet does not hold enough credit. Nothing was sent or charged. |
| 401 | 20003 | Missing, invalid or revoked API key, an IP outside the key’s allowlist, or a suspended organization. |
| 403 | 60238 | A delivery rule forbids this destination. |
| 404 | 20404 | The TemplateSid is not one of yours, or the ServiceSid is another organization’s. |
| 429 | 60203 | Too many sends to this number in the last hour. |
| 429 | 60212 | Too many sends to this number in the last few minutes. |
| 429 | 20429 | Your API key exceeded its rate limit. |
| 500 | 20500 | We could not price this destination. |
Insufficient credit is a 400, not a 402, and a suspended organization is a 401, not a 403.
Check a code
POST /v2/Services/{ServiceSid}/VerificationCheck
Submits the code your user entered. Free — the send was already charged. The path is Twilio’s: VerificationCheck is singular and carries no verification SID; you identify the verification in the body.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
Code | string | Yes | The code your user typed. Up to 10 characters. |
VerificationSid | string | One of the two | The VE… SID returned when you sent the code. |
To | string | One of the two | The destination in E.164. Resolves to that number’s most recent pending verification. |
Sending neither is a 400. Sending both is accepted and VerificationSid wins — prefer it, since checking by To is ambiguous when a user has more than one signup in flight.
Request
curl -X POST https://api.secondfactor.ai/v2/Services/VA0a1b2c…/VerificationCheck \
-H "X-API-Key: sf_4f8a1c9b2e7d…" \
-d "VerificationSid=VE0a1b2c3d4e5f60718293a4b5c6d7e8f9" \
-d "Code=482915"
Response
A correct code and a wrong code are both 200 OK. Branch on status, not on the HTTP status code.
// correct code
{ "sid": "VE0a1b2c…", "to": "+9779800000000", "channel": "sms",
"status": "approved", "valid": true,
"date_created": "2026-09-12T09:14:22Z", "date_updated": "2026-09-12T09:15:03Z" }
// wrong code
{ "sid": "VE0a1b2c…", "to": "+9779800000000", "channel": "sms",
"status": "pending", "valid": false,
"date_created": "2026-09-12T09:14:22Z", "date_updated": "2026-09-12T09:14:22Z" }
On approval the verification is terminal: a further check returns 404, and so does fetching it. The check response carries no attempts_remaining — fetch the verification to read the remaining allowance, which is free and consumes nothing.
response = requests.post(
f"{BASE}/v2/Services/{SERVICE_SID}/VerificationCheck",
headers={"X-API-Key": API_KEY},
data={"VerificationSid": sid, "Code": code_from_user},
)
if response.status_code == 200 and response.json()["status"] == "approved":
log_the_user_in()
elif response.status_code == 200:
show("That code is not right. Try again.")
else:
handle_error(response.json()["code"])
Attempts
Each check consumes one attempt, right or wrong. The allowance is five per verification, enforced across concurrent checks. A repeat send is a new verification with a new code and a fresh allowance. When the allowance runs out the verification is locked permanently and the check that exhausted it answers 429 with code 60202.
Errors
| HTTP | code | Cause |
|---|---|---|
| 400 | 60200 | Code is missing, neither VerificationSid nor To was given, or the ServiceSid is malformed. |
| 401 | 20003 | Missing, invalid or revoked API key, or a suspended organization. |
| 404 | 20404 | No such verification for your organization — including one already approved, expired, or locked. |
| 429 | 60202 | The five-attempt allowance is exhausted and the verification is locked. |
| 429 | 20429 | Your API key exceeded its rate limit. |
A wrong code is not in this table — it is a 200. And a 404 is ambiguous on purpose: unknown, approved and expired all answer the same way, so track verification outcomes in your own session state.
Fetch a verification
GET /v2/Services/{ServiceSid}/Verifications/{VerificationSid}
Reads a verification that is still pending. Free, and it changes nothing — in particular it does not consume a check attempt.
curl https://api.secondfactor.ai/v2/Services/VA0a1b2c…/Verifications/VE0a1b2c… \
-H "X-API-Key: sf_4f8a1c9b2e7d…"
200 OK, with the same field set as the send response. Two fields are worth reading carefully: channel is the channel that confirmed delivery (until a carrier confirms it reads sms, and we hop to the next channel when the current one has not delivered within eight seconds), and send_code_attempts always has one entry because a verification is exactly one charged send.
A finished verification is a 404
| State | This endpoint answers |
|---|---|
| Pending, code still valid | 200 with the verification body |
| Approved | 404, code 20404 |
| Expired | 404, code 20404 |
| Locked after too many wrong checks | 404, code 20404 |
| Unknown SID, or another organization’s | 404, code 20404 |
A 404 means “not pending any more” and cannot tell you which terminal state you reached. Learn the outcome from the check call and from webhooks, not from here.
Webhooks
Configure a webhook URL and a signing secret on the Integrate page of the dashboard. We push lifecycle events as they happen.
| Event | When |
|---|---|
otp.delivered | A carrier confirmed delivery. |
otp.failed | Every eligible channel was tried and none delivered. |
otp.verified | A correct code was accepted. |
otp.expired | The code’s lifetime elapsed without a correct code. |
credit.low | Your wallet balance crossed its alert threshold. |
{
"event": "otp.delivered",
"request_id": "OTP26H7K3M9PQRSTVW",
"phone": "+9779800000000",
"country": "NP",
"status": "DELIVERED",
"channel": "WHATSAPP",
"charged": "0.02",
"verified_at": null,
"requested_at": "2026-09-12T09:14:22.104382+00:00"
}
request_id is not the VE… SID. Match an event to a user by phone plus requested_at, or use the check response rather than the webhook as your source of truth.
status is our delivery vocabulary — PENDING, DELIVERED, FAILED, EXPIRED or REJECTED — and not the verification status this API returns. charged is what the request has cost so far, rounded to two decimals.
| Header | Meaning |
|---|---|
X-SF-Signature | "sha256=" + HMAC_SHA256(your_secret, raw_body) |
X-SF-Event | The event name, also in the body |
X-SF-Delivery | A unique id for this delivery attempt |
X-SF-Timestamp | When we sent it |
Compute the HMAC over the raw body, compare it in constant time, and reject a stale X-SF-Timestamp to defeat replay. A non-2xx response is retried up to five times with exponential backoff — roughly 30 seconds, then 1, 2 and 4 minutes — each attempt timing out after five seconds. Your endpoint must be reachable over public DNS.
Polling
If you cannot take a webhook, polling this endpoint works, but polling tells you when a verification stops being pending, not what it became. Poll no more than once per second, and stop on the first 404 or your own timeout. Your key’s rate limit applies.
Errors
| HTTP | code | Cause |
|---|---|---|
| 400 | 60200 | The ServiceSid is not VA-shaped. |
| 401 | 20003 | Missing, invalid or revoked API key, or a suspended organization. |
| 404 | 20404 | Not pending, unknown, or another organization’s. |
| 429 | 20429 | Your API key exceeded its rate limit. |