WhatsApp phone verification, send and verify.

```ts
await connect.otp.send({ phone: "+919876543210", workspaceId });
const { verified } = await connect.otp.verify({ phone: "+919876543210", code });
```

**Use a test-mode key while you build.** A key's `mode` decides whether a send is real: `test` returns a fixed code in the response and sends nothing, `live` sends a real WhatsApp message and costs real money. Set it when you create the key - the field only appears once an OTP scope is selected.

Sending is rate-limited per key *and* per phone number, which is what stops a leaked key becoming somebody else's SMS bill. Treat a 429 as "try later" and surface a wait to the user rather than retrying in a loop; the response carries `Retry-After` in seconds.

Split scopes on purpose: a frontend-adjacent service that only ever checks codes can hold `otp:verify` without being able to send.

## Endpoints

### POST `/api/v1/application/otp/send`

Send a WhatsApp (or SMS) OTP

**Scope** `otp:send` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key, 5 requests / hour per key+phone

Starts a phone verification via WhatsApp (or SMS, once wired to a provider). A test-mode key (`ApiKey.mode`) never reaches a real provider and echoes the code back in the response, so you can build and test without spending a real send.

#### Implementation

**1. Prerequisite** - create (or edit) an API key scoped to `otp:send` in the Integration tab.

**2. Collect the phone number** in E.164 format (e.g. `+919876543210`).

**3. Send the request** - an `Idempotency-Key` header (any string unique per logical attempt) lets you safely retry a timed-out request without double-sending:
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/otp/send \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-attempt-abc123" \
  -d '{"phoneNumber":"+919876543210","channel":"whatsapp"}'
```

**4. Store `verificationId`** from the response and show the user a code-entry screen. `expiresIn` (seconds) tells you how long the code is valid for.

**5. Move on to `/otp/verify`** (below) once the user submits the code.

#### Errors
`validation_error` (400 - bad phone/channel), `forbidden` (403 - key missing `otp:send`, unknown, or its `allowedIps` allowlist doesn't include your egress IP), `key_revoked`/`key_expired` (403 - this key is dead, reissue it), `idempotency_key_reused`/`request_in_progress` (409), `rate_limited` (429 - both a per-key and a tighter per-key+phone limit, the real defense against a leaked key spamming one number), `delivery_failed` (502 - retry).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `phoneNumber` | `string` | yes |  |
| `channel` | `whatsapp` \| `sms` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `201` | Verification created |
| `400` | validation_error - bad phoneNumber/channel |
| `403` | forbidden - key missing otp:send, unknown, or IP not allowlisted. key_revoked / key_expired if the key itself is dead |
| `409` | idempotency_key_reused / request_in_progress |
| `429` | rate_limited |
| `502` | delivery_failed - upstream WhatsApp/SMS provider error |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `verificationId` | `string` | yes |  |
| `status` | `string` | yes |  |
| `channel` | `whatsapp` \| `sms` | yes |  |
| `expiresIn` | `integer` | yes |  |
| `code` | `string` | no |  |

### POST `/api/v1/application/otp/verify`

Verify an OTP code

**Scope** `otp:verify` &nbsp;·&nbsp; **Rate limit** 30 requests / min per key

Checks a 6-digit code against a pending `verificationId` from `/otp/send`. Max 3 attempts per verification before it's exhausted.

#### Implementation

**1. Prerequisite** - an API key scoped to `otp:verify` (can be the same key as `otp:send`, or a separate one).

**2. Collect the code** the user typed in.

**3. Send the request:**
```bash
curl -X POST https://connect.cosmoops.com/api/v1/application/otp/verify \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"verificationId":"ver_...","code":"482913"}'
```

**4. Branch on `status`** in the response: `"approved"` means the phone number is verified - proceed with whatever the verification unblocks (e.g. account creation, a sensitive action). `"expired"`/`"max_attempts_exceeded"` mean the user needs to request a fresh code via `/otp/send`.

#### Errors
An unknown, expired, or attempt-exhausted `verificationId` returns `invalid_verification` (400, deliberately not 404 - it shouldn't be distinguishable from any other bad request). `forbidden` (403 - key missing `otp:verify`), `rate_limited` (429 - a coarser per-key throttle against hammering many different `verificationId`s; per-verification attempt exhaustion is handled separately, above).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `verificationId` | `string` | yes |  |
| `code` | `string` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Verification result |
| `400` | invalid_verification - unknown/expired/exhausted verificationId (deliberately not 404, see the route's doc comment) |
| `403` | forbidden - key missing otp:verify |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `verificationId` | `string` | yes |  |
| `status` | `pending` \| `approved` \| `expired` \| `max_attempts_exceeded` | yes |  |
| `phoneNumber` | `string` | yes |  |