On this page
POST /api/kyc/contact/verifications
POST /api/kyc/contact/verifications/:id/check
GET /api/kyc/contact/verifications/:idProve that someone controls an email address or a phone number. Send a one-time code, then check the code they typed. Both calls answer synchronously, so there is nothing to poll and no webhook to wait for.
This is the server-to-server form of the contact step the SDK runs inside a verification flow. Use it when you want the possession check on its own: at sign-up, before a payout, when a customer changes their number.
Authentication: Authorization: Bearer sk_… (secret keys only). The response describes a real person's contact details and the call spends your credits on a message, so it never belongs in client-side code.
Production note: sk_live_ keys require an approved business.
Send a code
POST /api/kyc/contact/verifications| Field | Required | Description |
|---|---|---|
channel | yes | email or phone. |
destination | yes | The address or number to verify. A phone number in national format resolves against country; one written with a dial code resolves on its own. |
country | no | ISO-2. Resolves national phone formats, and selects the price row for markets you have priced separately. It is stored on the challenge and reused when the check is charged, so the amount you are quoted here is the amount you are billed. |
via | no | sms (default) or whatsapp. WhatsApp is accepted and currently delivers by SMS; the response says which channel actually carried the code. |
codeLength | no | Digits in the code, 4 to 8. Default 6. |
maxAttempts | no | Wrong codes allowed before the challenge dies, 1 to 5. Default 3. |
externalUserId | no | Your own reference for this person. When you already hold a compliance entity under that reference, a verified contact is recorded against it as proved evidence. |
curl -X POST https://trust.myaza.app/api/kyc/contact/verifications \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"channel": "phone",
"destination": "08031234567",
"country": "NG",
"externalUserId": "user_8f21"
}'{
"contactVerification": {
"id": "clx8s0a4p0001qw9k2m7f3xyz",
"status": "pending",
"channel": "phone",
"destination": "+2348031234567",
"deliveryChannel": "sms",
"country": "NG",
"signals": {
"country": "NG",
"valid": true,
"callingCode": "234",
"lineType": "mobile",
"virtual": false
},
"externalUserId": "user_8f21",
"attemptsUsed": 0,
"chargedAmount": null,
"verifiedAt": null,
"expiresAt": "2026-09-12T10:05:00.000Z",
"createdAt": "2026-09-12T10:00:00.000Z"
}
}destination comes back normalised: an email is lowercased, a phone number is E.164. Store that form, because it is what the check and the entity record use.
Codes are valid for five minutes.
Check the code
POST /api/kyc/contact/verifications/:id/check| Field | Required | Description |
|---|---|---|
code | yes | The code the person typed. |
curl -X POST https://trust.myaza.app/api/kyc/contact/verifications/clx8s0a4p0001qw9k2m7f3xyz/check \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{ "code": "418305" }'{
"contactVerification": {
"id": "clx8s0a4p0001qw9k2m7f3xyz",
"status": "verified",
"channel": "phone",
"destination": "+2348031234567",
"deliveryChannel": "sms",
"country": "NG",
"signals": { "country": "NG", "valid": true, "callingCode": "234", "lineType": "mobile", "virtual": false },
"externalUserId": "user_8f21",
"attemptsUsed": 1,
"chargedAmount": "0.0500",
"verifiedAt": "2026-09-12T10:01:12.000Z",
"expiresAt": "2026-09-12T10:05:00.000Z",
"createdAt": "2026-09-12T10:00:00.000Z"
}
}A wrong code answers 400 invalid_code with attemptsRemaining, so you can tell the person how many tries are left. When the budget runs out the challenge is dead and you send a new code.
Re-checking a verified challenge is safe. It returns the same record and does not charge again.
Read a challenge
GET /api/kyc/contact/verifications/:idThe same record, at any point in its life. status is pending, verified or expired. Useful for reconciling your own state after a dropped response.
What the signals tell you
Every challenge carries the intelligence computed when the code was sent, at no extra cost. Branch on it however your risk policy needs.
| Field | Meaning |
|---|---|
domain | The mail domain. |
disposable | The domain is a known throwaway mailbox service. |
freeProvider | A consumer email service such as Gmail or Outlook. |
mxFound | The domain publishes mail servers. null means the lookup could not answer, which is not the same as no. |
Phone
| Field | Meaning |
|---|---|
country | ISO-2 the number belongs to, from its dial code. |
callingCode | Country calling code, digits only. |
valid | The number is a possible, valid number for that country. |
lineType | mobile, fixed_line, voip, toll_free and so on. null when the range cannot be classified. |
virtual | true only for a known VOIP range, false for any other known type, null when the type is unknown. Fire rules on true, never on a gap. |
Pricing
A check bills the same component your workflows already bill for the same work: email_verification or phone_verification on the sdk_verification module. One rate card governs both doors, and Billing in your dashboard shows the rate.
Three rules follow from that:
- A code that is never checked costs nothing. Billing happens when the check completes and returns an answer, not when the message goes out.
- We refuse before we send. If your balance cannot cover the check the send answers
402 insufficient_creditswith the required amount, rather than spending a message you cannot pay for. - Sandbox and development are free, and
chargedAmountisnullthere.
chargedAmount on a completed check is what it cost, in USD, as a decimal string.
Sandbox
On sk_test_ and sk_dev_ keys nothing is sent to anyone. The code is all zeros at the length you asked for, so 000000 by default, 0000 at codeLength: 4. deliveryChannel comes back as test. Everything else behaves exactly as it does live, so you can build and test the whole flow at no cost.
Rate limits
Two caps protect your account and ours, both counted per rolling hour:
- Five codes per destination. Hammering one number is stopped before it starts.
- A per-organisation ceiling across all destinations, 500 by default. This is what bounds a leaked key rotating through numbers. Contact support if your volume needs more.
Over either cap, the send answers 429 send_rate_limited.
Using a verified contact in a KYC flow
A contact proved here is deliberately not carried into a POST /verify submission. That submission prices its own contact steps, so honouring a proof from this API would bill the same check twice. If you want the contact verified as part of a verification, use the SDK's contact step, which is configured per workflow.
What this API does record, when you send an externalUserId and an entity already exists under it, is the proved contact on that entity's compliance record as otp_verified evidence, which is the strongest source there is. It never creates an entity that does not exist.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The body failed validation. message says what. |
| 400 | invalid_destination | The phone number could not be parsed. Send country for national formats. |
| 400 | invalid_code | Wrong code. attemptsRemaining says how many tries are left. |
| 402 | insufficient_credits | Balance too low for the check. required and balance are decimal strings. |
| 403 | secret_key_required | A publishable key was used. This surface is secret keys only. |
| 403 | business_not_approved | Production access needs an approved business. |
| 404 | challenge_not_found | Unknown id, or one that belongs to another organisation, another environment, or the SDK's contact step rather than this API. |
| 410 | challenge_expired | The five-minute window closed. Send a new code. |
| 429 | send_rate_limited | A send cap was reached. See Rate limits. |
| 429 | too_many_attempts | The attempt budget is spent. Send a new code. |
| 502 | send_failed | The message could not be handed to its delivery channel. Nothing was charged. |