---
title: "Phone, email and identity checks"
description: "Confirm a phone number, an email address or a person's identity at the level a market requires, with Twilio Verify, Bird, DigiLocker, Onfido or Persona and no answer that tells who has an account."
section: "interfaces"
status: "Building for 0.7 · ships as a beta API"
spec: "verify"
canonical: "https://mainsdk.dev/docs/interfaces/verify"
updated: "2026-10-28"
---

# Phone, email and identity checks

> Confirm a phone number, an email address or a person's identity at the level a market requires, with Twilio Verify, Bird, DigiLocker, Onfido or Persona and no answer that tells who has an account.

Status: Building for 0.7 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/verify · Updated: 2026-10-28

`verify` confirms a phone number or email address belongs to the person who gave it, and checks identity at the level a market requires. Reach for it at sign-up, before payouts or in regulated flows.

> **Note:** Built: phone codes, email links and the `twilio-verify`, `bird` and `local` drivers (milestone 0.7), and identity checks with `digilocker`, `onfido`, `persona` and `local` (1.2). The Twilio Verify, Bird, Onfido and Persona requests are tested against recorded replies, not live accounts, and DigiLocker is only as proven as the `digital-ids` driver it uses. Run one live check in each provider's sandbox before launch.

## At a glance

- Status: Building for 0.7 · ships as a beta API
- Phase: 2 (Cloud and Teams, 1.0)
- Milestone: 0.7 Teams and assure
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/verify.md
- Category: Identity and access

Calls:

- `verify.phone({ number, via?, for?, locale?, req? })`
- `verify.phone.check({ id, code })`
- `verify.email({ address, for?, req? })`
- `verify.email.confirm(token)`
- `verify.identity({ level, market, claims, need?, purpose, user, consent, redirect? })`
- `verify.identity.complete({ id, code? })`
- `createVerify(options)`
- `parseVerify(config)`
- `verifyHosts(config)`
- `normalizeNumber(input)`
- `normalizeAddress(input)`
- `maskNumber(e164)`
- `reaches(driver, level)`
- `twilioVerify(options)`
- `bird(options)`
- `localPhone()`
- `phoneFromConfig(id, { settings, secret, fetch })`
- `failure(provider, { retryable, cause })`
- `onfido(options)`
- `persona(options)`
- `digilockerIdentity(wallet)`
- `localIdentity(fixture?, options?)`
- `deriveClaims(attrs, { asked, nowMs })`
- `createIdentity(options)`

In main.yaml:

```yaml
verify:
  driver: twilio-verify
  key: secret:VERIFY_KEY
  code: { ttl: 10m, tries: 5 }
  limits: { per-number: 3, per-ip: 10, window: 10m }
  email: { base: https://app.example.com, path: /verify/email }
  identity: { in: digilocker, eu: onfido }
  require: { in: substantial, eu: high }
  policy: privacy-2026-10
  drivers:
    twilio-verify: { account: secret:TWILIO_ACCOUNT_SID, token: secret:TWILIO_AUTH_TOKEN, service: VAxxxxxxxx }
    onfido: { token: secret:ONFIDO_TOKEN, workflow: wf_123, region: eu }
```

Guarantees, whatever the driver:

- A six-digit code that ends after ten minutes or five tries, kept only as a keyed digest
- Sends limited per number and per IP, behind the bot-defence door
- Email checks sent through email, with a getqr.id link
- The same answer, in about the same time, whether or not an address has an account
- Numbers normalised to E.164, labelled personal and audited masked
- Changing the driver changes no code
- Identity checks keep only the claims asked for, with a consent record


## Drivers

Pick one in main.yaml with `verify.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Twilio Verify | `twilio-verify` | yes |  |
| Bird | `bird` |  |  |
| Local outbox for development and tests | `local` |  |  |
| DigiLocker | `digilocker` |  |  |
| Onfido | `onfido` |  |  |
| Persona | `persona` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-VERIFY-0004` | check | A market requires an identity level its driver cannot reach, or no driver serves it (check); an identity call asked for a level above the driver's reach (runtime, 422) |
| `MAIN-VERIFY-0006` | check | The `verify` section is not valid, a driver is not built, or an email check has no app origin, share or email (500) |
| `MAIN-VERIFY-0001` | runtime |  |
| `MAIN-VERIFY-0002` | runtime |  |
| `MAIN-VERIFY-0003` | runtime |  |
| `MAIN-VERIFY-0005` | runtime |  |
| `MAIN-VERIFY-0007` | runtime |  |
| `MAIN-VERIFY-0008` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Phone code with expiry | 2 | 0.7 | `verify#phone` |
| Behind the bot-defence door | 2 | 0.7 | `verify#door` |
| Email check with a getqr.id link | 2 | 0.7 | `verify#email` |
| Same answer whatever the input | 2 | 0.7 | `verify#same-answer` |
| Twilio Verify | 2 | 0.7 | `verify#drivers` |
| Identity checks by level and market | 2 | 0.7 | `verify#identity` |
| Check for a level a market's driver cannot reach | 2 | 0.7 | `verify#checks` |
| Digital credentials through wallets and DigiLocker | 5 | 1.6 | `digital-ids#wallets-digilocker` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
verify:
  driver: local
```

```ts title="app/account/phone/actions.ts"
"use server";
import { auth } from "mainsdk";
import { verify } from "mainsdk/verify";

declare function saveVerifiedPhone(user: string, number: string): Promise<void>;   // your own record

export async function sendCode(req: Request, phone: string) {
  const session = await auth.require(req);
  const started = await verify.phone({ number: phone, for: session.user, req });
  return started.id;   // { sent: true, id, expiresAt }; the person gets the code
}

export async function checkCode(req: Request, id: string, code: string) {
  const session = await auth.require(req);
  const check = await verify.phone.check({ id, code });
  if (check.ok) await saveVerifiedPhone(session.user, check.number);
  return check.ok;
}
```

`verify.phone()` normalises the number to E.164, makes a six-digit code and hands it to the driver. The code ends after 10 minutes or 5 wrong tries. `verify.phone.check()` answers `{ ok: true, number }` once, and `{ ok: false }` for every other case, so a wrong code, an expired one and an unknown id look the same.

## Configuration

```yaml title="main.yaml"
verify:
  driver: twilio-verify                   # twilio-verify | bird | local
  key: secret:VERIFY_KEY                  # keys the digest of codes, numbers and addresses
  code: { ttl: 10m, tries: 5 }            # a setting may only lower these
  limits: { per-number: 3, per-ip: 10, window: 10m }
  email: { base: https://app.example.com, path: /verify/email }
  identity: { in: digilocker, eu: onfido, default: persona }
  require: { in: substantial, eu: high }
  policy: privacy-2026-10
  drivers:
    twilio-verify: { account: secret:TWILIO_ACCOUNT_SID, token: secret:TWILIO_AUTH_TOKEN, service: VAxxxxxxxx }
    onfido: { token: secret:ONFIDO_TOKEN, workflow: wf_123, region: eu }
```

| Key | Type | Default | What it does |
| --- | --- | --- | --- |
| `driver` | string | `local` | Delivers the code MainSDK made: `twilio-verify`, `bird` or `local`. Another value fails with `MAIN-VERIFY-0006` |
| `key` | secret reference | one per process | Keys the digest of codes, numbers and addresses. Set it, or a code sent by one instance cannot be checked by another |
| `code.ttl` | duration | `10m` | How long a code or email link works; 10 minutes at most |
| `code.tries` | number | `5` | Wrong tries before a code is spent; 5 at most |
| `limits.per-number` | number | `3` | Sends to one number or address in the window |
| `limits.per-ip` | number | `10` | Sends from one IP in the window |
| `limits.window` | duration | `10m` | The window of both limits |
| `email.base` | URL | `links.base` | The app origin an email link lands on |
| `email.path` | path | `/verify/email` | The route that calls `verify.email.confirm()` |
| `identity.<market>` | string | none | The identity driver for a market, or `default` for the rest: `digilocker`, `onfido`, `persona` or `local` |
| `require.<market>` | string | none | The level a market requires: `basic`, `substantial` or `high` |
| `policy` | string | `privacy-policy` | The consent policy stored with claims when a call names none |
| `drivers.<id>` | map | none | The settings of each driver; keys that hold credentials must be `secret:` references |

Phone sends sit behind the `verify` door of [bot defence](https://mainsdk.dev/docs/interfaces/limits): with a `bots` section, a request the door challenges or denies, or a country the pumping check refuses, stops before a message is paid for.

### Building it by hand

The SDK builds `verify` for you from `main.yaml`. Call these only when you wire it by hand, for example in a test.

| Name | What it is |
| --- | --- |
| `parseVerify(config)` | Reads the `verify` section into a config and the issues `mainsdk check` reports. |
| `createVerify(options)` | Builds the interface over a phone driver and a store. Settings: `config`, `store`, `phone`, `hashKey`; optional `email`, `share`, `door`, `guardSend`, `clientIp`, `now`, `log`, `sleep`, `answerMs` and `identity`. |
| `verifyHosts(config)` | The hosts the configured drivers call, for the network allow list. |
| `normalizeNumber(input)`, `normalizeAddress(input)`, `maskNumber(e164)` | The E.164 number, the lower-case address and the masked number the audit log shows. |
| `reaches(driver, level)` | True when an identity driver can reach a level. |
| `twilioVerify(options)`, `bird(options)`, `localPhone()`, `phoneFromConfig(id, { settings, secret, fetch })`, `failure(provider, { retryable, cause })` | The phone drivers, the one that builds the configured driver from `secret:` references, and the delivery failure a driver throws. |
| `onfido(options)`, `persona(options)`, `digilockerIdentity(wallet)`, `localIdentity(fixture, options)`, `deriveClaims(attrs, { asked, nowMs })`, `createIdentity(options)` | The identity drivers, the claims a provider's answer gives, and the identity part of the interface. |

## What you get

`verify` sits next to [`auth`](https://mainsdk.dev/docs/interfaces/auth): `auth` signs people in, `verify` confirms facts about them. One call each for a phone, an email and an identity, the same on every driver. MainSDK makes and judges the code itself, so switching provider changes no answer. Every answer is the same shape and takes at least 400 ms, so neither tells an attacker which numbers or addresses have an account.

## Examples

### Confirm an email address

`verify.email()` sends a link, not a code, through your [`email`](https://mainsdk.dev/docs/interfaces/email) and minted by [`share`](https://mainsdk.dev/docs/interfaces/share), so it is a getqr.id link that expires and can be revoked. The link lands on `email.base` plus `email.path` with a token; your route confirms it. Confirm on a POST, so a mail scanner that opens the link does not spend it.

```ts title="app/verify/email/route.ts"
import { verify } from "mainsdk/verify";

export async function POST(req: Request) {
  const { token } = (await req.json()) as { token: string };
  const check = await verify.email.confirm(token);
  return Response.json({ confirmed: check.ok });   // check.address is the confirmed address
}

export async function sendLink(address: string) {
  return verify.email({ address });   // { sent: true, id, expiresAt }
}
```

### Check identity at the market's level

`verify.identity()` takes the level, the market, the claims you ask for, your purpose, the person and their consent. The market picks the driver from `identity`, and `require` can raise the level. It sends the person to the provider and returns where; `complete` reads the outcome.

```ts title="lib/kyc.ts"
import { verify } from "mainsdk/verify";

export async function startAgeCheck(user: string, market: string) {
  const started = await verify.identity({
    level: "substantial",
    market,
    claims: ["age_over_18"],
    purpose: "Sell age-restricted items",
    user,
    consent: { policy: "privacy-2026-10" },
  });
  return started.url;   // send the person here; keep started.id
}

export async function finishAgeCheck(id: string, code?: string) {
  const done = await verify.identity.complete(code ? { id, code } : { id });
  return done.status === "approved" ? done.claims : null;   // { age_over_18: true }
}
```

Only the claims you ask for are kept, each labelled, with a consent record of the time, the purpose and the policy. Ask for `age_over_18` rather than a birth date: a claim that identifies a person (a birth date, a name, a document number) that you don't list in `need` fails with `MAIN-IDENT-0001`. A level the market's driver cannot reach fails with `MAIN-VERIFY-0004` before the person is sent anywhere.

| Level | Reached by |
| --- | --- |
| `basic` | any identity driver |
| `substantial` | `digilocker`, `onfido`, `persona`, `local` |
| `high` | `onfido`, `persona`, `local` |

## Errors and checks

| Code | Where | Meaning and fix |
| --- | --- | --- |
| `MAIN-VERIFY-0001` | runtime | A code or link did not check out. The call answers `ok: false` and the audit record carries the code. Ask for a new code |
| `MAIN-VERIFY-0002` | runtime | Too many asks, or the door or the pumping check said no (429). Wait and retry |
| `MAIN-VERIFY-0003` | runtime | The provider could not deliver and said nothing about the recipient (502, retryable). Retry |
| `MAIN-VERIFY-0004` | check, runtime | A market requires a level its driver cannot reach, or no driver serves it. Serve it with `onfido` or `persona`, or lower the level |
| `MAIN-VERIFY-0005` | runtime | Not a valid E.164 number or email address (400). Use `+919876543210` |
| `MAIN-VERIFY-0006` | check, runtime | The `verify` section is not valid, or an email check lacks an origin, `share` or `email`. Fix `main.yaml` |
| `MAIN-VERIFY-0007` | runtime | An identity call is malformed: no market, person, purpose, claims or consent (400) |
| `MAIN-VERIFY-0008` | runtime | An identity provider could not be reached or answered something unusable (502, retryable) |
| `MAIN-IDENT-0001` | runtime | An identity request asks for a claim beyond what it needs. Name it in `need` or ask for `age_over_18` |

See [check codes](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/errors).

## Swapping drivers

Change one line; your `verify.*` calls stay the same.

```yaml title="main.yaml"
verify:
  driver: bird   # was twilio-verify
  drivers:
    bird: { workspace: ws_123, channel: ch_123, key: secret:BIRD_ACCESS_KEY }
```

See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver). Code, expiry and tries live in MainSDK, so pending checks keep working across a swap.

## Testing

`createTestApp` runs `verify` on the local driver: the codes stay in an outbox, nothing leaves the app.

```ts title="app/account/phone.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { verify } from "mainsdk/verify";

describe("verify", () => {
  it("verify AC1 checks a code once", async () => {
    const app = createTestApp({ yaml: "schema: 1\nverify:\n  driver: local\n" });
    const started = await verify.phone({ number: "+91 98765 43210" });
    const code = app.verifyKit().outbox.at(-1)?.code ?? "";
    assert.deepEqual(await verify.phone.check({ id: started.id, code }), { ok: true, number: "+919876543210" });
    assert.deepEqual(await verify.phone.check({ id: started.id, code }), { ok: false });
  });
});
```

`runVerifyConformance` from `@mainsdk/testing` checks a phone driver against the spec: the code, number and channel it sends, how it reads a 5xx, a 429 and a rejected recipient, and that a swap of driver changes no answer.

## Security and privacy

- Phone numbers and email addresses are personal data. The record that holds a number carries the label `personal`, lives no longer than the code and is deleted when the check succeeds. A code is kept only as a keyed digest, a link token only as a SHA-256 digest.
- Every send and check is audited with the number masked and never in the clear; an address never appears in a log or audit record.
- Ask only for what you need. Identity claims you do not ask for are dropped before your app sees them, and the check is spent once, so a repeat stores nothing twice.
- `verify` calls the provider only when a person asks; a provider outage fails that one send, never the app (ADR-0003). It is free and runs in your app.

## FAQ

**Is this the same as WhatsApp sign-in?** No. WhatsApp one-time codes for sign-in belong to [`auth`](https://mainsdk.dev/docs/interfaces/auth). `verify` confirms a number or address that a signed-in person gives you.

**Does a failed delivery tell the caller the number is bad?** Only when the failure says nothing about the recipient, such as an outage. A provider that refuses one recipient leaves the answer the same and the refusal is logged without the recipient.

**Do I need a migration to change provider?** No. Phone and email checks are short-lived and MainSDK holds their state. Identity results you keep live in your own `db`.

## Next

- [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)
- [Data and records](https://mainsdk.dev/docs/interfaces/db)
- [Privacy and data rights](https://mainsdk.dev/docs/interfaces/privacy)
