code
POST /api/kyc/contact/verifications
POST /api/kyc/contact/verifications/:id/check
GET  /api/kyc/contact/verifications/:id

Prove 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

code
POST /api/kyc/contact/verifications
FieldRequiredDescription
channelyesemail or phone.
destinationyesThe address or number to verify. A phone number in national format resolves against country; one written with a dial code resolves on its own.
countrynoISO-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.
vianosms (default) or whatsapp. WhatsApp is accepted and currently delivers by SMS; the response says which channel actually carried the code.
codeLengthnoDigits in the code, 4 to 8. Default 6.
maxAttemptsnoWrong codes allowed before the challenge dies, 1 to 5. Default 3.
externalUserIdnoYour 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.
shell
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"
  }'
json
{
  "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

code
POST /api/kyc/contact/verifications/:id/check
FieldRequiredDescription
codeyesThe code the person typed.
shell
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" }'
json
{
  "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

code
GET /api/kyc/contact/verifications/:id

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

Email

FieldMeaning
domainThe mail domain.
disposableThe domain is a known throwaway mailbox service.
freeProviderA consumer email service such as Gmail or Outlook.
mxFoundThe domain publishes mail servers. null means the lookup could not answer, which is not the same as no.

Phone

FieldMeaning
countryISO-2 the number belongs to, from its dial code.
callingCodeCountry calling code, digits only.
validThe number is a possible, valid number for that country.
lineTypemobile, fixed_line, voip, toll_free and so on. null when the range cannot be classified.
virtualtrue 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_credits with the required amount, rather than spending a message you cannot pay for.
  • Sandbox and development are free, and chargedAmount is null there.

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

StatusCodeMeaning
400invalid_requestThe body failed validation. message says what.
400invalid_destinationThe phone number could not be parsed. Send country for national formats.
400invalid_codeWrong code. attemptsRemaining says how many tries are left.
402insufficient_creditsBalance too low for the check. required and balance are decimal strings.
403secret_key_requiredA publishable key was used. This surface is secret keys only.
403business_not_approvedProduction access needs an approved business.
404challenge_not_foundUnknown id, or one that belongs to another organisation, another environment, or the SDK's contact step rather than this API.
410challenge_expiredThe five-minute window closed. Send a new code.
429send_rate_limitedA send cap was reached. See Rate limits.
429too_many_attemptsThe attempt budget is spent. Send a new code.
502send_failedThe message could not be handed to its delivery channel. Nothing was charged.