> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wave.sa/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify & OTP

> Confirm phone possession — Wave sends a code over WhatsApp, the user submits it, you get a signed verdict.

**Verify** is a messaging-based *one-time code* primitive. Your backend asks Wave
to send a verification code to a phone number over WhatsApp; the user reads and
submits it; you check the code and get a trustworthy, machine-readable verdict
over a signed webhook (and a status API).

It's a phone-possession factor for signup, step-up auth, and password reset — a
security primitive, not a menu-tree IVR. Because the outcome gates access, it is
**fail-closed by design**: only an exact, in-time, in-attempt-budget code grants,
and a wrong or expired code never does.

<Note>
  This is an API-triggered, outbound flow (Wave sends the user a code). It is
  distinct from [Voice Authentication](/voice/voice-auth) (a call-to-approve
  primitive). Verify is gated — ask your Wave contact to enable it for your
  account.
</Note>

## Authentication & tiers

Every `/v1/verify` endpoint needs a **production** API key (`sk_live_`) with the
**`verify:write`** scope (or `verify:read` for status). Sandbox keys can't send
real verification codes.

## Create a verification

```bash theme={null}
curl https://api.wave.sa/v1/verify \
  -H "Authorization: Bearer sk_live_…" \
  -H "Idempotency-Key: 4e1a…" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+9665XXXXXXXX",
    "channel": "whatsapp",
    "locale": "ar",
    "ttl_seconds": 300,
    "metadata": { "ref": "REF-8842" }
  }'
```

| Field             | Required | Notes                                                                                                                                |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `phone_number`    | ✅        | E.164 (`+9665…`). The number Wave sends the code to.                                                                                 |
| `channel`         | —        | `whatsapp` (default). The delivery channel.                                                                                          |
| `locale`          | —        | `ar` (default) or `en` — the message language.                                                                                       |
| `ttl_seconds`     | —        | 60–900, default 300. The code expires after this.                                                                                    |
| `idempotency_key` | —        | Send it as the `Idempotency-Key` header; a repeat returns the same verification without a second code minted or second message sent. |
| `metadata`        | —        | Opaque; echoed back in the webhook.                                                                                                  |

**`201`** returns the verification id and its expiry:

```json theme={null}
{ "verification_id": "ver_9f1c…", "state": "initiated", "expires_at": "2026-09-01T12:05:00Z" }
```

## The verdict — fail closed

A verification resolves to exactly one terminal `state`:

| State          | Meaning                                             | Grants access? |
| -------------- | --------------------------------------------------- | -------------- |
| `verified`     | The user submitted the correct code in time         | ✅ **yes**      |
| `failed`       | The code was wrong (attempt limit exhausted)        | no             |
| `expired`      | No valid check before `expires_at`                  | no             |
| `max_attempts` | Five wrong attempts reached                         | no             |
| `canceled`     | Superseded by a new verification to the same number | no             |

<Warning>
  Only `verified` grants access. Treat **every other state as a non-grant** and
  fail closed. Always confirm the verdict via the status API (below) before
  honoring an approval — defense in depth.
</Warning>

## Check the code

```bash theme={null}
curl https://api.wave.sa/v1/verify/check \
  -H "Authorization: Bearer sk_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "verification_id": "ver_9f1c…",
    "code": "123456"
  }'
```

| Field             | Required | Notes                              |
| ----------------- | -------- | ---------------------------------- |
| `verification_id` | ✅        | From the create response.          |
| `code`            | ✅        | The 6-digit code the user entered. |

**`200`** returns the verification state and a boolean verdict:

```json theme={null}
{ "verification_id": "ver_9f1c…", "state": "verified", "verified": true }
```

On a wrong code, the response carries `state: "awaiting_check"` and `verified: false`; the
attempt counter increments. After five wrong attempts, `state` becomes `max_attempts`
and further checks fail with `state: "max_attempts"`.

## The result webhook

On a terminal state Wave sends a signed **`verify.completed`** event to your
project's webhook endpoint:

```json theme={null}
{
  "event": "verify.completed",
  "data": {
    "verification_id": "ver_9f1c…",
    "state": "verified",
    "decided_at": "2026-09-01T12:02:10Z",
    "attempts": 1,
    "channel": "whatsapp"
  }
}
```

Verify every delivery:

* **`X-Wave-Signature`** — `sha256=…`, an HMAC-SHA256 of the raw body with your
  endpoint's signing secret. Reject a mismatch.
* **`X-Wave-Timestamp`** — unix seconds; reject deliveries older than your window
  (e.g. 5 minutes).
* **`X-Wave-Event-Id`** — stable across retries; dedupe on it.

The webhook is a notification — reconfirm via the status API before granting.

## Poll status

```bash theme={null}
curl https://api.wave.sa/v1/verify/ver_9f1c… \
  -H "Authorization: Bearer sk_live_…"
```

Returns the current `state`, `attempts`, `channel`, timestamps, and (once
terminal) the decision. Scoped to your organization only.

## One code per number, code expiry & idempotency

* **One live code per number** — a new `POST /v1/verify` to the same number
  supersedes the previous code and cancels the old verification.
* **Code expiry** — a code expires at `expires_at`. A check after expiry returns
  `state: "expired"` and does not increment attempts.
* **Idempotency** — send an `Idempotency-Key`; a retry with the same key returns
  the same verification and **never mints a second code or sends a second
  message**.

## Rate limits & abuse controls

* **Per-number limits** — Wave caps verifications to a single number per API key,
  and blocks known-abusive numbers/prefixes, to prevent code-bombing and toll
  fraud. Over a limit → **`429`**.
* **Per-verification attempt cap** — five wrong attempts → `max_attempts`. No further
  checks are accepted.
* **Global volume limits** — Wave tracks overall verification volume per API key.
  Sustained excess → **`429`**.

## Errors

| Code                      | HTTP | When                                            |
| ------------------------- | ---- | ----------------------------------------------- |
| `VERIFY_DISABLED`         | 503  | Not enabled for this account                    |
| `SANDBOX_KEY_NOT_ALLOWED` | 400  | A sandbox key was used                          |
| `VALIDATION_ERROR`        | 400  | Bad phone/locale/ttl                            |
| `VERIFY_BLOCKED`          | 403  | The target number is blocklisted                |
| `VERIFY_RATE_LIMITED`     | 429  | A per-number/per-originator limit was hit       |
| `VERIFY_NOT_FOUND`        | 404  | No such verification for your organization      |
| `INVALID_CODE`            | 400  | The code is malformed or wrong                  |
| `MAX_ATTEMPTS_EXCEEDED`   | 400  | The verification has exceeded the attempt limit |
| `VERIFICATION_EXPIRED`    | 400  | The verification has expired                    |
