⚡ 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

StepTraditional OTPMagic Link ✓
User seesEmpty input box"Continue with WhatsApp" button
User doesWaits for SMS, reads it, types codeTaps button → hits Send
FrictionHigh — typos, SMS delaysZero — one tap
Spoofable?Yes — anyone with the codeNo — token invisible to user
Credits used1 WhatsApp credit0 credits

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.

01

Your backend calls /magic-link/init

Returns session_id, wa_link (mobile button), scan_url (desktop QR), and stores your redirect_url.

02

Frontend shows button (mobile) or QR (desktop)

Detect mobile with navigator.userAgent. Mobile: <a href={wa_link}>. Desktop: <QRCodeSVG value={scan_url} />.

03

User taps or scans

WhatsApp opens with the pre-filled message that has an invisible verification token embedded in it.

04

User hits Send

The message with the hidden token arrives in your workspace WhatsApp inbox.

05

Webhook decodes token

Token is extracted, matched to session, session marked verified. Zero code entered or seen.

06

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 → Your Backend → EmacronAI CRM API (API key lives here only)
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

FieldTypeRequiredDescription
contextstringNoTag for the session, e.g. 'login', 'signup', 'payment'. Max 64 chars. Returned in status.
redirect_urlstring (URL)NoYour app callback URL. User is redirected here after verification. Must be http or https, max 2KB.
phone_number_idstringNoUse a specific WA number from your workspace. Defaults to primary number.

Response — 200 OK

FieldTypeDescription
session_idstring (uuid)Use to poll /status. Never expose to end-users.
wa_linkstring (URL)wa.me deep-link. href for the "Continue with WhatsApp" button on mobile.
scan_urlstring (URL)✅ Use this for QR codes. Short ~70-char URL, scans cleanly on all devices. Redirects to wa_link server-side.
qr_linkstring (URL)⚠️ Deprecated. Plain wa.me link. Kept for backward compatibility only — always use scan_url instead.
message_textstringVisible message text (invisible token is embedded inside). Informational.
redirect_urlstring | nullEchoed from your request. URL users are sent to after verification.
expires_innumberSession TTL in seconds (300 = 5 minutes).
expires_atstring (ISO)UTC timestamp of expiry. Show a countdown on your frontend.

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

cURL — init session (run from your backend)
# 1. Init a session (call from YOUR backend — never from the browser)
curl -X POST https://api.crm.emacronai.com/api/v1/whatsapp/magic-link/init \\
  -H "Authorization: Bearer oa_live_xxx" \\
  -H "Content-Type: application/json" \\
  -d '{
    "context": "login",
    "redirect_url": "https://yourapp.com/auth/callback"
  }'

# Response:
{
  "session_id":   "550e8400-e29b-41d4-a716-446655440000",
  "wa_link":      "https://wa.me/919272117887?text=Verify%E2%80%8D...",
  "scan_url":     "https://api.crm.emacronai.com/api/v1/whatsapp/magic-link/UUID/scan",
  "qr_link":      "https://wa.me/919272117887?text=login+3f9a8b2c",
  "message_text": "Verify my login to Acme",
  "redirect_url": "https://yourapp.com/auth/callback",
  "expires_in":   300,
  "expires_at":   "2026-09-04T07:00:00Z"
}

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.

FieldWhen presentDescription
verifiedAlwaystrue once the user has sent the WhatsApp message.
phoneverified = trueStrict E.164 — no spaces or dashes. Always +[country][number] e.g. '+917840985216' (not '+91 784...'). Normalize before DB lookup.
verified_atverified = trueISO timestamp of when the token was decoded.
contextverified = trueContext string you passed to init().
redirect_urlverified = trueURL you passed to init(). Redirect user here after issuing a JWT.
expires_atverified = falseWhen session expires. After this, returns 400 with error: "expired".

📞 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:

// ✅ Safe — strip all spaces before DB lookup const phone = data.phone.replace(/\s+/g, ''); const user = await db.users.findOne({ phone }); // ✅ Even better — store in strict E.164 at write time // so comparisons always work without normalization
cURL — poll status
# 2. Poll status every 2-3 seconds
curl https://api.crm.emacronai.com/api/v1/whatsapp/magic-link/UUID/status \\
  -H "Authorization: Bearer oa_live_xxx"

# Before verification:
{ "verified": false, "expires_at": "2026-09-04T07:00:00Z" }

# After user sends the WhatsApp message:
{
  "verified":     true,
  "phone":        "+919876543210",
  "verified_at":  "2026-09-04T06:58:23Z",
  "context":      "login",
  "redirect_url": "https://yourapp.com/auth/callback"
}

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.

Without redirect_url: Users see a neutral "You're verified" screen. Your frontend polling handles the redirect via your own JWT issuance. Both flows work.

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 — isMobile detection + conditional QR / button
import { QRCodeSVG } from 'qrcode.react';
import { useState, useEffect } from 'react';

function WhatsAppLoginWidget({ wa_link, scan_url }) {
  const [isMobile, setIsMobile] = useState(false);

  useEffect(() => {
    setIsMobile(/Android|iPhone|iPad|Mobile/i.test(navigator.userAgent));
  }, []);

  return (
    <div>
      {isMobile ? (
        // Mobile: tap button opens WhatsApp directly
        <a href={wa_link} target="_blank" rel="noopener noreferrer">
          Continue with WhatsApp
        </a>
      ) : (
        // Desktop: QR code (use scan_url — it's short and scans cleanly)
        <div>
          <QRCodeSVG value={scan_url} size={200} />
          <p>Scan with your phone camera to verify</p>
        </div>
      )}
    </div>
  );
}

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

SettingDefaultDescription
verify_message"Verify my login to [AppName]"The visible pre-filled WhatsApp message text. Max 80 chars.
success_reply"✅ You're verified!..."Reply sent after successful verification. Max 300 chars.
include_fallback_linktrueAppend a tap-to-return link in the WA reply. Set false for server-side polling flows where you redirect programmatically.
cURL — update config
# Customize the WhatsApp reply message after verification
curl -X PUT https://api.crm.emacronai.com/api/v1/whatsapp/magic-link-config \\
  -H "Authorization: Bearer oa_live_xxx" \\
  -H "Content-Type: application/json" \\
  -d '{
    "success_reply":         "You are verified! Returning you to the app...",
    "include_fallback_link": false
  }'

# include_fallback_link:
#   true  (default) — appends "Didn't update? Tap: <link>" to the WA reply
#   false           — sends only your success_reply text, no link appended
#                     Use this for server-side flows where you handle redirect

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.

Webhook payload
{
  "type": "magic_link.verified",
  "workspace_id": "ws_xxx",
  "data": {
    "session_id":   "550e8400-e29b-41d4-a716-446655440000",
    "phone":        "+919876543210",
    "context":      "login",
    "redirect_url": "https://yourapp.com/auth/callback",
    "verified_at":  "2026-09-04T06:58:23Z"
  }
}

Full code examples

Node.js / TypeScript — complete flow with redirect_url
import { createClient } from '@emacrontechnologies/outreachagent';

const oa = createClient(process.env.OUTREACHAGENT_API_KEY!);

// Step 1: Backend — init session
app.post('/auth/whatsapp/init', async (req, res) => {
  const { session_id, wa_link, scan_url, expires_at } =
    await oa.magicLink.init({
      context:      'login',
      redirect_url: 'https://yourapp.com/auth/callback',
    });
  res.json({ session_id, wa_link, scan_url, expires_at });
});

// Step 2: Backend — poll / handle status
app.get('/auth/whatsapp/status/:id', async (req, res) => {
  const result = await oa.magicLink.status(req.params.id);
  if (result.verified) {
    const jwt = issueJwt(result.phone!);
    // Redirect user back to their app with JWT
    if (result.redirect_url) {
      return res.redirect(result.redirect_url + '?token=' + jwt);
    }
    return res.json({ verified: true, token: jwt, phone: result.phone });
  }
  res.json({ verified: false, expires_at: result.expires_at });
});

Error codes

HTTPcodeMeaning
400expiredSession expired (5 min TTL). Create a new session.
400invalid_redirect_urlredirect_url must be a valid http or https URL.
400no_wa_numberNo WhatsApp number connected. Go to Settings → WhatsApp.
401invalid_api_keyAPI key is missing or wrong. Check Authorization: Bearer oa_live_xxx header and verify the key in Settings → API Keys.
403module_inactiveWhatsApp module not activated.
403UNAUTHORIZEDAPI key lacks permission for this workspace.
404—Session not found or belongs to a different workspace.
429RATE_LIMITED100 sessions / workspace / 15 min exceeded.
502—EmacronAI CRM upstream error. Check that Authorization: Bearer oa_live_xxx is being sent and the key is valid. If env var is not set, this is the error you get.

Rate limits

EndpointLimit
POST /magic-link/init100 sessions / workspace / 15 minutes
GET /magic-link/:id/status120 polls / session (every 3s for 5 min)
GET /magic-link/:id/scanPublic — no auth, no limit
PUT /magic-link-config10 updates / workspace / hour

See also