# MiniMoth — Full API Documentation > WhatsApp/SMS OTP authentication API for Indian developers and startups. Send and verify OTPs via two REST endpoints — no WhatsApp Business Account setup, no TRAI DLT registration, no telecom paperwork. MiniMoth handles all regulatory complexity for both channels. Base URL: `https://api.minimoth.dev` Authentication: `X-Api-Key: mm_live_...` header on every request. Indian phone numbers only. Accepted formats: `+91XXXXXXXXXX` or bare 10 digits starting with 6–9. --- ## Quickstart Three steps to verify a phone number (with an optional status check between steps 1 and 2): 1. Your server calls `POST /v1/otp/send` — MiniMoth generates a 6-digit code and attempts delivery via WhatsApp first, then SMS automatically if WhatsApp fails. You are charged ₹0.35 at this point. Response includes an `otp_id`. 2. *(Optional)* Your server calls `GET /v1/otp/status/:otp_id` — poll to confirm delivery. Returns `channel` (`whatsapp` or `sms`), `status` (`queued`, `delivered`, `failed`), and `delivered_at`. 3. User enters the code in your app. 4. Your server calls `POST /v1/otp/verify` — on success, returns `access_token` and `refresh_token`. ### Get an API key Create a project at https://app.minimoth.dev. Every project gets an API key in the format `mm_live_...`, shown once on creation and available any time under Project Settings. ### Send an OTP ``` POST /v1/otp/send Headers: X-Api-Key, Content-Type: application/json Body: { "phone": "+919876543210" } ``` ### Check delivery status (optional) ``` GET /v1/otp/status/:otp_id Headers: X-Api-Key ``` Response: ```json { "otp_id": "550e8400-e29b-41d4-a716-446655440000", "channel": "whatsapp", "status": "delivered", "delivered_at": "2024-01-15T10:30:00.000Z" } ``` - `channel`: `"whatsapp"` or `"sms"` — which carrier delivered it - `status`: `"queued"` (in-flight), `"delivered"` (confirmed), `"failed"` (both channels failed) - `delivered_at`: ISO 8601 timestamp, or `null` if not yet delivered Use the `otp_id` returned by `otp/send`. Poll until `status === "delivered"` before showing a "code sent" confirmation to your user. This step is optional — you may call `otp/verify` without first checking status. **Sandbox:** With a `mm_test_` key, mock delivery completes in 1–10 seconds and `status` transitions from `"queued"` to `"delivered"` automatically. ### Verify the code ``` POST /v1/otp/verify Headers: X-Api-Key, Content-Type: application/json Body: { "phone": "+919876543210", "code": "123456" } Response: { access_token, refresh_token, expires_at } // expires_at is the access_token expiry (ISO 8601) // access_token valid for 5 minutes // refresh_token valid for project's configured refresh window (default 10 days, max 30 days) ``` If you already have your own auth system, you can use MiniMoth purely for OTP delivery and verification — ignore the returned tokens and create a session in your own system. --- ## Authentication Every request must include the API key: ``` headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json', } ``` Find or regenerate your key from the dashboard under Project → Settings. Regenerating immediately invalidates the old one. ### Rate Limits | Endpoint | Live key (`mm_live_`) | Test key (`mm_test_`) | |------------------------------|-------------------------------|----------------------------| | POST /v1/otp/send | 3 per phone per 10 minutes | 20 per phone per 10 minutes | | POST /v1/otp/verify | 5 attempts per OTP, then invalidated | 20 attempts per OTP | | POST /v1/session/validate | 3000 requests/min | 300 requests/min | | Other `/v1/*` (refresh, logout, status) | 1000 requests/min | 100 requests/min | --- ## Sessions A successful `otp/verify` returns two tokens: **access_token** — Short-lived, expires in 5 minutes. Send with every authenticated request to your backend. Validate via `session/validate`. **refresh_token** — Long-lived, absolute expiry per project (default 10 days, max 30 days). Store securely server-side only. Use to get a new access_token without asking the user to re-enter their OTP. ### Token Lifecycle 1. OTP verified — two tokens issued. Store `access_token` in memory or short-lived cookie. Store `refresh_token` in httpOnly cookie or secure server-side store. 2. Every request — validate access_token via `session/validate`. Returns `valid: true` + expiry (no phone number — see below). 3. access_token expires (5 min) — call `session/refresh` with refresh_token. Both tokens are rotated — you get a new access_token AND new refresh_token. Replace both. 4. refresh_token reaches absolute expiry — session over. User must re-verify phone. The absolute expiry never extends — using the refresh token does not reset the clock. 5. Logout — call `session/logout`. access_token immediately invalidated, all refresh tokens for the session removed. ### The 10-Second Grace Window Every refresh rotates both tokens. The old refresh_token is immediately invalid. However, SSR frameworks like Next.js can trigger simultaneous server + client refresh calls within milliseconds. The grace window handles this: - Within 10 seconds: same token used again — treated as duplicate, returns new access_token safely, no lockout. - After 10 seconds: same token used again — theft detected, all session tokens revoked, user must re-verify. Note: on theft detection, any access_token already issued remains valid until it expires naturally (up to 5 minutes). Revocation applies to refresh tokens immediately. ### Validate a Session ``` POST /v1/session/validate Body: { "access_token": "..." } Response: { valid: boolean, expires_at: string } // valid: false on any invalid/expired token — never throws ``` Safe to call on every request. Fast and lightweight. No `phone` field is returned — the JWT payload deliberately excludes it (JWTs are unencrypted and travel client-side). Decode `access_token` yourself for `session_id`, then resolve it back to a phone number via your own mapping, built when you called `otp/verify`. **API validation vs. local JWT validation:** the access_token is a signed JWT (RS256). Calling `session/validate` as above is API validation — every call checks server-side, so revocation (logout, theft detection) is caught immediately. The Node.js SDK also offers local JWT validation: it fetches MiniMoth's public signing key once from `/.well-known/jwks.json` (cached 1 hour) and verifies the token's signature and expiry entirely on your server — zero network round trip per request. This is the SDK's default `validateMode: 'instant'`. Local is fastest but blind to revocation until the token's own 5-minute expiry; API (or SDK `strict` mode) is always current but costs a round trip. See validateMode below for the full comparison. ### Refresh a Session ``` POST /v1/session/refresh Body: { "refresh_token": "..." } Response: { access_token, refresh_token, expires_at } // Always replace the stored refresh_token with the new one — old one is immediately invalid // 401 if the refresh token is expired or reused outside the 10-second grace window ``` ### Log Out ``` POST /v1/session/logout Body: { "access_token": "..." } // 401 INVALID_ACCESS_TOKEN if the access_token is expired — refresh first, then log out ``` --- ## Recipes ### Recipe 1: OTP-Only Delivery Use MiniMoth to verify a phone number only. Handle sessions yourself. ```javascript // Step 1: send the OTP await fetch('https://api.minimoth.dev/v1/otp/send', { method: 'POST', headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '+919876543210' }), }) // Step 2: verify the code const res = await fetch('https://api.minimoth.dev/v1/otp/verify', { method: 'POST', headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: '+919876543210', code: '123456' }), }) if (!res.ok) throw new Error('OTP verification failed') const { access_token } = await res.json() // Phone is verified — now use your own auth (JWT, sessions table, cookie, etc.) // MiniMoth's tokens are optional — ignore them if you prefer ``` ### Recipe 2: Protecting an API Route (Express) Verify the access_token locally as a JWT — no network round trip per request. Fetch and cache MiniMoth's public signing keys once, refetching on a `kid` cache miss. The guard's job is just confirming the session is valid and exposing `session_id`; resolving that to an end-user (your own `session_id → phone` mapping, built at `otp/verify` time) is your app's concern, not the guard's. ```javascript const jwt = require('jsonwebtoken') const { createPublicKey } = require('crypto') let jwks = null async function getPublicKey(kid) { if (!jwks?.keys.some(k => k.kid === kid)) { const res = await fetch('https://api.minimoth.dev/.well-known/jwks.json') jwks = await res.json() } const jwk = jwks.keys.find(k => k.kid === kid) return createPublicKey({ key: jwk, format: 'jwk' }) } async function requireAuth(req, res, next) { const token = req.headers['x-access-token'] ?? req.cookies.access_token if (!token) return res.status(401).json({ error: 'No token' }) try { const { kid } = jwt.decode(token, { complete: true }).header const publicKey = await getPublicKey(kid) const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] }) req.sessionId = payload.session_id next() } catch { res.status(401).json({ error: 'Invalid or expired session' }) } } ``` Local verification is blind to server-side revocation (logout, theft detection) until the token's own 5-minute expiry. If every request needs the latest revocation state, call `session/validate` instead. The Node.js SDK's `mm.session.safeValidate()` does this same JWKS fetch/cache/verify for you, and lets you dial revocation freshness via `validateMode`: `instant` (local only, default), `recheck_1m` / `recheck_3m` (revocation check, cached), or `strict` (revocation check every call) — see validateMode below. ### Recipe 3: Handling Token Refresh (Client-Side) ```javascript let accessToken = null async function apiFetch(url, options = {}) { const res = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}` }, }) if (res.status === 401) { const refreshed = await fetch('/auth/refresh', { method: 'POST' }) if (!refreshed.ok) { window.location.href = '/login' return } const { access_token } = await refreshed.json() accessToken = access_token return fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}` }, }) } return res } ``` Keep refresh_token on your server only (httpOnly cookie). Client only ever sees the short-lived access_token. ### Recipe 4: Next.js App Router Refresh Endpoint ```typescript // app/api/auth/refresh/route.ts import { cookies } from 'next/headers' import { NextResponse } from 'next/server' export async function POST() { const cookieStore = cookies() const refreshToken = cookieStore.get('mm_refresh')?.value if (!refreshToken) { return NextResponse.json({ error: 'No refresh token' }, { status: 401 }) } const res = await fetch('https://api.minimoth.dev/v1/session/refresh', { method: 'POST', headers: { 'X-Api-Key': process.env.MINIMOTH_API_KEY!, 'Content-Type': 'application/json' }, body: JSON.stringify({ refresh_token: refreshToken }), }) if (!res.ok) { const response = NextResponse.json({ error: 'Session expired' }, { status: 401 }) response.cookies.delete('mm_refresh') return response } const { access_token, refresh_token: newRefreshToken } = await res.json() const response = NextResponse.json({ access_token }) response.cookies.set('mm_refresh', newRefreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/auth/refresh', // scoped — not sent with every request maxAge: 60 * 60 * 24 * 10, // match your project's refresh window }) return response } ``` --- ## Error Codes Every error response shape: ```json { "error": "Human-readable message", "code": "STABLE_CODE", "request_id": "550e8400-e29b-41d4-a716-446655440000" } ``` Codes are stable and will not change. Safe to switch on. ### Authentication Errors | Code | Status | Description | |-------------------|--------|------------------------------------------------------------------------| | MISSING_API_KEY | 401 | No X-Api-Key header was sent. | | INVALID_API_KEY | 401 | The API key is not recognised. Check the key in your project settings. | ### Validation Errors | Code | Status | Description | |-------------------------|--------|---------------------------------------------------------------------------------------------------------------| | INVALID_PHONE | 422 | Phone number is not a valid Indian mobile number. Must be 10 digits starting with 6–9, with or without +91. | | INVALID_OTP_CODE | 422 | The code field must be exactly 6 digits. | | MISSING_ACCESS_TOKEN | 422 | The request body is missing the access_token field. | | MISSING_REFRESH_TOKEN | 422 | The request body is missing the refresh_token field. | ### OTP Errors | Code | Status | Description | |-----------------------|--------|----------------------------------------------------------------------------------------------------| | OTP_RATE_LIMITED | 429 | Too many OTPs requested for this number. Limit is 3 per phone per 10 minutes. | | OTP_NOT_FOUND | 400 | No active OTP exists for this phone number. May have expired (10 min TTL) or already been used. | | INVALID_OTP | 400 | The OTP code is incorrect. | | VERIFY_RATE_LIMITED | 429 | Too many incorrect attempts. The OTP is rate-locked for 10 minutes — request a new one. | | SMS_FAILED | 500 | Both WhatsApp and SMS delivery failed. The charge was not applied. Retry after a moment. | | OTP_REQUEST_NOT_FOUND | 404 | No OTP request found with that ID, or it does not belong to this project. Returned by `GET /v1/otp/status/:otp_id`. | ### Billing Errors | Code | Status | Description | |-----------------------|---------|-----------------------------------------------------------------------------------------------------------------| | INSUFFICIENT_BALANCE | 402 | Wallet balance too low to send an OTP. Top up from the dashboard. No OTP was sent, no charge applied. | ### Session Errors | Code | Status | Description | |------------------|--------|-----------------------------------------------------------------------------------------| | SESSION_EXPIRED | 401 | The refresh token has expired or is invalid. The user must re-authenticate. | | INVALID_ACCESS_TOKEN | 401 | The access token is invalid or expired. Returned by session/logout — refresh the session first, then retry the logout. | | RATE_LIMITED | 429 | General rate limit exceeded — 1000 requests/min per API key. Back off and retry. | **Note on theft detection:** If a used refresh token is reused outside the 10-second grace window, all tokens for the session are immediately revoked and a 401 is returned. This response has no `code` field — only `error: "Refresh token reuse detected"` and `request_id`. Treat any 401 from `session/refresh` without a `code` as a security event; the user must re-verify their phone number. --- ## Node.js SDK npm package: `@minimoth/sdk-node` Install: `npm install @minimoth/sdk-node` Requires Node.js 18+. Recommended over raw API calls for Node.js, Express, and Next.js backends. **Key advantage over raw API:** `session.validate()` auto-refreshes expired access tokens silently using the stored refresh token. New tokens are returned in `session.newTokens` — you never write refresh logic. ### Setup ```typescript import { MiniMoth } from '@minimoth/sdk-node' const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY, // mm_live_... }) ``` ### OTP Flow ```typescript // Send OTP — returns otpId const { otpId } = await mm.otp.send({ phone: '+919876543210' }) // Check delivery status (optional) const delivery = await mm.otp.status(otpId) // delivery.status: 'queued' | 'delivered' | 'failed' // delivery.channel: 'whatsapp' | 'sms' // Verify — never throws, always returns a result const result = await mm.otp.verify({ phone: '+919876543210', otp: '123456' }) if (!result.valid) { // result.code: 'INVALID_OTP' | 'OTP_NOT_FOUND' | 'VERIFY_RATE_LIMITED' | 'INVALID_PHONE' } // result.accessToken, result.refreshToken, result.sessionId ``` Phone formats accepted by the SDK: `+91XXXXXXXXXX`, `91XXXXXXXXXX`, or bare 10 digits. Spaces and hyphens are rejected with `INVALID_PHONE` before the request is sent. ### Validate a Session ```typescript // Never throws — returns { valid, session, code } const result = await mm.session.safeValidate(accessToken) if (!result.valid) return res.status(401).json({ error: result.code }) result.session.projectId result.session.sessionId result.session.expiresAt // Date // If SDK auto-refreshed the token, send new tokens to the client if (result.session.newTokens) { // result.session.newTokens.accessToken // result.session.newTokens.refreshToken } ``` ### Refresh and Logout ```typescript // Manual refresh (SDK handles refresh automatically inside safeValidate) const tokens = await mm.session.refresh(refreshToken) // tokens.accessToken, tokens.refreshToken // Logout — revokes all tokens for the session await mm.session.logout({ accessToken, refreshToken }) ``` ### Express — Full Auth Flow ```typescript import express from 'express' import cookieParser from 'cookie-parser' import { MiniMoth, MiniMothError } from '@minimoth/sdk-node' const app = express() app.use(express.json()) app.use(cookieParser()) const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY }) app.post('/auth/send', async (req, res) => { try { const { otpId } = await mm.otp.send({ phone: req.body.phone }) res.json({ otpId }) } catch (err) { if (err instanceof MiniMothError) { return res.status(err.statusCode || 400).json({ error: err.code }) } throw err } }) app.post('/auth/verify', async (req, res) => { const result = await mm.otp.verify({ phone: req.body.phone, otp: req.body.otp }) if (!result.valid) return res.status(400).json({ error: result.code }) res.cookie('mm_refresh', result.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' }) res.json({ accessToken: result.accessToken }) }) // safeValidate auto-refreshes expired tokens — no manual refresh logic async function requireAuth(req, res, next) { const token = req.headers['x-access-token'] if (!token) return res.status(401).json({ error: 'No token' }) const result = await mm.session.safeValidate(token) if (!result.valid) return res.status(401).json({ error: result.code }) // result.session has no phone field — resolve session_id via your own // mapping (built when you called mm.otp.verify) if you need the phone. req.sessionId = result.session.sessionId if (result.session.newTokens) { res.setHeader('X-New-Access-Token', result.session.newTokens.accessToken) res.cookie('mm_refresh', result.session.newTokens.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict' }) } next() } app.get('/profile', requireAuth, (req, res) => res.json({ sessionId: req.sessionId })) app.post('/auth/logout', requireAuth, async (req, res) => { await mm.session.logout({ accessToken: req.headers['x-access-token'], refreshToken: req.cookies.mm_refresh }) res.clearCookie('mm_refresh') res.json({ ok: true }) }) ``` **Client-side: handling `X-New-Access-Token`** When `safeValidate` auto-refreshes a token, it sets `X-New-Access-Token` on the response. The browser client **must** read this header on every API response and replace its stored access token. Without this, subsequent requests will use the old expired token — causing the SDK to auto-refresh again on every call. ```javascript // Browser-side fetch wrapper — check X-New-Access-Token on every response async function apiFetch(url, options = {}) { const res = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}` }, }) // Server auto-refreshed the token (SDK detected expiry) — update local copy. // Must happen on every response so the next request uses the new token. const newToken = res.headers.get('X-New-Access-Token') if (newToken) accessToken = newToken // 401 means the access token is expired and the server could not auto-refresh // (refresh token is also expired or revoked) if (res.status === 401) { const refreshed = await fetch('/auth/refresh', { method: 'POST' }) if (!refreshed.ok) { window.location.href = '/login'; return } const { access_token } = await refreshed.json() accessToken = access_token return fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${accessToken}` }, }) } return res } ``` ### Next.js App Router — Full Auth Flow ```typescript // lib/minimoth.ts import { MiniMoth } from '@minimoth/sdk-node' export const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY! }) // app/api/auth/send/route.ts import { mm } from '@/lib/minimoth' import { MiniMothError } from '@minimoth/sdk-node' import { NextRequest, NextResponse } from 'next/server' export async function POST(req: NextRequest) { const { phone } = await req.json() try { const { otpId } = await mm.otp.send({ phone }) return NextResponse.json({ otpId }) } catch (err) { if (err instanceof MiniMothError) { return NextResponse.json({ error: err.code }, { status: err.statusCode || 400 }) } throw err } } // app/api/auth/verify/route.ts export async function POST(req: NextRequest) { const { phone, otp } = await req.json() const result = await mm.otp.verify({ phone, otp }) if (!result.valid) return NextResponse.json({ error: result.code }, { status: 400 }) const response = NextResponse.json({ accessToken: result.accessToken }) response.cookies.set('mm_refresh', result.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 60 * 60 * 24 * 10, }) return response } // app/api/auth/refresh/route.ts import { cookies } from 'next/headers' export async function POST() { const refreshToken = cookies().get('mm_refresh')?.value if (!refreshToken) return NextResponse.json({ error: 'No refresh token' }, { status: 401 }) try { const tokens = await mm.session.refresh(refreshToken) const response = NextResponse.json({ accessToken: tokens.accessToken }) response.cookies.set('mm_refresh', tokens.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 60 * 60 * 24 * 10, }) return response } catch { const response = NextResponse.json({ error: 'SESSION_EXPIRED' }, { status: 401 }) response.cookies.delete('mm_refresh') return response } } // middleware.ts export async function middleware(req: NextRequest) { const accessToken = req.cookies.get('mm_access')?.value if (!accessToken) return NextResponse.redirect(new URL('/login', req.url)) const result = await mm.session.safeValidate(accessToken) if (!result.valid) return NextResponse.redirect(new URL('/login', req.url)) // result.session has no phone field — resolve session_id via your own // mapping (built when you called mm.otp.verify) if you need the phone. const response = NextResponse.next() response.headers.set('x-session-id', result.session.sessionId) return response } export const config = { matcher: ['/dashboard/:path*'] } // app/api/auth/logout/route.ts export async function POST() { const cookieStore = cookies() const accessToken = cookieStore.get('mm_access')?.value const refreshToken = cookieStore.get('mm_refresh')?.value if (accessToken && refreshToken) { await mm.session.logout({ accessToken, refreshToken }) } const response = NextResponse.json({ ok: true }) response.cookies.delete('mm_access') response.cookies.delete('mm_refresh') return response } ``` ### validateMode Controls whether `session.validate()` checks token revocation on the server. | Mode | Behaviour | Use when | |---|---|---| | `instant` (default) | Local JWT verify only — zero network round trip | Most apps | | `recheck_1m` | Revocation check, cached 60 s per token | Sensitive routes | | `recheck_3m` | Revocation check, cached 180 s per token | Balance latency/freshness | | `strict` | Revocation check on every call, no cache | High-security endpoints | ### Custom Session Store (Redis) The default store is in-memory. For multi-replica or serverless deployments, provide a Redis-backed store so refresh tokens are shared across instances. ```typescript const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY, session: { store: { async getRefreshToken(sessionId) { return redis.get(`mm:rt:${sessionId}`) }, async setTokens(sessionId, { refreshToken }) { await redis.set(`mm:rt:${sessionId}`, refreshToken, { EX: 60 * 60 * 24 * 10 }) }, async deleteSession(sessionId) { await redis.del(`mm:rt:${sessionId}`) }, }, }, }) ``` ### Error Codes (SDK) SDK error codes match backend codes exactly. SDK-only codes: | Code | Source | When | |---|---|---| | `INVALID_ACCESS_TOKEN` | Backend + local JWT | Token invalid, expired, or malformed | | `SESSION_EXPIRED` | Backend + SDK | Refresh token expired or session revoked | | `INVALID_OTP` | Backend | Wrong OTP code | | `OTP_NOT_FOUND` | Backend | OTP expired (10 min TTL) or already used | | `OTP_RATE_LIMITED` | Backend | 3 OTPs per phone per 10 min exceeded | | `VERIFY_RATE_LIMITED` | Backend | Too many wrong attempts on this OTP | | `INSUFFICIENT_BALANCE` | Backend | Wallet balance too low | | `INVALID_PHONE` | SDK (client-side) | Spaces/hyphens in phone, or empty | | `NETWORK_ERROR` | SDK (client-side) | fetch() threw — network unreachable | | `UNKNOWN_ERROR` | SDK | Unmapped backend error code | --- ## Integrations ### Firebase MiniMoth handles OTP delivery and verification only — it does not touch the Firebase Admin SDK or hold Firebase service account credentials. To sign a verified user into Firebase Auth, your own backend (which holds your Firebase service account) mints a [custom token](https://firebase.google.com/docs/auth/admin/create-custom-tokens) after calling `otp.verify()`, and the client exchanges it via `signInWithCustomToken()`. ```typescript // Your backend app.post('/auth/send', async (req, res) => { const { phone } = req.body const { otpId } = await mm.otp.send({ phone }) res.json({ otpId }) }) const result = await mm.otp.verify({ phone, otp }) if (!result.valid) return res.status(400).json({ error: result.code }) // result has no phone field — use the phone you already have. const uid = `phone:${phone}` // tenant-defined convention const customToken = await getAuth().createCustomToken(uid, { phone_number: phone }) res.json({ customToken }) ``` ```typescript // Your client const { customToken } = await fetch('/auth/verify', { /* ... */ }).then(r => r.json()) await signInWithCustomToken(getAuth(), customToken) ``` The `uid` convention is entirely tenant-defined — build it from MiniMoth's phone format (`+91XXXXXXXXXX`) consistently, or the same user can fork into two Firebase records. Full guide: https://minimoth.dev/docs/integrations/firebase ### Supabase Supabase Auth owns the entire OTP lifecycle (generation, verification, session issuance) via its [Send SMS Hook](https://supabase.com/docs/guides/auth/auth-hooks/send-sms-hook). MiniMoth becomes the delivery backend Supabase calls out to — your own application code doesn't change: ```typescript // This doesn't change when you add MiniMoth const { error } = await supabase.auth.signInWithOtp({ phone }) ``` Setup (test mode first): enable the Supabase Hook in the MiniMoth dashboard and copy the Test Hook URL + Secret, paste them into Supabase's Authentication → Hooks → Send SMS hook (type HTTPS), then trigger `signInWithOtp()` and check Supabase's own Auth logs for a successful call — test mode sends no real message, so Supabase's logs are the only way to confirm the hook is wired correctly. Repeat with the Live URL + Secret to go live. Real OTPs are billed to wallet balance at send time (default ₹0.35/OTP); test mode is free. Full guide: https://minimoth.dev/docs/integrations/supabase