---
description: >-
  Verify that a phone number belongs to a sender via Twilio identity-match — person mode or stateless inline mode, E.164 input, a 90-day cache, and a MATCH/PARTIAL/NO_MATCH score.
---

# Phone Verification

Phone Verification checks that a phone number actually belongs to the person who claims it, using a carrier identity-match lookup. It compares the sender's name, address, and date of birth against the data the mobile carrier holds for that number and returns a match score. Use it as a fraud signal during onboarding or before a first transaction.

> This check is **diagnostic and explicit** — it never runs automatically from the transaction flow, and it never rewrites the sender's stored phone number. Call it when you want a check.

***

### Verifying a Phone Number

**Endpoint:** `POST /organizations/{tenant}/v2/senders/verifyPhone`\
**Authentication:** Agent-level (`x-api-key` + `x-agent-id` + `x-agent-api-key`)

> The legacy `POST /v2/people/verifyPhone` path still works for integrators mid-migration, but new integrations should use `/v2/senders`.

There are two mutually exclusive calling modes:

| Mode | Send | Behavior |
| ---- | ---- | -------- |
| **Person mode** | `personId` only | Inyo loads the sender's name, address, and date of birth from the stored record, verifies against the carrier, and mirrors the outcome onto the person (`phoneVerificationScore`, `phoneVerificationStatus`, `phoneVerificationLastCheckedAt`). |
| **Inline mode** | The identity fields directly, no `personId` | Stateless — nothing is read from or written to the person record. |

> ⚠️ The modes cannot be combined. If you send `personId`, you must not send any inline identity field, or the request returns `422`.

#### Person Mode

```bash
curl --request POST \
  --url https://{FQDN}/organizations/$TENANT/v2/senders/verifyPhone \
  --header 'Content-Type: application/json' \
  --header "x-api-key: $API_KEY" \
  --header "x-agent-id: $AGENT_ID" \
  --header "x-agent-api-key: $AGENT_KEY" \
  --data '{
  "personId": "48066496-9445-41b7-acbe-85e069a77cb7"
}'
```

The sender's stored `phoneNumber` must already be in E.164 format, otherwise the request returns `422`.

#### Inline Mode

```bash
curl --request POST \
  --url https://{FQDN}/organizations/$TENANT/v2/senders/verifyPhone \
  --header 'Content-Type: application/json' \
  --header "x-api-key: $API_KEY" \
  --header "x-agent-id: $AGENT_ID" \
  --header "x-agent-api-key: $AGENT_KEY" \
  --data '{
  "phoneNumber": "+14145447770",
  "firstName": "John",
  "lastName": "Doe",
  "addressLine1": "123 Market St",
  "city": "San Francisco",
  "state": "CA",
  "postalCode": "94105",
  "addressCountryCode": "US",
  "dateOfBirth": "1990-01-15"
}'
```

#### Request Fields

| Field | Type | Required | Description |
| ----- | ---- | -------- | ----------- |
| `personId` | string (UUID) | Person mode | The sender to verify. When present, all identity fields are loaded from the record and none may be sent inline. |
| `phoneNumber` | string | Inline mode | The number to verify, **E.164** (`+` and 1–15 digits, e.g. `+14145447770`). Not normalized — canonicalize before sending. |
| `firstName` | string | No | Given name to match |
| `lastName` | string | No | Family name to match |
| `addressLine1` | string | No | Street address to match |
| `addressLine2` | string | No | Additional address line |
| `city` | string | No | City to match |
| `state` | string | No | State/province to match |
| `postalCode` | string | No | Postal/ZIP code to match |
| `addressCountryCode` | string | No | ISO 3166-1 alpha-2 country code |
| `dateOfBirth` | string | No | `YYYY-MM-DD` or `YYYYMMDD` |

More identity fields you provide means a more complete match result.

***

### Response

```json
{
  "verificationId": 42,
  "phoneNumber": "+14145447770",
  "status": "MATCH",
  "summaryScore": 100,
  "callerType": "CONSUMER",
  "valid": true,
  "nationalFormat": "(414) 544-7770",
  "countryCode": "US",
  "identityMatch": {
    "first_name_match": "exact_match",
    "last_name_match": "exact_match",
    "address_lines_match": "exact_match",
    "date_of_birth_match": "exact_match",
    "summary_score": 100
  },
  "verifiedAt": "2026-09-09T14:22:11+00:00",
  "cached": false
}
```

| Field | Description |
| ----- | ----------- |
| `verificationId` | Identifier for this verification record |
| `status` | Overall outcome — `MATCH`, `PARTIAL`, `NO_MATCH`, or `ERROR` |
| `summaryScore` | Match score `0`–`100`, or `null` when the provider returned no score |
| `callerType` | `CONSUMER`, `BUSINESS`, or similar carrier classification |
| `valid` | Whether the number is a valid, reachable line |
| `nationalFormat` | The number formatted for its country |
| `countryCode` | ISO country code of the number |
| `identityMatch` | Per-field match detail (name, address parts, date of birth, etc.) |
| `verifiedAt` | When the verification was performed |
| `cached` | `true` when this result was served from cache (see below) |

#### Status Thresholds

`status` is derived from `summaryScore` using per-tenant thresholds (defaults: `MATCH` ≥ 80, `PARTIAL` ≥ 40, below → `NO_MATCH`). The status is **captured at verification time** — later threshold changes do not re-bucket historical results. Contact Inyo to tune your thresholds.

***

### Caching

Identical requests are cached **per tenant for 90 days**, keyed on the full input tuple (phone number, name, address, date of birth). A cache hit returns the stored result with `"cached": true` and does **not** re-bill the verification provider. Vary any input field to force a fresh lookup.

***

### Error Responses

| HTTP | Error | Cause |
| ---- | ----- | ----- |
| `422` | `VALIDATION_ERROR` | Missing/invalid fields, non-E.164 phone, or `personId` sent together with inline identity fields |
| `422` | `VERIFICATION_UNAVAILABLE` | The verification could not be completed — the provider was unavailable, or verification isn't configured for your tenant. Retry with backoff; if it persists, contact Inyo support. |

> ℹ️ Both "provider temporarily down" and "not configured for your tenant" surface as a single `422 VERIFICATION_UNAVAILABLE`. This is deliberate — the API sits behind a CDN that intercepts any `5xx` origin response and replaces the JSON body with its own error page, so the API never returns `5xx` for these cases. Retry with backoff; a persistent failure is an Inyo-side fix.

***

### Sandbox Testing

In the sandbox, phone verification does **not** call the real provider — no billing, no live lookups. Instead, the outcome is driven entirely by the **last digit of the phone number**, so you can exercise every branch by choosing the number you send:

| Phone ends in | `status` | `summaryScore` | What it simulates |
| ------------- | -------- | -------------- | ----------------- |
| `0`, `1`, `2` | `MATCH` | `100` | Full identity match — all fields `exact_match` |
| `3`, `4`, `5` | `PARTIAL` | `60` | Mixed — some fields match, some don't |
| `6`, `7` | `NO_MATCH` | `20` | Mostly `no_match` fields |
| `8` | `ERROR` | `null` | Provider returned no score (nothing to match against) |
| `9` | — | — | Upstream `500` → the endpoint returns `422 VERIFICATION_UNAVAILABLE` |

For example, `+14145550002` returns `MATCH`, and `+14145550009` returns `422 VERIFICATION_UNAVAILABLE`.

> Because the cache key includes the phone number, re-running the same sandbox number returns `"cached": true` on the second call. Change the number (or any other field) to force a fresh simulated lookup.

***

### All Endpoints

| Operation | Method | Endpoint |
| --------- | ------ | -------- |
| Verify a phone number | `POST` | `/organizations/{tenant}/v2/senders/verifyPhone` |
