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

# Voice Authentication

> Verify a person by phone — Wave calls them, they press to approve, you get a signed verdict.

**Voice Authentication** is an outbound *call-to-approve* primitive. Your backend
asks Wave to verify someone; Wave calls that number, plays a short bilingual
prompt describing what's being authorized, captures the keypress, and returns a
trustworthy, machine-readable verdict over a signed webhook (and a status API).

It's the voice equivalent of a push-to-approve / OTP step — a security factor, not
a menu-tree IVR. Because the outcome gates access, it is **fail-closed by design**:
only an explicit approval grants, and a machine/voicemail answer can never approve.

<Note>
  This is an outbound, API-triggered flow (Wave calls the user). It is distinct
  from [IVR Menus](/voice/ivr) (a caller dials *you*). Voice Authentication is
  gated — ask your Wave contact to enable it for your account.
</Note>

## Authentication & tiers

Every `/v1/voice-auth` endpoint needs a **production** API key (`sk_live_`) with
the **`voice_auth:write`** scope (or `voice_auth:read` for status). Sandbox keys
can't place real authentication calls.

## Create a challenge

```bash theme={null}
curl https://api.wave.sa/v1/voice-auth/challenges \
  -H "Authorization: Bearer sk_live_…" \
  -H "Idempotency-Key: 4e1a…" \
  -H "Content-Type: application/json" \
  -d '{
    "phone_number": "+9665XXXXXXXX",
    "locale": "ar",
    "context": { "action": "login", "app_name": "Wave Secure" },
    "assurance": "standard",
    "ttl_seconds": 90,
    "retry_on_no_answer": 0,
    "metadata": { "ref": "REF-8842" }
  }'
```

| Field                | Required | Notes                                                                                                                |
| -------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- |
| `phone_number`       | ✅        | E.164 (`+9665…`). The number Wave dials.                                                                             |
| `locale`             | —        | `ar` (default) or `en` — the prompt language.                                                                        |
| `context`            | —        | `{ action, app_name, reference }`, injected into the prompt. **Non-secret only** — never a password/OTP/card number. |
| `assurance`          | —        | `standard` (press 1) or `number_match` (see below). Default `standard`.                                              |
| `ttl_seconds`        | —        | 30–180, default 90. The challenge expires after this.                                                                |
| `retry_on_no_answer` | —        | 0–2, default 0 — auto-redials on no-answer/busy, within the TTL.                                                     |
| `idempotency_key`    | —        | Send it as the `Idempotency-Key` header; a repeat returns the same challenge without a second call.                  |
| `metadata`           | —        | Opaque; echoed back in the webhook.                                                                                  |

**`201`** returns the challenge id and (for `number_match`) the digits to display:

```json theme={null}
{ "challenge_id": "9f1c…", "state": "initiated", "expires_at": "2026-09-01T12:01:30Z" }
```

## The verdict — fail closed

A challenge resolves to exactly one terminal `state`:

| State                           | Meaning                                                  | Grants access? |
| ------------------------------- | -------------------------------------------------------- | -------------- |
| `approved`                      | The user approved (pressed 1, or keyed the match digits) | ✅ **yes**      |
| `declined`                      | The user declined (pressed 2)                            | no             |
| `timeout`                       | Answered, no valid input after the reprompts             | no             |
| `no_answer` / `busy` / `failed` | The call didn't connect                                  | no             |
| `machine_detected`              | A voicemail/machine answered                             | no             |
| `expired`                       | No decision before `expires_at`                          | no             |

<Warning>
  Only `approved` and `declined` are affirmative user decisions. 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>

### Answering-machine detection (AMD)

Before any prompt, Wave classifies who answered. A **machine or voicemail** answer
resolves to `machine_detected` and the approval prompt is **never played** — so a
voicemail can never capture a keypress or leave a stray approval. This is enforced,
not best-effort.

## High-assurance number matching

For high-value actions (payments, admin changes), set `assurance: "number_match"`.
The `201` response includes `match_digits` (e.g. `"47"`) — display them to your
user. The call then asks the callee to **key those exact digits to approve**. A
wrong entry never approves — this defeats "just press 1" MFA-fatigue and accidental
approvals.

```json theme={null}
{ "challenge_id": "9f1c…", "state": "initiated", "expires_at": "…", "match_digits": "47" }
```

## The result webhook

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

```json theme={null}
{
  "event": "voice_auth.completed",
  "data": {
    "challenge_id": "9f1c…",
    "state": "approved",
    "decided_at": "2026-09-01T12:00:41Z",
    "attempts": 1,
    "amd_result": "human"
  }
}
```

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/voice-auth/challenges/9f1c… \
  -H "Authorization: Bearer sk_live_…"
```

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

## Idempotency, rate limits & abuse controls

* **Idempotency** — send an `Idempotency-Key`; a retry with the same key returns
  the same challenge and never places a second call.
* **Per-number & per-originator limits** — Wave caps challenges to a single number
  and per API key, and blocks known-abusive numbers/prefixes, to prevent
  voice-bombing and toll fraud. Over a limit → **`429`**.

## Errors

| Code                      | HTTP | When                                                |
| ------------------------- | ---- | --------------------------------------------------- |
| `VOICE_AUTH_DISABLED`     | 503  | Not enabled for this account                        |
| `SANDBOX_KEY_NOT_ALLOWED` | 400  | A sandbox key was used                              |
| `VALIDATION_ERROR`        | 400  | Bad phone/locale/ttl, or a secret-bearing `context` |
| `VOICE_AUTH_BLOCKED`      | 403  | The target number is blocklisted                    |
| `VOICE_AUTH_RATE_LIMITED` | 429  | A per-number/per-originator limit was hit           |
| `VOICE_AUTH_NOT_FOUND`    | 404  | No such challenge for your organization             |
