Build your own UI

The 150₮ price notice and SMS button the terms require, ready to copy in HTML or React.

The widget does all of this for you

The Widget already shows the price, code, SMS button and QR, and hides the button once verified. Use this page only when you need your own design and flow.

The user pays their operator 150₮ (VAT included) for the SMS. Before they send it, they must know that.

Example. No SMS is sent here.

Required by the terms

Every integration must show the price before showing the SMS button, and hide the button once the number is verified. This protects users from paying for messages they didn't expect, or from sending twice.

What it looks like

Verify your phone number

Tap the button below to send one SMS to 150073 and verify your number.

150₮ · Charged by your operator.

Demo. No real SMS is sent here.

The rules:

  1. Show the price, "150₮", next to the button.
  2. The button opens sms_uri (for example sms:150073?body=482913), which fills in the number and the code.
  3. Also show the code and 150073 as text, for desktop users and phones that don't support sms: links.
  4. Poll the session status through your own backend every 2 seconds or so.
  5. When the status is verified, hide the button and show a confirmation. When it's expired, hide the button and offer a new code.
  6. On desktop pages you can show qr_svg instead of the button, with the same price line before it.

Good practice

Pass phone whenever you already know the number (sign-in, payment confirmation). A session bound to a phone can't be completed from another number. After verification, show the number back to the user: "Is your number 9911••33?"

Your backend

The browser never sees your secret API key. Add two small endpoints that proxy to 2fa.mn. Return only what the UI needs.

server.js
import express from "express";

const app = express();
app.use(express.json());

const API = "https://api.2fa.mn";
const headers = {
  Authorization: `Bearer ${process.env.TWOFA_KEY}`,
  "Content-Type": "application/json",
};

const pick = (s) => ({
  id: s.id,
  code: s.code,
  short_code: s.short_code,
  sms_uri: s.sms_uri,
  qr_svg: s.qr_svg, // only while pending
  status: s.status,
  expires_at: s.expires_at,
});

// Start a verification for the signed-in user.
app.post("/api/2fa/session", async (req, res) => {
  const r = await fetch(`${API}/v1/sessions`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      phone: req.body.phone, // optional
      metadata: { user_id: req.user?.id },
    }),
  });
  const body = await r.json();
  if (!r.ok) return res.status(r.status).json(body);
  res.status(201).json(pick(body));
});

// Read the status. Check that the session belongs to this user in your own DB.
app.get("/api/2fa/session/:id", async (req, res) => {
  const r = await fetch(`${API}/v1/sessions/${encodeURIComponent(req.params.id)}`, { headers });
  const body = await r.json();
  if (!r.ok) return res.status(r.status).json(body);
  res.json(pick(body));
});

app.listen(3000);

Mark the user as verified in your webhook handler, not from the polling endpoint. Polling only updates the screen.

Frontend

No framework, no build step. On desktop pages show the QR instead of the button: the price first, then qr_svg, then a hint. The backend above already returns qr_svg.

verify.html
<div id="twofa" class="twofa">
  <p class="twofa-price">Verification costs <strong>150₮</strong>, charged by your mobile operator.</p>
  <a id="twofa-btn" class="twofa-btn" href="#" hidden>Send SMS</a>
  <p id="twofa-manual" class="twofa-manual" hidden>
    Or send <strong id="twofa-code"></strong> to <strong>150073</strong>.
  </p>
  <p id="twofa-status" class="twofa-status" role="status" aria-live="polite"></p>
</div>

<script>
  (function () {
    var btn = document.getElementById("twofa-btn");
    var manual = document.getElementById("twofa-manual");
    var codeEl = document.getElementById("twofa-code");
    var statusEl = document.getElementById("twofa-status");
    var timer = null;

    function hideButton() {
      btn.hidden = true;
      manual.hidden = true;
    }

    function poll(id) {
      timer = setInterval(function () {
        fetch("/api/2fa/session/" + encodeURIComponent(id))
          .then(function (r) { return r.json(); })
          .then(function (s) {
            if (s.status === "verified") {
              clearInterval(timer);
              hideButton();
              statusEl.textContent = "Your number is verified.";
            } else if (s.status === "expired") {
              clearInterval(timer);
              hideButton();
              statusEl.textContent = "The code expired. Reload to get a new one.";
            }
          })
          .catch(function () {});
      }, 2000);
    }

    fetch("/api/2fa/session", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    })
      .then(function (r) { return r.json(); })
      .then(function (s) {
        btn.href = s.sms_uri;
        codeEl.textContent = s.code;
        btn.hidden = false;
        manual.hidden = false;
        statusEl.textContent = "Waiting for your SMS…";
        poll(s.id);
      })
      .catch(function () {
        statusEl.textContent = "Couldn't start verification. Try again.";
      });
  })();
</script>

<style>
  .twofa { max-width: 22rem; font-family: system-ui, sans-serif; }
  .twofa-btn {
    display: block; text-align: center; padding: 0.875rem 1rem;
    border-radius: 0.75rem; background: #1e3fae; color: #fff;
    font-weight: 600; text-decoration: none;
  }
  .twofa-btn[hidden], .twofa-manual[hidden] { display: none; }
  .twofa-manual, .twofa-status { font-size: 0.875rem; color: #555; }
</style>
verify-qr.html
<div class="twofa">
  <p class="twofa-price">Verification costs <strong>150₮</strong>, charged by your mobile operator.</p>
  <div id="twofa-qr-box" hidden>
    <div id="twofa-qr" class="twofa-qr" role="img" aria-label="QR code to send the SMS"></div>
    <p class="twofa-hint">Scan with your phone's camera</p>
    <p class="twofa-hint">Or send <strong id="twofa-qr-code"></strong> to <strong>150073</strong>.</p>
  </div>
  <p id="twofa-qr-status" class="twofa-status" role="status" aria-live="polite"></p>
</div>

<script>
  (async function () {
    const box = document.getElementById("twofa-qr-box");
    const statusEl = document.getElementById("twofa-qr-status");

    const r = await fetch("/api/2fa/session", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    if (!r.ok) {
      statusEl.textContent = "Couldn't start verification. Try again.";
      return;
    }
    const s = await r.json();
    // qr_svg is generated by 2fa.mn from fixed characters only, so it is safe to inline.
    document.getElementById("twofa-qr").innerHTML = s.qr_svg;
    document.getElementById("twofa-qr-code").textContent = s.code;
    box.hidden = false;
    statusEl.textContent = "Waiting for your SMS…";

    const timer = setInterval(async () => {
      try {
        const res = await fetch("/api/2fa/session/" + encodeURIComponent(s.id));
        if (!res.ok) return;
        const { status } = await res.json();
        if (status === "pending") return;
        clearInterval(timer);
        box.hidden = true; // hide the QR once verified or expired
        statusEl.textContent =
          status === "verified" ? "Your number is verified." : "The code expired. Reload to get a new one.";
      } catch {
        // temporary network error: try again on the next tick
      }
    }, 2000);
  })();
</script>

<style>
  .twofa-qr { width: 200px; height: 200px; padding: 12px; background: #fff; border-radius: 12px; }
  .twofa-qr svg { display: block; width: 100%; height: 100%; }
  #twofa-qr-box[hidden] { display: none; }
</style>
PhoneVerify.tsx
"use client";

import { useEffect, useRef, useState } from "react";

type Session = {
  id: string;
  code: string;
  short_code: string;
  sms_uri: string;
  status: "pending" | "verified" | "expired";
};

type Props = {
  phone?: string;
  locale?: "mn" | "en";
  onVerified?: () => void;
};

const copy = {
  mn: {
    price: "150₮",
    priceNote: "Баталгаажуулалтын төлбөрийг таны оператор авна.",
    button: "SMS илгээх",
    manual: (code: string) => `Эсвэл ${code} кодыг 150073 дугаарт илгээнэ үү.`,
    waiting: "Таны SMS-ийг хүлээж байна…",
    verified: "Таны дугаар баталгаажлаа.",
    expired: "Кодын хугацаа дууслаа.",
    retry: "Шинэ код авах",
    error: "Баталгаажуулалт эхлүүлж чадсангүй.",
  },
  en: {
    price: "150₮",
    priceNote: "Charged by your mobile operator.",
    button: "Send SMS",
    manual: (code: string) => `Or send ${code} to 150073.`,
    waiting: "Waiting for your SMS…",
    verified: "Your number is verified.",
    expired: "The code expired.",
    retry: "Get a new code",
    error: "Couldn't start verification.",
  },
};

export function PhoneVerify({ phone, locale = "mn", onVerified }: Props) {
  const t = copy[locale];
  const [session, setSession] = useState<Session | null>(null);
  const [error, setError] = useState(false);
  const verifiedRef = useRef(onVerified);
  verifiedRef.current = onVerified;

  async function start() {
    setError(false);
    setSession(null);
    try {
      const r = await fetch("/api/2fa/session", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(phone ? { phone } : {}),
      });
      if (!r.ok) throw new Error();
      setSession(await r.json());
    } catch {
      setError(true);
    }
  }

  useEffect(() => {
    start();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [phone]);

  useEffect(() => {
    if (!session || session.status !== "pending") return;
    const id = setInterval(async () => {
      try {
        const r = await fetch(`/api/2fa/session/${encodeURIComponent(session.id)}`);
        if (!r.ok) return;
        const next: Session = await r.json();
        if (next.status !== "pending") {
          setSession(next);
          if (next.status === "verified") verifiedRef.current?.();
        }
      } catch {}
    }, 2000);
    return () => clearInterval(id);
  }, [session]);

  if (error) {
    return (
      <div>
        <p>{t.error}</p>
        <button onClick={start}>{t.retry}</button>
      </div>
    );
  }

  if (!session) return null;

  if (session.status === "verified") {
    return <p role="status">{t.verified}</p>;
  }

  if (session.status === "expired") {
    return (
      <div>
        <p role="status">{t.expired}</p>
        <button onClick={start}>{t.retry}</button>
      </div>
    );
  }

  return (
    <div style={{ maxWidth: 352 }}>
      <p>
        <strong>{t.price}</strong> · {t.priceNote}
      </p>
      <a
        href={session.sms_uri}
        style={{
          display: "block",
          textAlign: "center",
          padding: "14px 16px",
          borderRadius: 12,
          background: "#1e3fae",
          color: "#fff",
          fontWeight: 600,
          textDecoration: "none",
        }}
      >
        {t.button}
      </a>
      <p style={{ fontSize: 14 }}>{t.manual(session.code)}</p>
      <p role="status" aria-live="polite" style={{ fontSize: 14 }}>
        {t.waiting}
      </p>
    </div>
  );
}

Use it on any page:

<PhoneVerify locale="en" onVerified={() => router.push("/welcome")} />

Copy you can use

MongolianEnglish
Price150₮150₮
Price noteБаталгаажуулалтын төлбөрийг таны оператор авна.Charged by your mobile operator.
ButtonSMS илгээхSend SMS
QR hintУтасныхаа камераар уншуулна ууScan with your phone's camera
ManualЭсвэл 482913 кодыг 150073 дугаарт илгээнэ үү.Or send 482913 to 150073.
VerifiedТаны дугаар баталгаажлаа.Your number is verified.
ExpiredКодын хугацаа дууслаа.The code expired.

What's next

Was this page helpful?