# 2fa.mn > 2fa.mn verifies that a user owns a Mongolian phone number. The user sends one SMS from their own phone to the short code 150073 (the operator charges the user 150₮, VAT included), and the client's server receives a signed webhook in under a second. Clients pay nothing per verification and earn on every verification. This is MO (user-sent) verification: it proves SIM ownership and makes SMS-pumping fraud impossible. Products: SESSION (one-time 6-digit code, valid 5 minutes, single-use, one-tap `sms:150073?body=CODE` link) and RULE (a fixed text prefix such as "HIHI" that triggers a `rule.matched` webhook). Operator coverage: all six operators live (Unitel, Mobicom, Skytel, G-Mobile, ONDO, Lime), plus Toki (on Unitel's network) and KAOS (on Mobicom's network). ## Docs Get started: - [Quickstart in 5 minutes](https://2fa.mn/en/docs/quickstart): widget button with a test key, redeem the token and check state on your server - [Widget](https://2fa.mn/en/docs/widget): button plus modal (number step, then price, code, SMS button, QR), phone and state options, events, redeem call, security, JWKS - [Introduction](https://2fa.mn/en/docs): how verification by a user-sent SMS works - [Operators](https://2fa.mn/en/docs/operators): live operators, number prefixes (reference only; ported numbers work) Products: - [One-time code (Session)](https://2fa.mn/en/docs/session): one-time codes, phone-bound sessions, statuses, errors - [Fixed text (Rule)](https://2fa.mn/en/docs/rule): text prefixes that trigger actions Integrate: - [Webhooks & signatures](https://2fa.mn/en/docs/webhooks): events, signature verification in Node, Go, PHP, Python, retries, delivery log - [API keys](https://2fa.mn/en/docs/keys): test and live keys, rotation, IP allowlist - [Test mode](https://2fa.mn/en/docs/test-mode): simulate an SMS with POST /v1/test/mo, Dashboard playground - [Build your own UI](https://2fa.mn/en/docs/price-ui): required price notice and SMS button (HTML+JS and React) when not using the widget Earnings: - [Earnings and payouts](https://2fa.mn/en/docs/payouts): statuses, payout timing, 10,000₮ minimum, tax, bank account, acceptable use Reference: - [Error codes](https://2fa.mn/en/docs/errors): error format and every code the API returns - [Limits](https://2fa.mn/en/docs/limits): session, phone, request and webhook limits - [FAQ](https://2fa.mn/en/docs/faq): pricing, operators, ported numbers, testing - [API reference](https://2fa.mn/en/docs/api-reference): every endpoint - [AI coding assistants](https://2fa.mn/en/docs/ai): llms.txt and a ready prompt Mongolian docs live at the same paths without `/en` (for example https://2fa.mn/docs/session). ## Widget (drop-in) The widget draws a "Continue with phone" button; a click opens a centered modal (iframe from verify.2fa.mn, blurred backdrop). The user enters their 8-digit number, then sees the code, the 150₮ price and an SMS button (phone) or QR (desktop), and sends the code from that number. The page cannot alter it. Status arrives by server push (SSE) inside the widget. Why the SMS: the operator delivers it with the sender's number, so sending from that phone proves the user holds the SIM; an SMS from another number is refused and the widget asks the user to send it from the number they entered. Create a publishable key (`pk_test_` / `pk_live_`) and list allowed sites in Dashboard > Widget (`localhost` / `127.0.0.1` on test keys only), then: ```html
``` `TwoFA.button(target, { key, lang?, phone?, state?, accent?, radius?, size? })` returns a handle with `.on('open' | 'close' | 'verified' | 'expired' | 'error', fn)`, `.open()`, `.close()`, `.destroy()`. `verified` gives `{ token }`. `phone` passes a known 8-digit number: the number step is skipped and other senders are refused (bad value: invalid_phone). `state` (1..128 chars `[A-Za-z0-9._~-]`, bad value: invalid_request) is a random value your server stores in the user's session; it is bound into the token and returned by redeem, and you must compare it with the session value (stops a token from one browser being used in someone else's sign-in). `accent` is '#rrggbb' only, `radius` px 0..24 (default 12), `size` 'full' | 'compact'; `lang` defaults to 'mn'. `error` gives the code as a string. `TwoFA.mount(target, { key, lang, phone, state })` renders the widget inline instead (also emits `ready`; no open/close or look overrides). Redeem the token once on the server with the secret key: ```bash curl https://api.2fa.mn/v1/verifications -H "Authorization: Bearer $TWOFA_KEY" \ -H "Content-Type: application/json" -d "{\"token\": \"$TOKEN\"}" ``` The token is an ES256 JWT, single-use, valid 10 minutes by default. Never trust it in the browser. Offline check (signature alone is not enough: test tokens are signed with a separate test key and the JWKS lists both): JWKS at https://api.2fa.mn/.well-known/jwks.json, verify the ES256 signature, `iss` exactly `https://api.2fa.mn` (test tokens: `https://api.2fa.mn/test`), `aud` exactly your client id (test tokens: `:test`), `mode` == `live`, `exp`, and track `jti` yourself (single use). The `verification.succeeded` webhook still fires with `"source":"widget"`. ## Full spec - [llms-full.txt](https://2fa.mn/llms-full.txt): the complete API contract in one file ## API summary - Base URL: https://api.2fa.mn - Auth: `Authorization: Bearer sk_test_...` or `sk_live_...` (server-side only). Test keys never reach operators and earn nothing. - Phone numbers: 8 digits (`99112233`); `+976`/`976` accepted on input, always returned as 8 digits. - `POST /v1/sessions` `{ phone?, metadata? }` → 201 `{ id, mode, code, short_code, sms_uri, status, phone, metadata, expires_at, created_at }` - `GET /v1/sessions/{id}` → session; when `status` is `verified` also `verified_phone`, `verified_at`, `operator`. Statuses: pending, verified, expired. - `POST /v1/rules` `{ prefix, name? }` → 201 (live prefix at least 4 characters, matched on the first word: `HIHI` matches `HIHI 123`, not `HIHI123`; reserved: 2FA, LOGIN, STOP, HELP, TEST, INFO; at most 5 live rules per client, else 409 `rule_limit`); `GET /v1/rules` → `{ data: [...] }`; `DELETE /v1/rules/{id}` → 204 - `PUT /v1/webhook` `{ url }` → `{ url, secret }`; `GET /v1/webhook`; `POST /v1/webhook/rotate-secret`; `GET /v1/webhook/deliveries` - `POST /v1/verifications` `{ token }` (secret key) → 200 `{ session_id, phone, verified_at, mode, state }`; redeems a widget token once. `GET /v1/verifications/{token}` does the same (kept for compatibility; prefer POST, which keeps the token out of URLs and logs). Errors: 400 invalid_token, 404 not_found, 409 token_used, 410 token_expired. - `POST /v1/test/mo` `{ from, text }` → 202 `{ id }` (test keys only; 30 per minute per client; test webhooks are sent once, no retries) - Webhook events: `verification.succeeded`, `rule.matched`, `session.expired`. Header `X-2FA-Signature: t=,v1=.")>`. Reject if |now − t| > 300 s; constant-time compare; respond 2xx within 10 s; dedupe on event `id`. - Errors: `{ "error": { "code", "message" } }` with codes unauthorized (401, also for keys used outside their IP allowlist), invalid_request (400), invalid_phone (400), not_found (404), rate_limited (429), client_suspended (403), test_only (403), internal_error (500), prefix_taken (409: a live rule prefix overlaps another client's; prefixes are shared across 150073), rule_limit (409: live rule cap reached), invalid_token (400), token_used (409), token_expired (410), origin_not_allowed (403), widget_disabled (403). - Required UI: show "150₮" before the "SMS илгээх" button; hide the button once verified.