
# Email & SMS verification

```
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](https://trust.myaza.co/documentation/environments/markdown#production).

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

```bash
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

```
POST /api/kyc/contact/verifications/:id/check
```

| Field | Required | Description |
|---|---|---|
| `code` | yes | The code the person typed. |

```bash
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

```
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**

| 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_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](https://trust.myaza.co/documentation/identity-hub/markdown) 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. |
