# 2fa.mn: full API specification > 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). The client's server receives a signed webhook (delivery target: under a second). Clients pay nothing per verification and earn on every verification. This document is self-contained and intended for AI coding assistants and developers. Human-readable docs: https://2fa.mn/en/docs ## Concepts - MO verification: the user sends the SMS (mobile-originated). It proves the message came from the user's own SIM. Because nothing is sent at the client's expense, SMS-pumping fraud is impossible. - Short code: 150073, the same on every operator. - SESSION: a one-time 6-digit code, valid for 5 minutes, single-use. The client creates it, shows a one-tap `sms:150073?body=CODE` link, and gets a webhook when the user sends it. - RULE: a fixed text prefix (e.g. "HIHI"). Any SMS to 150073 starting with it triggers a `rule.matched` webhook. - Modes: test (`sk_test_...`) and live (`sk_live_...`). Each mode has its own sessions, rules, webhook endpoint and secret. Test keys never reach operators and earn nothing. - 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). Ported numbers work. - Phone numbers and message contents are used only for verification. The production service will run on servers in Mongolia. ## Basics - Base URL: `https://api.2fa.mn` - Authentication: `Authorization: Bearer sk_test_...` or `Authorization: Bearer sk_live_...`. Keep keys on the server; never send them to a browser or mobile app. - JSON request and response bodies (`Content-Type: application/json`). - Phone numbers: 8-digit Mongolian numbers, e.g. `99112233`. `+976` and `976` prefixes are accepted on input; responses always use 8 digits. - Timestamps: RFC 3339. ## Endpoints ### POST /v1/sessions Create a session. Request body (all fields optional): ```json { "phone": "99112233", "metadata": { "user_id": "u_42" } } ``` - `phone`: if set, only an SMS from exactly that number verifies the session. - `metadata`: any JSON; returned on the session and in the webhook. Response 201: ```json { "id": "ses_8f2k1c9x", "mode": "test", "code": "482913", "short_code": "150073", "sms_uri": "sms:150073?body=482913", "qr_svg": "…", "status": "pending", "phone": "99112233", "metadata": { "user_id": "u_42" }, "expires_at": "2026-12-01T10:05:00Z", "created_at": "2026-12-01T10:00:00Z" } ``` `phone` is `null` when not provided. Sessions are single-use and expire after 5 minutes. `qr_svg` is a complete SVG (no width/height; size it with CSS) of a QR code encoding `sms_uri`. It is present only while `status` is `pending` (on POST and GET), and omitted once verified or expired. 2fa.mn generates it from fixed characters only, so it is safe to inline. Use it on desktop pages; use the `sms_uri` button on phones. ### GET /v1/sessions/{id} Returns the session. When `status` is `verified`, the object also contains `verified_phone`, `verified_at` and `operator`: ```json { "id": "ses_8f2k1c9x", "mode": "test", "code": "482913", "short_code": "150073", "sms_uri": "sms:150073?body=482913", "status": "verified", "phone": "99112233", "metadata": { "user_id": "u_42" }, "expires_at": "2026-12-01T10:05:00Z", "created_at": "2026-12-01T10:00:00Z", "verified_phone": "99112233", "verified_at": "2026-12-01T10:00:41Z", "operator": "unitel" } ``` Statuses: `pending`, `verified`, `expired`. ### POST /v1/rules ```json { "prefix": "HIHI", "name": "Activate account" } ``` - `prefix`: 2–20 characters, starts with a Latin letter, letters and digits only, matched case-insensitively. A live prefix needs at least 4 characters (test prefixes may have 2). Reserved words: `2FA`, `LOGIN`, `STOP`, `HELP`, `TEST`, `INFO`. - Matching is on the whole first word: the text equals the prefix or starts with the prefix followed by a space. `HIHI` matches `HIHI` and `HIHI 123`, not `HIHI123`. - At most 5 live rules per client (can be raised per client by 2fa.mn); beyond that → 409 `rule_limit`. - `name`: optional. - Prefixes are shared across all clients on 150073: a live prefix may not be a prefix of another client's (or vice versa). Overlap → 409 `prefix_taken`. Response 201: ```json { "id": "rul_3m9q2w", "mode": "test", "prefix": "HIHI", "name": "Activate account", "created_at": "2026-12-01T10:00:00Z" } ``` ### GET /v1/rules Response 200: `{ "data": [ , ... ] }` ### DELETE /v1/rules/{id} Response 204, no body. ### PUT /v1/webhook One endpoint per mode. ```json { "url": "https://example.com/webhooks/2fa" } ``` Response 200: ```json { "url": "https://example.com/webhooks/2fa", "secret": "whsec_..." } ``` ### GET /v1/webhook Returns the endpoint configured for the key's mode. ### POST /v1/webhook/rotate-secret Generates a new signing secret and returns it. ### GET /v1/webhook/deliveries ```json { "data": [ { "event_id": "evt_9a8b7c", "type": "verification.succeeded", "status": "delivered", "attempts": 1, "last_status_code": 200, "created_at": "2026-12-01T10:00:42Z", "delivered_at": "2026-12-01T10:00:42Z" } ] } ``` `status`: `pending`, `delivered` or `failed`. ### POST /v1/verifications Secret key, same client and mode as the widget session. Redeems a widget token once. ```json { "token": "eyJhbGciOiJFUzI1NiIs..." } ``` `GET /v1/verifications/{token}` does the same and stays for compatibility; prefer POST, which keeps the token out of URLs and logs. Response 200: ```json { "session_id": "ses_8f3k2m", "phone": "99112233", "verified_at": "2026-12-01T10:02:14Z", "mode": "test", "state": "q9Xh2LwP0aT7bV4e" } ``` Errors: 400 `invalid_token`, 404 `not_found` (other client or mode), 409 `token_used`, 410 `token_expired`. ### POST /v1/test/mo Test keys only (live keys get 403 `test_only`). Simulates the user's SMS to 150073. ```json { "from": "99112233", "text": "482913" } ``` Response 202: `{ "id": "mo_4h6j8k" }`. Limited to 30 per minute per client (429 `rate_limited`). Test webhooks are sent once, without retries. ## Webhooks 2fa.mn sends `POST` requests with a JSON body to the client's webhook URL: ```json { "id": "evt_9a8b7c", "type": "verification.succeeded", "mode": "live", "created_at": "2026-12-01T10:00:42Z", "data": { "session_id": "ses_8f2k1c9x", "phone": "99112233", "operator": "unitel", "verified_at": "2026-12-01T10:00:41Z", "metadata": { "user_id": "u_42" } } } ``` Event types and their `data`: - `verification.succeeded`: `{ session_id, phone, operator, verified_at, metadata }` - `rule.matched`: `{ rule_id, prefix, phone, text, operator, received_at }` - `session.expired`: `{ session_id }` ### Signature Header: `X-2FA-Signature: t=,v1=.")>` Verification steps: 1. Read the raw request body bytes (do not re-serialize JSON). 2. Parse `t` and `v1` from the header. 3. Reject if |now − t| > 300 seconds. 4. Compute hex HMAC-SHA256 of `"."` with the webhook secret. 5. Compare with `v1` in constant time. Node.js: ```js import crypto from "node:crypto"; export function verify2faSignature(rawBody, header, secret) { const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.trim().split("="))); const t = Number(parts.t); if (!t || !parts.v1) return false; if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false; const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: app.post("/webhooks/2fa", express.raw({ type: "application/json" }), handler) ``` Python: ```python import hashlib, hmac, time def verify_2fa_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.strip().partition("=")[::2] for p in (header or "").split(",")) try: t = int(parts.get("t", "0")) except ValueError: return False v1 = parts.get("v1", "") if not t or not v1 or abs(int(time.time()) - t) > 300: return False expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) ``` ### Delivery rules - Respond with any 2xx within 10 seconds; do slow work asynchronously. - Non-2xx or timeout → retried with backoff of roughly 10 s, 1 min, 5 min, 30 min, 2 h; up to 5 retries (6 attempts in all). Live webhooks only: test webhooks are sent once and not retried. - The same event `id` is reused across retries. Deduplicate on `id`. ## Widget Drop-in alternative to building the UI. `TwoFA.button` draws a "Утасны дугаараар нэвтрэх" / "Continue with phone" button; a click opens a centered modal over a blurred backdrop: an iframe from verify.2fa.mn with the header "{display name}-д нэвтрэх". Step 1 asks for the phone number (+976 and 8 digits, with a note that the next step sends a 150₮ SMS to 150073). Step 2 shows "send it from {masked number}" with a change-number link, the code, the 150₮ price above the SMS button (phone) or a QR (desktop). The embedding page cannot alter it, so the price rule is always met. Status arrives by server push (SSE); nothing to code. On success the modal shows a check and the masked number, closes after about 1.2 s and fires `verified`. Why the SMS: a typed number proves nothing, but the operator delivers the SMS with the sender's number, so sending the code from that phone proves the user holds the SIM. An SMS with the code from another number is refused, and the widget says so ("That SMS came from another number. Send it from {masked}."). Create a publishable key (`pk_test_` / `pk_live_`) and add allowed sites in Dashboard > Widget (`localhost` / `127.0.0.1` on test keys only; others get an error card; `origin_not_allowed`, `widget_disabled`). ```html
``` `TwoFA.button(target, options)` returns a handle: `.on('open' | 'close' | 'verified' | 'expired' | 'error', fn)`, `.open()`, `.close()`, `.destroy()`. `verified` gives `{ token }`. The session is created when the user continues from the number step (at once on open when `phone` was passed). Esc, the close button or a backdrop click closes the modal. `error` gives the code as a bare string. `TwoFA.mount(target, { key, lang, phone, state })` renders the widget inline: same verified/expired/error events plus `ready` when first sized; `.on()` and `.destroy()` only. Options: - `key` (required): publishable key. - `lang`: `mn` or `en`; default `mn`. - `phone`: a known 8-digit number; skips the number step, and the session accepts an SMS only from it. Invalid: `invalid_phone`. - `state`: 1..128 chars `[A-Za-z0-9._~-]` (else `invalid_request`). A random value your server creates per sign-in attempt and keeps in the user's session. It is bound into the token and returned by redeem; compare it with the session value and refuse on mismatch. This stops a token obtained in one browser from being used in someone else's sign-in. - `accent`: '#rrggbb' only (else falls back to #1d3fc4). `radius`: px, clamped 0..24, default 12. `size`: 'full' (default) or 'compact'. Redeem on the server once with `POST /v1/verifications` (above), then check `state`. The token is an ES256 JWT, single-use, 10 minutes by default (a setting). Never trust it in the browser. Offline verification (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"` in its data. Dashboard > Widget (per mode): publishable key, allowed sites, theme, accent, radius, language, button style (full or compact), display name shown in the modal header, a live test-mode preview and a live activity list. ## Required price-before-sending UI (own UI only) Required by the terms: - Show the price "150₮" (English: "150₮") before showing the SMS button. - The button ("SMS илгээх" / "Send SMS") links to `sms_uri`. Also show the code and 150073 as text. - On desktop pages, show `qr_svg` instead of the button: price line first, then the QR, then the hint "Утасныхаа камераар уншуулна уу" / "Scan with your phone's camera". Hide the QR once `verified` or `expired`. - Poll the session status through the client's own backend (e.g. every 2 s). Never call api.2fa.mn from the browser. - Hide the button once the status is `verified` (and when `expired`). - Mark the user verified from the webhook, not from polling. Suggested backend proxy endpoints: `POST /api/2fa/session` (creates a session with `metadata.user_id`) and `GET /api/2fa/session/:id` (returns `id`, `code`, `short_code`, `sms_uri`, `qr_svg`, `status`, `expires_at`). ## Errors Format: HTTP status + `{ "error": { "code": "...", "message": "..." } }`. Branch on `code`. | HTTP | code | Meaning | | --- | --- | --- | | 401 | unauthorized | Missing, invalid or revoked API key, or a key used outside its IP allowlist | | 400 | invalid_request | Malformed JSON or invalid/missing field | | 400 | invalid_phone | Not a valid 8-digit Mongolian number | | 404 | not_found | Object doesn't exist in this mode | | 409 | prefix_taken | Live rule prefix overlaps another client's prefix | | 409 | rule_limit | The client already has the maximum number of live rules (5) | | 429 | rate_limited | Too many requests; back off and retry | | 403 | client_suspended | Client account suspended | | 500 | internal_error | Temporary server error; retry with backoff | | 403 | test_only | Endpoint requires a test key | | 400 | invalid_token | Widget token malformed or unknown | | 409 | token_used | Widget token already redeemed | | 410 | token_expired | Widget token expired | | 403 | origin_not_allowed | Widget loaded from a site not on the key's list | | 403 | widget_disabled | Widget switched off | Re-sending the code of an already verified session is `already_verified`, not an error: no new webhook, and it does not count toward the wrong-SMS throttle (5 wrong SMS per number per hour). Rate limits apply per phone number per client and per client per minute. Values are adjustable; don't hard-code them.