⚡ OTPless Authentication
WhatsApp Magic Link
Let users verify their WhatsApp number with zero code entry. They tap Continue with WhatsApp, a pre-filled message opens, they hit Send — verified instantly. No OTP, no typing, no friction.
< 10s
Avg. verification time
0
Digits to type
1 tap
User action required
How it works
The session token is encoded as invisible Unicode zero-width characters (ZWJ/ZWNJ) inside the visible message. A human cannot read or copy them — WhatsApp sends them automatically when the user taps Send.
Your backend calls /magic-link/init
Returns session_id, wa_link (mobile button), scan_url (desktop QR), and stores your redirect_url.
Frontend shows button (mobile) or QR (desktop)
Detect mobile with navigator.userAgent. Mobile: <a href={wa_link}>. Desktop: <QRCodeSVG value={scan_url} />.
User taps or scans
WhatsApp opens with the pre-filled message that has an invisible verification token embedded in it.
User hits Send
The message with the hidden token arrives in your workspace WhatsApp inbox.
Webhook decodes token
Token is extracted, matched to session, session marked verified. Zero code entered or seen.
Poll returns verified: true
Your backend issues a JWT. User is redirected to redirect_url. Done.
Authentication
All init and status requests require an API key. Only the /scan redirect is public.
Header
Authorization: Bearer oa_live_xxx
Base URL
https://api.crm.emacronai.com/api/v1
Get keys
Settings → API Keys
🚫 CAUTION — Never call /magic-link/init from the browser
Your oa_live_xxx API key would be visible in the browser Network tab and could be stolen.
Browser ← { wa_link, scan_url, expires_at } ←──
Correct pattern: Frontend calls your own backend (e.g. POST /api/auth/whatsapp/init) → your backend calls EmacronAI CRM with the API key → forwards only session_id, wa_link, scan_url, expires_at to the frontend.
If your backend returns 502 on /init, verify that Authorization: Bearer oa_live_xxx is being sent and the key is valid in Settings → API Keys.
POST/api/v1/whatsapp/magic-link/init
Creates a new OTPless session. Returns wa_link for mobile and scan_url for desktop QR.
Request body
Response — 200 OK
🚫 Call /init from your backend — not from React/Vue/mobile
Exposing your API key in frontend code lets anyone read it from the browser Network tab. See the Authentication section above for the correct architecture.
GET/api/v1/whatsapp/magic-link/:session_id/status
Poll every 2–3 seconds. Returns verified: true with phone and redirect_url once the user sends the WhatsApp message.
📞 Phone number format — important
The phone field is always returned in strict E.164 format with no spaces, dashes, or parentheses — e.g. +917840985216. If your database stores numbers with spaces or formatting, a direct string comparison will silently return 0 rows. Always normalize both sides before comparing:
redirect_url — return users to your app
Pass redirect_url when calling init(). After the user verifies, three things happen automatically:
The WhatsApp confirmation reply will contain your redirect_url as a tap-to-return link.
The /auth/magic verification page auto-redirects to redirect_url (not the EmacronAI CRM dashboard).
The status API returns redirect_url — use it in your polling loop to redirect server-side.
Desktop QR code + Mobile button
On desktop, users cannot tap a wa.me link. Show a QR code using scan_url (a short ~70-char URL — produces a clean, easy-to-scan code). On mobile, show a <a href={wa_link}> button that opens WhatsApp directly. Detect device with navigator.userAgent.
📱 Mobile
Show wa_link as an anchor tag. Tapping opens WhatsApp with the pre-filled message.
🖥️ Desktop
Show QR code with scan_url as value. User scans with phone, WhatsApp opens, they send the message.
⚠️ React gotcha — countdown initialization causes instant false expiry
When state transitions to "pending", there is a render cycle where your countdown hasn't initialized yet and still reads 0. If you use secondsLeft === 0 as the expiry trigger, the handler fires instantly on the first render — showing “session expired” before the user even sees the QR code.
// ❌ Dangerous — triggers instantly on first render
const [secondsLeft, setSecondsLeft] = useState(0); useEffect(() => { if (state === "pending" && secondsLeft === 0) expire(); // fires immediately! }, [secondsLeft]);// ✅ Safe — initialize to expires_in (e.g. 300)
const [secondsLeft, setSecondsLeft] = useState(300); // not 0 // Let polling handle real expiry — server returns 400 when expired. // Use the countdown only as a UX display indicator, not for control flow.PUT/api/v1/whatsapp/magic-link-config
Customize the WhatsApp confirmation message sent to users after verification, and control whether a tap-back link is included.
Webhooks — alternative to polling
Instead of polling, register a webhook for magic_link.verified. Your server is notified instantly when a user verifies. See Webhooks docs for setup.