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

ParameterDescription
ServiceSidYour 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

ParameterTypeRequiredDescription
TostringYesThe destination in E.164 format, including the leading +. Up to 20 characters, e.g. +9779800000000.
ChannelstringNoThe channel to try first: sms, whatsapp, viber, rcs or sna.
TemplateSidstringNoOne 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

HeaderRequiredDescription
X-API-KeyYesYour API key.
Idempotency-KeyNoA unique string of your choosing, up to 191 characters, scoped to your organization.

What Idempotency-Key actually protects

SituationWhat happens
You send with a key that has been used beforeThe 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 verificationA 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
}
FieldDescription
sidThe verification SID. Store it against your user’s session.
service_sid, account_sidYour two identifiers, echoed back.
toThe destination, normalized to E.164.
channelThe channel that confirmed delivery. See the caveat below.
statuspending, approved, expired, max_attempts_reached or failed.
validtrue only when status is approved.
send_code_attemptsAlways one entry: a verification is exactly one charged send.
attempts_remainingHow many more times the code may be checked before the verification locks. Not a Twilio field.
lookup, amount, payee, snaAlways 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 429 and 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

HTTPcodeCause
40060200To is missing or malformed, the ServiceSid is not VA-shaped, the number is not parseable E.164, or its country is not served.
40060200No TemplateSid given and your organization has no default template.
40060204No approved channel can reach this destination.
40060238This number is suppressed after failing on every channel.
40020003Your wallet does not hold enough credit. Nothing was sent or charged.
40120003Missing, invalid or revoked API key, an IP outside the key’s allowlist, or a suspended organization.
40360238A delivery rule forbids this destination.
40420404The TemplateSid is not one of yours, or the ServiceSid is another organization’s.
42960203Too many sends to this number in the last hour.
42960212Too many sends to this number in the last few minutes.
42920429Your API key exceeded its rate limit.
50020500We 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

ParameterTypeRequiredDescription
CodestringYesThe code your user typed. Up to 10 characters.
VerificationSidstringOne of the twoThe VE… SID returned when you sent the code.
TostringOne of the twoThe 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

HTTPcodeCause
40060200Code is missing, neither VerificationSid nor To was given, or the ServiceSid is malformed.
40120003Missing, invalid or revoked API key, or a suspended organization.
40420404No such verification for your organization — including one already approved, expired, or locked.
42960202The five-attempt allowance is exhausted and the verification is locked.
42920429Your 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

StateThis endpoint answers
Pending, code still valid200 with the verification body
Approved404, code 20404
Expired404, code 20404
Locked after too many wrong checks404, code 20404
Unknown SID, or another organization’s404, 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.

EventWhen
otp.deliveredA carrier confirmed delivery.
otp.failedEvery eligible channel was tried and none delivered.
otp.verifiedA correct code was accepted.
otp.expiredThe code’s lifetime elapsed without a correct code.
credit.lowYour 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.

HeaderMeaning
X-SF-Signature"sha256=" + HMAC_SHA256(your_secret, raw_body)
X-SF-EventThe event name, also in the body
X-SF-DeliveryA unique id for this delivery attempt
X-SF-TimestampWhen 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

HTTPcodeCause
40060200The ServiceSid is not VA-shaped.
40120003Missing, invalid or revoked API key, or a suspended organization.
40420404Not pending, unknown, or another organization’s.
42920429Your API key exceeded its rate limit.
Get Started Talk to Sales