Phone, email and identity checks
verifyConfirm 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.
verifyFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
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.
At a glance
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 }import { verify } from "mainsdk/verify";
const started = await verify.phone({ number: input.phone, for: user.id });
// later, with the code the person typed
const check = await verify.phone.check({ id: started.id, code: input.code });
if (check.ok) await db.users.for(user).update(user.id, { phone: check.number, phoneVerified: true });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 });
});
});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)
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
6 drivers. Select one to see its line in main.yaml.
verify:
driver: twilio-verifyChange this one line to swap providers; your calls stay the same.Twilio integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-VERIFY-0004 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | 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 | RuntimeError responses and logs | See the spec |
MAIN-VERIFY-0002 | RuntimeError responses and logs | See the spec |
MAIN-VERIFY-0003 | RuntimeError responses and logs | See the spec |
MAIN-VERIFY-0005 | RuntimeError responses and logs | See the spec |
MAIN-VERIFY-0007 | RuntimeError responses and logs | See the spec |
MAIN-VERIFY-0008 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Phone code with expiry | Phase 2 | 0.7 | verify#phone |
| Behind the bot-defence door | Phase 2 | 0.7 | verify#door |
| Email check with a getqr.id link | Phase 2 | 0.7 | verify#email |
| Same answer whatever the input | Phase 2 | 0.7 | verify#same-answer |
| Twilio Verify | Phase 2 | 0.7 | verify#drivers |
| Identity checks by level and market | Phase 2 | 0.7 | verify#identity |
| Check for a level a market's driver cannot reach | Phase 2 | 0.7 | verify#checks |
| Digital credentials through wallets and DigiLocker | Phase 5 | 1.6 | digital-ids#wallets-digilocker |
Quick start
verify:
driver: local"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
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: 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: 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 and minted by 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.
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.
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 and errors.
Swapping drivers
Change one line; your verify.* calls stay the same.
verify:
driver: bird # was twilio-verify
drivers:
bird: { workspace: ws_123, channel: ch_123, key: secret:BIRD_ACCESS_KEY }See 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.
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.
verifycalls 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. 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
Was this page helpful?