MainSDK
Search⌘K
Interfaces

Identity and access

Phone, email and identity checks

verify

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.

Building for 0.7ships as a beta APIPhase 2 · Cloud and Teams, 1.0Spec verifyFree in your app, Apache-2.0

Status 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.
Stability levels
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.

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 }

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.

main.yamlTwilio Verify (default)
verify:
  driver: twilio-verify

Change this one line to swap providers; your calls stay the same.Twilio integration

Codes

CodeWhere it appearsMeaning
MAIN-VERIFY-0004Checkmainsdk check, before deployA 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-0006Checkmainsdk check, before deployThe verify section is not valid, a driver is not built, or an email check has no app origin, share or email (500)
MAIN-VERIFY-0001RuntimeError responses and logsSee the spec
MAIN-VERIFY-0002RuntimeError responses and logsSee the spec
MAIN-VERIFY-0003RuntimeError responses and logsSee the spec
MAIN-VERIFY-0005RuntimeError responses and logsSee the spec
MAIN-VERIFY-0007RuntimeError responses and logsSee the spec
MAIN-VERIFY-0008RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Phone code with expiryPhase 20.7verify#phone
Behind the bot-defence doorPhase 20.7verify#door
Email check with a getqr.id linkPhase 20.7verify#email
Same answer whatever the inputPhase 20.7verify#same-answer
Twilio VerifyPhase 20.7verify#drivers
Identity checks by level and marketPhase 20.7verify#identity
Check for a level a market's driver cannot reachPhase 20.7verify#checks
Digital credentials through wallets and DigiLockerPhase 51.6digital-ids#wallets-digilocker

Quick start

main.yaml
verify:
  driver: local
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

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 }
KeyTypeDefaultWhat it does
driverstringlocalDelivers the code MainSDK made: twilio-verify, bird or local. Another value fails with MAIN-VERIFY-0006
keysecret referenceone per processKeys the digest of codes, numbers and addresses. Set it, or a code sent by one instance cannot be checked by another
code.ttlduration10mHow long a code or email link works; 10 minutes at most
code.triesnumber5Wrong tries before a code is spent; 5 at most
limits.per-numbernumber3Sends to one number or address in the window
limits.per-ipnumber10Sends from one IP in the window
limits.windowduration10mThe window of both limits
email.baseURLlinks.baseThe app origin an email link lands on
email.pathpath/verify/emailThe route that calls verify.email.confirm()
identity.<market>stringnoneThe identity driver for a market, or default for the rest: digilocker, onfido, persona or local
require.<market>stringnoneThe level a market requires: basic, substantial or high
policystringprivacy-policyThe consent policy stored with claims when a call names none
drivers.<id>mapnoneThe 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.

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

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.

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.

LevelReached by
basicany identity driver
substantialdigilocker, onfido, persona, local
highonfido, persona, local

Errors and checks

CodeWhereMeaning and fix
MAIN-VERIFY-0001runtimeA 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-0002runtimeToo many asks, or the door or the pumping check said no (429). Wait and retry
MAIN-VERIFY-0003runtimeThe provider could not deliver and said nothing about the recipient (502, retryable). Retry
MAIN-VERIFY-0004check, runtimeA 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-0005runtimeNot a valid E.164 number or email address (400). Use +919876543210
MAIN-VERIFY-0006check, runtimeThe verify section is not valid, or an email check lacks an origin, share or email. Fix main.yaml
MAIN-VERIFY-0007runtimeAn identity call is malformed: no market, person, purpose, claims or consent (400)
MAIN-VERIFY-0008runtimeAn identity provider could not be reached or answered something unusable (502, retryable)
MAIN-IDENT-0001runtimeAn 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.

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

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

↑↓ moveEnter openEsc close

Not decided yet