# 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: `+91XXXXXXXXXX` or bare 10 digits starting with 6–9. Projects can also opt in, per region, to WhatsApp-only OTP delivery for US and EU-area phone numbers — see the International WhatsApp OTP section below.
---
## Quickstart
Who this is for: MiniMoth is designed for indie developers, startups, and anyone building for Indian users who needs WhatsApp/SMS OTP without the overhead of a WhatsApp Business Account, DLT registration, or session management.
Delivery: Every OTP is sent over WhatsApp first, falling back to SMS automatically if WhatsApp delivery fails. Both channels run on MiniMoth's own infrastructure — a verified WhatsApp Business Account and a TRAI DLT-registered SMS sender — so you never set up a WABA, register a sender ID, or manage compliance for either one. You get one simple API; MiniMoth handles both channels behind it.
POST /v1/otp/send', description: 'MiniMoth generates a 6-digit code and delivers it via WhatsApp first, falling back to SMS automatically if WhatsApp fails. You are charged ₹0.35 at this point.' },
{ marker: '~', title: 'Optionally check delivery status', description: 'Call GET /v1/otp/status/:otp_id using the otp_id from step 1. Tells you whether the code landed on WhatsApp or SMS. Useful for showing delivery confirmation in your UI before the user enters the code.', tone: 'muted' },
{ marker: '2', title: 'User enters the code in your app', description: 'You collect the 6-digit code from your UI and send it to your server.' },
{ marker: '3', title: 'Your server calls POST /v1/otp/verify', description: 'MiniMoth checks the code and returns an access_token and refresh_token on success.' },
{ marker: '✓', title: 'Phone number verified — user is authenticated', description: 'Use the tokens to validate every subsequent request, or bring your own session system. See Sessions and Recipes.', tone: 'final' },
]} />
## 1. Get an API key
Create a project in the [dashboard](https://app.minimoth.dev/register). Every project gets an API key in the format `mm_live_...`, shown once on creation and available any time under Project Settings.
2. Send and verify an OTP with the Node.js SDK
[@minimoth/sdk-node](https://www.npmjs.com/package/@minimoth/sdk-node) is the fastest way in — it handles phone normalisation, token management, and auto-refresh, so there's no boilerplate around the raw API.
```bash
npm install @minimoth/sdk-node
```
```typescript
import { MiniMoth } from '@minimoth/sdk-node'
const mm = new MiniMoth({
apiKey: process.env.MINIMOTH_API_KEY, // mm_live_...
})
// 1. Send OTP — delivered via WhatsApp first, falls back to SMS automatically
const { otpId } = await mm.otp.send({ phone: '+919876543210' })
// 2. Verify the code — 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' | ...
} else {
// Phone is verified — result.accessToken, result.refreshToken, result.sessionId
}
```
**Accepted phone formats:** `+91XXXXXXXXXX` or bare 10 digits `XXXXXXXXXX`. The 10-digit number must start with 6–9.
`accessToken` is valid for 5 minutes. `refreshToken` is valid for your project's configured refresh window (default 10 days, max 30 days). See [the full Node.js SDK guide →](/docs/sdk-nodejs) for auto-refresh, session validation, and framework examples.
## Using another language, or calling the API directly
Not on Node.js, or want to talk to the REST API yourself? Two endpoints, no SDK required:
Send an OTP
```javascript
const res = 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' }),
})
const { otp_id, expires_at } = await res.json()
// otp_id can be used with GET /v1/otp/status/:otp_id to check delivery
// expires_at is when the OTP code itself expires (10 minutes), not a session token
```
The response includes an `otp_id` that can be used to poll delivery status, and an `expires_at` timestamp for when the OTP code itself expires (10 minutes) — distinct from the session token expiry returned by `otp/verify`.
### Check delivery status (optional)
```javascript
// Optional — poll delivery status before showing "OTP sent" in your UI
const res = await fetch(`https://api.minimoth.dev/v1/otp/status/${otp_id}`, {
headers: { 'X-Api-Key': 'mm_live_...' },
})
const status = await res.json()
// { otp_id, channel: 'whatsapp' | 'sms', status: 'queued' | 'delivered' | 'failed', delivered_at }
```
MiniMoth first tries WhatsApp; if that fails, SMS is sent automatically. The `channel` field tells you which was used. This step is optional — you can call `otp/verify` directly without checking status first.
### Verify the code
```javascript
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' }),
})
const { access_token, refresh_token, expires_at, identity_id } = await res.json()
// expires_at is the access_token expiry (ISO 8601)
// identity_id is stable across every future login from this same phone number
```
`access_token` is valid for 5 minutes. `refresh_token` is valid for your project's configured refresh window (default 10 days, max 30 days). See [Sessions](/docs/sessions) for how to refresh and validate tokens, and [Identity](/docs/identity) for what `identity_id` is used for.
Just need OTP delivery? You don't have to use MiniMoth's session tokens at all. If you already have your own auth system, call otp/send + otp/verify to confirm the phone number, then create a session in your own system. See the OTP-only recipe.
---
## Authentication
Every request to the MiniMoth API must include your project's API key in the `X-Api-Key` header:
```javascript
headers: {
'X-Api-Key': 'mm_live_...',
'Content-Type': 'application/json',
}
```
Find or regenerate your key any time from the dashboard under **Project → Settings**. Regenerating a key immediately invalidates the old one.
Using Node.js, Express, or Next.js? @minimoth/sdk-node attaches the X-Api-Key header for you — pass the key once to the client instead of setting it on every request:
```typescript
import { MiniMoth } from '@minimoth/sdk-node'
const mm = new MiniMoth({
apiKey: process.env.MINIMOTH_API_KEY, // mm_live_...
})
// mm.otp.send(), mm.otp.verify(), etc. attach the header for you
```
See the [Node.js SDK guide](/docs/sdk-nodejs) for the full API.
## 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 call — whether the code arrived over WhatsApp or SMS, MiniMoth's delivery channel doesn't affect sessions at all — returns two tokens. Here's what they do and why both exist.
access_token
Short-lived — expires in 5 minutes. Send this with every authenticated request to your backend. Validate it locally as a JWT (zero network round trip — the Node.js SDK does this by default), or call session/validate for a server-checked result. See local vs. API validation for the trade-off.
refresh_token
Long-lived — absolute expiry set per project (default 10 days, max 30 days). Store this securely. Use it to get a new access_token when the old one expires, without asking the user to re-enter their OTP. Every refresh also rotates the refresh_token itself — replace both, see Refresh a session.
The access_token is intentionally short-lived. If it leaks — intercepted in a log, accidentally exposed in an error — the damage is limited to 5 minutes. After that it's useless.
But you can't ask your user to re-verify their phone number every 5 minutes. That's where the refresh_token comes in. It lives on your server only, never travels in API responses to end users, and silently exchanges for a new access_token in the background.
Think of it like this: the access_token is the door key (short-lived, used constantly), and the refresh_token is the key-cutting machine (long-lived, stored safely, used only when the door key expires).
access_token in memory (or a short-lived cookie). Store refresh_token in an httpOnly cookie or secure server-side store.' },
{ marker: '2', title: 'Every request → validate access_token', description: 'Call session/validate. Returns valid: true + the session\'s expiry. Fast and lightweight — safe to call on every request. To resolve which end-user this session belongs to, look up the session_id against your own records — see the note below.' },
{ marker: '3', title: 'access_token expires (5 min) → call refresh', description: 'Call session/refresh with the refresh_token. Both tokens are rotated — you get a new access_token and a new refresh_token. Replace both in storage.' },
{ marker: '4', title: 'refresh_token reaches absolute expiry', description: 'The session is over. The user must re-verify their phone to get new tokens. The absolute expiry never extends — using the refresh token does not reset the clock.' },
{ marker: '✕', title: 'User logs out → both tokens revoked immediately', description: 'Call session/logout. The access_token is immediately invalidated and all refresh tokens for the session are removed.', tone: 'final' },
]} />
Every refresh rotates both tokens. The old refresh_token is immediately invalid. But there's one common scenario where this causes problems without a grace window:
The SSR hydration problem
In Next.js or similar frameworks, the server renders the page and the browser hydrates it almost simultaneously. If both try to refresh the token at the same instant — within milliseconds of each other — the first call succeeds and rotates the token. The second call arrives a few milliseconds later with the now-invalidated old token and gets a 401, logging the user out unexpectedly.
The 10-second grace window solves this: if the same refresh_token is used a second time within 10 seconds, MiniMoth treats it as a duplicate of the first call and returns a new access_token without invalidating anything.
After 10 seconds, the grace window closes. If an already-rotated refresh_token is used again, MiniMoth treats it as a stolen token and immediately revokes all tokens for that session, forcing the user to re-verify.
Note: when theft is detected, any access_token already issued for that session remains valid until it expires naturally (up to 5 minutes). Revocation applies to refresh tokens immediately — access tokens expire on their own TTL.
## Validate a session
Call this on every authenticated request from your app's users. Fast and lightweight — safe to call on every request without latency concerns. Returns `{ valid, expires_at }`, plus `identity_id` when there is one to report — no `session_id` or phone number; see the note below on resolving those. See [Identity](/docs/identity) for what `identity_id` is and when it's absent.
```javascript
const res = await fetch('https://api.minimoth.dev/v1/session/validate', {
method: 'POST',
headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token }),
})
const { valid, expires_at, identity_id } = await res.json()
// valid: false on any invalid/expired token — never throws
// identity_id is omitted entirely (not null) when there is none to report
```
The access_token is a signed JWT (RS256). Calling session/validate as above is API validation — every call checks MiniMoth's server-side cache, 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'.
The SDK also has recheck_1m / recheck_3m modes that balance the two — see validateMode for the full comparison.
Using the Node.js SDK? mm.session.safeValidate() does local JWT validation by default and auto-refreshes expired tokens for you:
```typescript
import { MiniMoth } from '@minimoth/sdk-node'
const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY })
// Local JWT verify only, zero network round trip (default validateMode: 'instant')
const result = await mm.session.safeValidate(accessToken)
if (!result.valid) return res.status(401).json({ error: result.code })
// result.session.sessionId, result.session.expiresAt
// If the access token had expired, safeValidate already refreshed it —
// forward result.session.newTokens to the client if present
```
Refresh a session
```javascript
const res = await fetch('https://api.minimoth.dev/v1/session/refresh', {
method: 'POST',
headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token }),
})
const { access_token, refresh_token: new_refresh_token, expires_at } = await res.json()
// Always replace the stored refresh_token with new_refresh_token.
// The old one is immediately invalidated.
// 401 if the refresh token is expired or was reused outside the 10-second grace window.
```
**Always replace both tokens** after a refresh — store the new `access_token` and the new `refresh_token`. The old refresh_token is immediately invalid.
## Log out
```javascript
await fetch('https://api.minimoth.dev/v1/session/logout', {
method: 'POST',
headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token }),
})
// 401 INVALID_ACCESS_TOKEN if the access_token is expired — refresh first, then log out
```
Don't need session management? You can use MiniMoth purely to verify a phone number and manage sessions yourself. See the OTP-only recipe.
Resolving a session to a phone number. session/validate returns only {'{ valid, expires_at }'} — no session_id and no phone number, by design (JWT payloads are unencrypted and travel client-side). The session_id lives in the access_token's own JWT payload — decode it yourself (the Node.js SDK's safeValidate() does this for you, exposing it as result.session.sessionId). You already have the phone number at the moment you call otp/verify (you submitted it yourself); store a session_id → phone mapping in your own database at that point, and look it up whenever a validated session needs to be tied back to a specific user.
---
## Identity
Every successful `otp/verify` call returns an `identity_id` — a stable id for the *person*, not just the session, scoped to your project. Verify the same phone number again next week, next month, next year, and you get the same `identity_id` back — as long as it's the same project. The same phone number verified in a different one of your projects gets a completely unrelated `identity_id`; MiniMoth never links identities across projects. It's a stable, opaque reference to that person for your own records — a cleaner foreign key than keying your schema directly off their phone number.
```javascript
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' }),
})
const { access_token, refresh_token, expires_at, identity_id } = await res.json()
// identity_id is stable across every future login from this same phone number
```
`session/validate` reports the same `identity_id` too:
```javascript
const res = await fetch('https://api.minimoth.dev/v1/session/validate', {
method: 'POST',
headers: { 'X-Api-Key': 'mm_live_...', 'Content-Type': 'application/json' },
body: JSON.stringify({ access_token }),
})
const { valid, expires_at, identity_id } = await res.json()
// identity_id is omitted entirely when there is none to report
```
This is separate from session_id. session_id identifies one login; it changes every time a user verifies their phone again. identity_id stays the same across all of them — it's what ties multiple logins, on multiple devices, over months or years, back to the same person.
An identity is scoped to one project. The same phone number verified in two different MiniMoth projects gets two unrelated identity_id values — same as everything else in MiniMoth, projects never share user data with each other.
Identities are retained indefinitely — there's no automatic expiry or cleanup. Recognising a returning person requires not deleting the link between their phone number and their identity_id. If you need to remove one, see Deleting an identity below.
Identity resolution only runs on real, live verifications — a mm_test_ sandbox key, and MiniMoth's dashboard Playground, never resolve or return an identity_id. Neither does the Test Group preview feature. All three are testing infrastructure, not real end users, so there's nothing to recognise across logins.
If you're using MiniMoth as a Supabase Send SMS Hook or an Auth0 custom phone provider, you will never get an identity_id — there is no MiniMoth session to attach one to.
In both integrations, Supabase or Auth0 generates the code, verifies it, and issues the session entirely on their own side. MiniMoth only delivers the SMS/WhatsApp message; it never sees the verification step and never mints a session of its own. The identity of that user already lives in Supabase's or Auth0's own user table — that's their job, not MiniMoth's, for these two integrations specifically.
If you need to recognise returning users across logins for a Supabase or Auth0 app, use their own user id — auth.users.id in Supabase, the Auth0 user_id — rather than anything from MiniMoth.
Looking up an identity
Looking up an identity is off by default for every project too, gated by its own setting — separate from identity deletion, so you can enable one without the other.
Turn it on first
In the dashboard, open your project's Identity settings card and enable Identity lookup — the same card that holds the identity deletion toggle. Until you do, both lookup routes return 403 IDENTITY_LOOKUP_DISABLED for every request.
```javascript
const phone = '+919876543210'
const res = await fetch(`https://api.minimoth.dev/v1/identity?phone=${encodeURIComponent(phone)}`, {
headers: { 'X-Api-Key': 'mm_live_...' },
})
const { id, created_at, factors } = await res.json()
// factors: [{ type: 'phone', value: '+919876543210' }]
// 403 IDENTITY_LOOKUP_DISABLED until you turn this on for the project (see below)
// 404 IDENTITY_NOT_FOUND if no identity matches that phone number exactly
```
Phone lookup is exact-match only.
You can also fetch by id directly with GET /v1/identity/:id — useful when you already have an identity_id saved (from a session, say) and want its current details.
```javascript
const res = await fetch(`https://api.minimoth.dev/v1/identity/${identity_id}`, {
headers: { 'X-Api-Key': 'mm_live_...' },
})
const { id, created_at, factors } = await res.json()
// factors: [{ type: 'phone', value: '+919876543210' }]
// 403 IDENTITY_LOOKUP_DISABLED until you turn this on for the project (see below)
// 404 IDENTITY_NOT_FOUND if the id doesn't exist, or belongs to a different project
```
This is different from session/validate. That endpoint deliberately omits any phone field (see above). These lookup routes exist specifically to return it — that's the point of opting in to identity lookup, and it's why it's off by default.
Both routes are rate limited to 30 requests/minute per project, shared between them.
Deleting an identity
Deleting an identity is off by default for every project — a live API key can't be used to erase identity data unless you've explicitly turned this on.
Turn it on first
In the dashboard, open your project's Identity settings card and enable Identity deletion. Until you do, DELETE /v1/identity/:id returns 403 IDENTITY_DELETE_DISABLED for every request, valid id or not.
```javascript
await fetch(`https://api.minimoth.dev/v1/identity/${identity_id}`, {
method: 'DELETE',
headers: { 'X-Api-Key': 'mm_live_...' },
})
// 403 IDENTITY_DELETE_DISABLED until you turn this on for the project (see below)
// 404 IDENTITY_NOT_FOUND if the id doesn't exist, or belongs to a different project
```
Once enabled, deleting an identity:
This is a good fit for a "delete my account" flow in your own app — call it from your backend once you've confirmed the request is genuinely from that user.
---
## Node.js SDK
Official SDK for Node.js, Express, and Next.js. Handles phone normalisation, token management, local JWT validation, and auto-refresh. Published as @minimoth/sdk-node on npm.
Unlike raw API calls, you never write refresh logic. The SDK's session.validate() automatically detects expired access tokens and refreshes them using the stored refresh token — returning the new tokens in session.newTokens so you can forward them to the client. One call, no manual token rotation.
## Installation
```bash
npm install @minimoth/sdk-node
```
Requires Node.js 18+. View the package on npm.
## Setup
```typescript
import { MiniMoth } from '@minimoth/sdk-node'
const mm = new MiniMoth({
apiKey: process.env.MINIMOTH_API_KEY, // mm_live_...
})
```
## OTP flow
Send an OTP, optionally check delivery, then verify the code. Works the same for international numbers once your project has opted in to a region — pass a number in E.164 format (e.g. `+1-2025551234`) instead of a domestic one. See International WhatsApp OTP for supported regions.
```typescript
// 1. Send OTP — returns otpId you can use to check delivery status
const { otpId } = await mm.otp.send({ phone: '+919876543210' })
// 2. (Optional) check delivery status
const delivery = await mm.otp.status(otpId)
// delivery.status: 'queued' | 'delivered' | 'failed'
// delivery.channel: 'whatsapp' | 'sms'
// 3. Verify the code — 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' | ...
return res.status(400).json({ error: result.code })
}
// Phone is verified — result.accessToken, result.refreshToken, result.sessionId
// The SDK stores tokens automatically. On validate(), expired tokens are
// refreshed silently — you never write refresh logic.
```
Identity
Every successful `verify()` recognises the same phone number across logins — see Identity for the full concept. `mm.identity` wraps the lookup and delete routes; both are off by default per project.
```typescript
// result.identityId (from otp.verify()) and session.identityId (from
// session.validate()) are the same stable id for this person, across every
// future login — see /docs/identity for the full concept.
if (result.valid && result.identityId) {
await saveIdentityForUser(result.identityId)
}
// Both lookup routes are off by default per project — enable them in
// Project Settings first (see /docs/identity), or these throw
// IDENTITY_LOOKUP_DISABLED / IDENTITY_DELETE_DISABLED.
const identity = await mm.identity.get(result.identityId)
// identity.id, identity.createdAt, identity.factors: [{ type: 'phone', value: '919876543210' }]
const same = await mm.identity.getByPhone('+919876543210')
// same.id === identity.id — exact-match phone lookup
// Hard-deletes the identity and revokes every active session tied to it
await mm.identity.delete(result.identityId)
```
Express — complete example
A single Express server handling the full auth flow. The SDK's default in-memory store persists refresh tokens across requests — auto-refresh works with no extra setup.
```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 })
// Send OTP
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
}
})
// Verify OTP — issue tokens
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 })
// Keep refresh_token server-side in an httpOnly cookie
res.cookie('mm_refresh', result.refreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
})
res.json({ accessToken: result.accessToken })
})
// Auth middleware — safeValidate auto-refreshes if the access token is expired
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 })
req.sessionId = result.session.sessionId
req.phone = await lookupPhoneBySessionId(req.sessionId) // your own session_id -> phone mapping, stored at verify time
// SDK auto-refreshed — send new tokens to the client
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()
}
// Protected route
app.get('/profile', requireAuth, (req, res) => {
res.json({ phone: req.phone })
})
// Logout
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 })
})
```
How auto-refresh works here: when safeValidate finds an expired access token, it silently calls session/refresh using the stored refresh token and returns the new tokens in session.newTokens. Your middleware forwards them via X-New-Access-Token header and a new cookie. The user never sees a logout.
Client must handle X-New-Access-Token: your browser-side fetch wrapper must check every response for this header and update the stored access token when it appears. If the client keeps sending the old expired token, the SDK will auto-refresh on every request — burning a network round-trip each time. See Recipe 3 for the client-side pattern.
Next.js App Router — complete example
Five files cover the full auth lifecycle. The refresh token lives in an httpOnly cookie — the client only ever sees the short-lived access token.
### Shared client
```typescript
// lib/minimoth.ts
import { MiniMoth } from '@minimoth/sdk-node'
export const mm = new MiniMoth({
apiKey: process.env.MINIMOTH_API_KEY!,
})
```
### Send OTP
```typescript
// 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
}
}
```
### Verify OTP
```typescript
// app/api/auth/verify/route.ts
import { mm } from '@/lib/minimoth'
import { NextRequest, NextResponse } from 'next/server'
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
}
```
### Refresh endpoint
Called by your client when it receives a 401. The SDK replaces the raw `fetch` to `session/refresh`.
```typescript
// app/api/auth/refresh/route.ts
// Called by your client when it receives a 401. The SDK handles the
// MiniMoth API call — no manual fetch needed.
import { mm } from '@/lib/minimoth'
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
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 })
}
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
```typescript
// middleware.ts
import { mm } from '@/lib/minimoth'
import { NextRequest, NextResponse } from 'next/server'
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))
// Pass the verified phone to downstream route handlers
const response = NextResponse.next()
const phone = await lookupPhoneBySessionId(result.session.sessionId) // your own session_id -> phone mapping, stored at verify time
response.headers.set('x-phone', phone)
return response
}
export const config = {
matcher: ['/dashboard/:path*'],
}
```
### Logout
```typescript
// app/api/auth/logout/route.ts
import { mm } from '@/lib/minimoth'
import { NextResponse } from 'next/server'
import { cookies } from 'next/headers'
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 revocation against the server.
| Mode | What it does | Use when |
|---|---|---|
| `instant` | Local JWT verify only — zero network round trip | Default. Fine for most apps. |
| `recheck_1m` | Revocation check, cached 60 s per token | Sensitive routes (payments, admin). |
| `recheck_3m` | Revocation check, cached 180 s per token | Balance between freshness and latency. |
| `strict` | Revocation check on every call, no cache | High-security endpoints. |
```typescript
const mm = new MiniMoth({
apiKey: process.env.MINIMOTH_API_KEY,
session: {
validateMode: 'recheck_1m', // check revocation, cache result 60 s per token
},
})
```
Custom session store (Redis)
The default store is in-memory — it works well for single-process Express apps. For multi-replica deployments or Next.js, provide a Redis-backed store so refresh tokens are shared across instances and auto-refresh works reliably.
```typescript
import { createClient } from 'redis'
import { MiniMoth } from '@minimoth/sdk-node'
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
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 handling
`send()` throws `MiniMothError` on failure. `verify()` never throws — it always returns a result object. See [Error Codes](/docs/error-codes) for the full list.
```typescript
import { MiniMothError } from '@minimoth/sdk-node'
// send() throws on error
try {
await mm.otp.send({ phone })
} catch (err) {
if (err instanceof MiniMothError) {
switch (err.code) {
case 'OTP_RATE_LIMITED': return res.status(429).json({ error: 'Wait 10 minutes before requesting another OTP' })
case 'INSUFFICIENT_BALANCE': return res.status(402).json({ error: 'Top up your wallet at app.minimoth.dev' })
case 'INVALID_PHONE': return res.status(422).json({ error: 'Invalid phone number' })
default: throw err
}
}
throw err
}
// verify() never throws — returns { valid: false, code } on failure
const result = await mm.otp.verify({ phone, otp })
if (!result.valid) {
switch (result.code) {
case 'INVALID_OTP': return res.status(400).json({ error: 'Wrong code' })
case 'OTP_NOT_FOUND': return res.status(400).json({ error: 'OTP expired or already used' })
case 'VERIFY_RATE_LIMITED': return res.status(429).json({ error: 'Too many attempts — request a new OTP' })
}
}
```
Not using Node.js? Use the REST API directly — MiniMoth is a plain HTTP API and works with any language or framework.
---
## Recipes
Common patterns for integrating MiniMoth into real apps.
Recipe 1
OTP-only delivery
Use MiniMoth purely to verify a phone number. You handle sessions yourself — with your own JWT, your own database, or whatever auth system you already have. MiniMoth acts as the OTP delivery and verification layer only.
This is a good fit if you already have users in a database and just want to add phone verification, or if you need session behaviour that MiniMoth doesn't support (e.g. multi-device, custom claims in tokens).
```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) {
// wrong code, too many attempts, or OTP expired
throw new Error('OTP verification failed')
}
const { access_token } = await res.json()
// access_token proves the phone was verified — now use your own auth:
// - create a row in your sessions table
// - set a cookie
// MiniMoth's access_token and refresh_token are optional — ignore them if you prefer
```
What you get: a confirmed phone number. What you own: everything after that — sessions, cookies, JWTs, logout.
Recipe 2
Protecting an API route
Verify the `access_token` locally as a JWT in your middleware or route guard — no network round trip per request. 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
// Middleware / route guard (Node.js / Express example)
// Verifies 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 rather than on every request.
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 — see
local vs. API validation.
The [Node.js SDK](/docs/sdk-nodejs) handles this same JWKS fetch, cache, and local verify for you via `mm.session.safeValidate()` — no need to write it yourself. It also lets you dial revocation freshness up or down per call via `validateMode`: `instant` (local only, the default), `recheck_1m` / `recheck_3m` (revocation check, cached), or `strict` (revocation check every call). See [validateMode](/docs/sdk-nodejs#validatemode).
Recipe 3
Handling token refresh
The access_token expires after 5 minutes. The pattern below retries any failed request automatically after refreshing — the user never sees a logout or an error.
**Security note:** keep the refresh_token on your server only, in an httpOnly cookie. Never expose it to JavaScript in the browser. The client only ever sees and stores the short-lived access_token.
```javascript
// Client-side fetch helper (works with any framework)
// Store access_token in memory, refresh_token in an httpOnly cookie (set by your server)
let accessToken = null
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 in middleware) — update local copy.
// Must happen on every response so the next request uses the new token, not the old one.
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
// (e.g. no refresh token in store). Fall back to calling the refresh endpoint directly.
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
}
// Your server's /auth/refresh endpoint calls MiniMoth:
// POST /v1/session/refresh with the refresh_token from the httpOnly cookie
// Returns new access_token + new refresh_token (set new cookie, return new access_token)
```
The [Node.js SDK](/docs/sdk-nodejs) (
@minimoth/sdk-node) does all of this for you —
session.safeValidate() detects an expired access token and refreshes it automatically, no manual retry loop needed. It also offers multiple refresh/revocation modes via
validateMode:
instant,
recheck_1m,
recheck_3m, and
strict — see
validateMode.
Recipe 4
Next.js App Router — refresh endpoint
A full server-side refresh route for Next.js. The refresh_token lives in an httpOnly cookie managed by the server; the browser only receives the new short-lived access_token.
```typescript
// app/api/auth/refresh/route.ts (Next.js App Router)
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) {
// Refresh token expired or revoked
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',
maxAge: 60 * 60 * 24 * 10, // match your project's refresh window
})
return response
}
```
Set path: '/api/auth/refresh' on the cookie so the browser only sends it to that specific endpoint — not to every API route.
## With the Node.js SDK
The same patterns above, using [@minimoth/sdk-node](https://www.npmjs.com/package/@minimoth/sdk-node). The SDK handles phone normalisation, typed errors, and auto-refreshes expired tokens inside `safeValidate()` — no refresh boilerplate needed. [Full SDK guide →](/docs/sdk-nodejs)
SDK Recipe 1
Express — full auth flow
Send, verify, protect routes, and logout — all using the SDK. Auto-refresh is built into `safeValidate()`; new tokens arrive in `session.newTokens` when rotation happens.
```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 })
req.sessionId = result.session.sessionId
req.phone = await lookupPhoneBySessionId(req.sessionId) // your own session_id -> phone mapping, stored at verify time
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({ phone: req.phone })
})
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 })
})
```
SDK Recipe 2
Next.js App Router — full auth flow
Five route handlers and a middleware file. The SDK replaces all raw `fetch` calls to MiniMoth — send, verify, refresh, validate, and logout.
```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 — SDK replaces the raw fetch call
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
import { NextRequest, NextResponse } from 'next/server'
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))
const response = NextResponse.next()
const phone = await lookupPhoneBySessionId(result.session.sessionId) // your own session_id -> phone mapping, stored at verify time
response.headers.set('x-phone', phone)
return response
}
export const config = { matcher: ['/dashboard/:path*'] }
```
For multi-replica or serverless deployments, provide a Redis-backed session store so the SDK can find refresh tokens across instances. See the
custom session store section.
---
## Error Codes
Every error response from the MiniMoth API has a stable `code` field you can use in your code to handle specific cases. Codes will not change — you can safely switch on them.
```json
{
"error": "Human-readable message",
"code": "STABLE_CODE",
"request_id": "req_..."
}
```
## Authentication
| 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
| Code | Status | Description |
|---|---|---|
| `INVALID_PHONE` | 422 | Phone number is not a valid Indian mobile number (10 digits starting with 6–9, with or without `+91`), and not a number in a supported international market either. See [International WhatsApp OTP](/docs/international-whatsapp) for supported markets. |
| `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
| 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. It 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 for this OTP. The OTP is now locked — request a new one. |
| `SMS_FAILED` | 500 | Both WhatsApp and SMS fallback 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`. |
## International markets
| Code | Status | Description |
|---|---|---|
| `REGION_NOT_ENABLED` | 403 | The phone number's market exists, but this project hasn't turned it on yet. Enable it from the project's International WhatsApp OTP card in the dashboard, which shows the rate before you turn it on. See [International WhatsApp OTP](/docs/international-whatsapp). |
| `INTERNATIONAL_WHATSAPP_FAILED` | 502 | A real WhatsApp delivery attempt to an international number failed. There is no SMS fallback for these numbers. The message states whether the charge was refunded — it always is unless the refund itself failed (rare; contact support if it persists). |
| `INTERNATIONAL_WHATSAPP_BLOCKED` | 502 | This number has had too many recent WhatsApp delivery failures and is temporarily blocked — no delivery was attempted and no charge was made for this call. The block clears on its own once the window passes without a new real failure. |
## Billing
| Code | Status | Description |
|---|---|---|
| `INSUFFICIENT_BALANCE` | 402 | Wallet balance is too low to send an OTP. Top up from the dashboard. No OTP was sent and no charge was applied. |
## Session
| 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` when called with an expired token — refresh the session first, then retry the logout. |
| `RATE_LIMITED` | 429 | General rate limit exceeded — 1000 requests per minute per API key. Back off and retry. |
## Identity
| Code | Status | Description |
|---|---|---|
| `IDENTITY_DELETE_DISABLED` | 403 | Identity deletion isn't turned on for this project. Enable it from the project's Identity settings card in the dashboard. See [Identity](/docs/identity#deleting-an-identity). |
| `IDENTITY_LOOKUP_DISABLED` | 403 | Identity lookup isn't turned on for this project. Enable it from the project's Identity settings card in the dashboard. See [Identity](/docs/identity#looking-up-an-identity). |
| `IDENTITY_NOT_FOUND` | 404 | No identity with that id, or it belongs to a different project. |
## Node.js SDK
[@minimoth/sdk-node](https://www.npmjs.com/package/@minimoth/sdk-node) surfaces every code above as-is via `err.code` — plus a few additional codes on top of the API's, for conditions the SDK catches locally before or instead of a network call.
| Code | Status | Description |
|---|---|---|
| `INVALID_PHONE` | — | SDK-only trigger, thrown client-side before any request is sent: an empty or non-string phone number. Spaces and hyphens are stripped, not rejected — `+1-2025551234` and `+12025551234` both work. Distinct from the API's own `INVALID_PHONE` (422) above, which fires server-side on a malformed but well-formatted number. |
| `NETWORK_ERROR` | — | The underlying `fetch()` call threw — the network was unreachable or the request otherwise never completed. |
| `UNKNOWN_ERROR` | — | The API response had no error code at all — an unparseable body, or a response shape that never included one. Check `err.statusCode` for the raw HTTP status. |
---
## International WhatsApp OTP
Every MiniMoth project can send OTPs to US and EU-area phone numbers over WhatsApp, using the exact same `POST /v1/otp/send` and `POST /v1/otp/verify` endpoints you already use for Indian (+91) numbers — just pass an international number instead:
```javascript
const res = 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: '+1-2025551234' }),
})
const { message, otp_id, expires_at } = await res.json()
// 403 REGION_NOT_ENABLED if this project hasn't turned on the number's region yet
// 422 INVALID_PHONE if the number's country isn't a supported region at all
```
```javascript
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: '+1-2025551234', code: '123456' }),
})
const { access_token, refresh_token, expires_at, identity_id } = await res.json()
// Same response shape as a domestic (+91) verify -- nothing about the session,
// identity, or refresh-token lifecycle differs for an international number.
```
Enable International regions
Enable regions from your project's International WhatsApp OTP card in the project details page, which shows the current rate for each region before you turn it on.
United States / Canada, United Kingdom, France, Netherlands, Germany, Rest of Western Europe, Spain, Italy, Poland, Hungary, Romania, and Rest of Central & Eastern Europe. Each is billed per OTP sent. If your project has its own connected WhatsApp Business Account instead, every international send is billed at a flat facilitation fee regardless of region — see Branding for connecting your own account.
Current rates for every region are shown in your project's International WhatsApp OTP card in the dashboard before you enable one.
Domestic (+91) sends try WhatsApp first and automatically fall back to SMS if delivery fails. International sends don't — there's no SMS route for these numbers at all, so a WhatsApp delivery failure surfaces directly instead of silently retrying over a different channel.
If a WhatsApp delivery fails, you get 502 INTERNATIONAL_WHATSAPP_FAILED and the charge for that attempt is automatically refunded to your wallet — no action needed on your end. See Error Codes for the full list.
After enough WhatsApp delivery failures for the same number in a short window, further sends to that number return 502 INTERNATIONAL_WHATSAPP_BLOCKED instead of attempting delivery again — no charge is made for a blocked attempt, since nothing was sent. The block clears on its own after the window passes without a new real failure.
---
## API Reference
Base URL: `https://api.minimoth.dev`. Every `/v1/*` request needs your project's API key in the `X-Api-Key` header — see [Authentication](/docs/authentication) for how to send it, and for the full rate-limit table.
## OTP
| Method | Endpoint | Description | Details |
|---|---|---|---|
| POST | /v1/otp/send | Send an OTP to a phone number. Tries WhatsApp first, falls back to SMS automatically. | Quickstart → |
| GET | /v1/otp/status/:otp_id | Check delivery status of a previously sent OTP. Optional — you can verify directly without polling this first. | Quickstart → |
| POST | /v1/otp/verify | Verify the code the user entered. Returns an access token and refresh token on success. | Quickstart → |
## Session
| Method | Endpoint | Description | Details |
|---|---|---|---|
| POST | /v1/session/validate | Check whether an access token is still valid. Fast enough to call on every authenticated request. | Sessions → |
| POST | /v1/session/refresh | Exchange a refresh token for a new access + refresh token pair. Always replace both. | Sessions → |
| POST | /v1/session/logout | Revoke a session's refresh token and its current access token. | Sessions → |
## Node.js SDK
[@minimoth/sdk-node](/docs/sdk-nodejs) wraps every endpoint above — `mm.otp.send()`, `mm.otp.verify()`, `mm.session.validate()`, and the rest — and handles the parts you'd otherwise write yourself: it attaches your API key to every request, **silently refreshes an expired session for you (`safeValidate`)**, and lets you configure where refresh tokens are stored (in-memory by default, Redis-backed for multi-replica deployments). Validation itself is configurable too — from `instant` (local JWT check, zero network round trip) to `strict` (revocation checked on every call) with cached in-between modes — see [validateMode](/docs/sdk-nodejs#validatemode) for the full comparison.
## Identity
Recognise the same phone number across logins. Both lookup and delete are off by default per project — turn them on from the project's Identity settings card in the dashboard first.
| Method | Endpoint | Description | Details |
|---|---|---|---|
| GET | /v1/identity/:id | Look up an identity by its id. | Identity → |
| GET | /v1/identity?phone= | Look up an identity by phone number (exact match). | Identity → |
| DELETE | /v1/identity/:id | Hard-delete an identity and revoke every session tied to it. | Identity → |
## Public key
| Method | Endpoint | Description | Details |
|---|---|---|---|
| GET | /.well-known/jwks.json | MiniMoth's public signing keys, for verifying access tokens locally without a network round trip per request. No API key needed. | Sessions → |
---
See [Error Codes](/docs/error-codes) for what each endpoint returns on failure, or [Recipes](/docs/recipes) for common integration patterns.
---
## Dashboard
The dashboard at [app.minimoth.dev](https://app.minimoth.dev) is where you create projects, copy API keys, watch usage, and top up your balance. What your app does at runtime — sending and verifying OTPs, validating and refreshing sessions, looking up identities — goes through the [API](/docs/api-reference); the dashboard is where you set things up and keep an eye on them.
## The pages
- **Overview** — your account balance (wallet plus free credits), OTPs sent today, today's delivery rate, and your projects.
- **Projects** — create a project, then open it for its live and test [API keys](/docs/authentication), the [Supabase](/docs/integrations/supabase) and [Auth0](/docs/integrations/auth0) hooks, [Identity](/docs/identity) settings, [Test Group](/docs/integrations/test-group), WhatsApp options (your own WhatsApp Business Account and international regions), and project settings such as the refresh-token window.
- **Identities** — browse and search the identities in a project, and delete one if you've turned deletion on. See [Identity](/docs/identity).
- **Playground** — try the whole OTP flow without writing code. See [below](#playground).
- **Usage** — OTPs sent over WhatsApp versus SMS, delivery outcomes, and daily spend, for the last 3 or 7 days. A card links to [Failed deliveries](#failed-deliveries).
- **Billing** — wallet balance and free credits, adding funds, redeeming a voucher, your [monthly spend cap](#spend-cap-and-alerts), and your transaction history.
- **Notifications** — which email alerts you get. See [below](#spend-cap-and-alerts).
- **Settings** — your profile, theme, [Streamer mode](#streamer-mode), and password.
## Conveniences
### Playground
The Playground has two panels:
- **Sandbox** — walks through send, status, verify, validate, refresh and logout against your project's test key. No message is sent and no credits are used; the verification code is always `000000`. Each step shows the request as a `curl` command and the response, so you can copy it straight into your own code.
- **Live delivery** — sends a real OTP to your own phone so you can see how it arrives. This is billed like any other OTP and uses your credits first, then your wallet.
### Test Group
Let up to five real testers preview the OTPs your Supabase or Auth0 Test Hook generates, with no real message sent. Set it up from a project's Test Group card — see the [Test Group guide](/docs/integrations/test-group).
### Failed deliveries
A list of every OTP that completely failed to deliver in the last 7 days, across all your projects. Open it from the Failed deliveries card on the Usage page.
### Spend cap and alerts
- **Monthly spend cap** — a ceiling on what your account can spend in a calendar month, set on the Billing page. It defaults to ₹1,000 and can be anything from ₹100 to ₹100,000. Once a send would take you past it, the send is rejected instead of billed.
- **Email alerts** — under Notifications, set a low-balance alert threshold (minimum ₹10) and choose whether to be told when a live API key is regenerated, when Identity deletion or lookup is switched on or off, when an international WhatsApp region is enabled or disabled, and about product updates. The alert for a zero balance is always on, because your next OTP would fail.
### Light and dark theme
Choose a theme under Settings → Appearance. It's saved to your account, so it follows you to other browsers and devices.
### Streamer mode
When you're about to share your screen, turn on **Streamer mode** to hide sensitive details. Use the Streamer mode switch at the bottom of the sidebar, or Settings → Streamer mode, and choose what to hide:
- **Account details** — your email, name, username, verified phone number, and profile photo.
- **Phone numbers** — your end-users' numbers, on the Identities, Test Group, Failed deliveries and Playground pages.
- **Money** — your balance, credits, spend, and transactions. Pricing (for example the per-OTP rate and the top-up amounts) stays visible, since it isn't specific to you.
Streamer mode only changes what's drawn on screen. It doesn't change anything MiniMoth stores or sends, and the setting is saved in the browser where you turned it on — it isn't synced to your account or other devices.
Streamer mode doesn't hide everything. API keys and webhook secrets keep their own show/hide control, the Playground's sandbox key and tokens are still visible in its request and response panels, anything you type into a field is shown as typed, and the payment window that opens when you add funds is shown by our payment provider. Check the page before you share it.
---
## Branding
Why this matters: MiniMoth delivers OTPs primarily via
WhatsApp, with SMS as fallback. On WhatsApp, messages arrive from MiniMoth's registered business account.
On SMS, they arrive from MiniMoth's TRAI DLT-registered sender ID. Either way, your users see MiniMoth's
name — not yours. Displaying the Powered by MiniMoth badge near
your phone number or OTP input lets users recognise the sender before the message arrives, building
trust instead of leaving them wondering who "MiniMoth" is.
## Badge variants
Four variants are available — light and dark backgrounds, with and without the moth mark. All badges link to `minimoth.dev` and are hosted on our CDN.
## Where to place it
- Near the phone number input — before the user submits, so they know what sender to expect
- On the OTP entry screen — next to the "enter the code you received" prompt
- Minimum display width: 120 px
## Placement examples
Show the badge where users are about to receive a message — before they look at their phone.
Phone number screen
Enter your phone number
+91 98765 43210
Send OTP
OTP entry screen
Enter the code
Sent to +91 98765 43210 via WhatsApp
{['1','2','3','4','5','6'].map((d) => (
{d}
))}
## Usage
Badges are hosted on our CDN — no self-hosting needed. Copy the snippet for your background and drop it in.
**Light background**
```html
```
**Dark background**
```html
```
## Usage terms
- Displaying this badge confirms that your product uses MiniMoth to deliver OTPs. It does not imply a partnership, endorsement, or any formal relationship beyond platform use.
- Do not alter the badge design, colors, or linked URL.
- MiniMoth reserves the right to revoke permission to use these assets at any time.
See [Terms of Service](/terms#brand-assets) for the full brand asset policy.
## Download — optional
The CDN snippets above are the easiest path. Download only if you need to self-host the files.
{[
{ label: 'Light — with logo', svg: 'powered-by-light-logo.svg', png: 'powered-by-light-logo.png' },
{ label: 'Dark — with logo', svg: 'powered-by-dark-logo.svg', png: 'powered-by-dark-logo.png' },
{ label: 'Light — text only', svg: 'powered-by-light.svg', png: 'powered-by-light.png' },
{ label: 'Dark — text only', svg: 'powered-by-dark.svg', png: 'powered-by-dark.png' },
].map(({ label, svg, png }, i) => (
0 ? 'border-t border-white/10' : ''}`}>
{label}
))}
---
## Firebase
Use MiniMoth for OTP delivery and verification, then sign the user into Firebase Auth with a custom token minted by your own backend.
## How it fits together
MiniMoth verifies the phone number. Your backend — which already holds your Firebase service account — mints a [Firebase custom token](https://firebase.google.com/docs/auth/admin/create-custom-tokens) for that user and hands it to the client, which exchanges it for a Firebase session:
1. Client calls your backend, which calls `otp/verify` via MiniMoth
2. Your backend calls `getAuth().createCustomToken(uid)`
3. Client calls `signInWithCustomToken()` with that token
MiniMoth never touches the Firebase Admin SDK and never stores your Firebase service account credentials — the custom token is minted entirely on your own backend, from your own credentials. This is a Custom Auth Token integration, not a Firebase Extension.
## Your backend (Express)
Verify the OTP, then mint the custom token in the same handler. `result` from `otp.verify()` carries no phone field — use the phone number you already have (the one the client sent to be verified) to build the Firebase `uid`.
```typescript
import express from 'express'
import { MiniMoth } from '@minimoth/sdk-node'
import { getAuth } from 'firebase-admin/auth'
const mm = new MiniMoth({ apiKey: process.env.MINIMOTH_API_KEY })
const app = express()
app.use(express.json())
app.post('/auth/send', async (req, res) => {
const { phone } = req.body
const { otpId } = await mm.otp.send({ phone })
res.json({ otpId })
})
app.post('/auth/verify', async (req, res) => {
const { phone, otp } = req.body
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 (the one
// you called otp.verify with) to build a stable, tenant-defined uid.
const uid = `phone:${phone}` // e.g. "phone:+919876543210"
const customToken = await getAuth().createCustomToken(uid, { phone_number: phone })
res.json({ customToken })
})
```
## Your client (web)
```typescript
import { getAuth, signInWithCustomToken } from 'firebase/auth'
const { customToken } = await fetch('/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, otp }),
}).then(r => r.json())
await signInWithCustomToken(getAuth(), customToken)
```
The uid convention is entirely yours
MiniMoth has no opinion on how you derive a Firebase uid from a phone number. MiniMoth's phone field is always +91XXXXXXXXXX — build your uid from that consistently, so the same user doesn't fork into two Firebase records (e.g. +919876543210 vs 9876543210). Changing the convention later orphans existing Firebase user records — there's no MiniMoth-side migration for this.
---
## Supabase
Deliver Supabase Auth's phone OTPs over WhatsApp first, SMS fallback, for Indian numbers — at MiniMoth's rate, with zero changes to your application code.
## How it fits together
Supabase Auth already owns the entire OTP lifecycle — it generates the code, verifies it, and issues the session. MiniMoth becomes the delivery backend Supabase calls out to via its Send SMS Hook. Your own application code doesn't change:
```typescript
// This doesn't change when you add MiniMoth
const { error } = await supabase.auth.signInWithOtp({ phone })
```
MiniMoth never sees the Supabase session or the verification step — only the outbound SMS-hook call Supabase makes when it needs an OTP delivered. This is a delivery-backend integration, not an auth provider swap. One consequence: this integration never returns a MiniMoth
identity_id — Supabase already owns that user's identity in
auth.users, so use its id instead.
## Setup
Wire up test mode first — it confirms the connection without sending a real message or touching your balance.
1. **MiniMoth dashboard** — open your project, find the Supabase Hook card, and click `Enable Supabase Hook`. Copy the `Test` Hook URL and Secret.
2. **Supabase dashboard** — go to `Authentication → Hooks → Send SMS hook`, choose `HTTPS`, paste the test URL and secret, and save.
3. **Verify the connection** — trigger `signInWithOtp()` from your app, then check Supabase's own Auth logs for a successful call. Test mode sends no real message, so this is the only way to confirm the hook is wired correctly — MiniMoth's own Playground can't substitute for it here, since Supabase owns the request.
No app to test from yet? Paste this into a scratch file:
```javascript
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
// 1. Send — this is what calls MiniMoth's hook
await supabase.auth.signInWithOtp({ phone: '+919876543210' })
// 2. Verify — the code the user received
await supabase.auth.verifyOtp({ phone: '+919876543210', token: '123456', type: 'sms' })
```
4. **Go live** — repeat steps 1–2 with the `Live` Hook URL and Secret. This is also the only way to see a real OTP actually delivered, since test mode by design never sends one.
Billing
Real OTPs sent via the live hook are billed to your wallet balance at send time, at the same default rate (₹0.35) as calling the API directly. Test-mode calls cost nothing and consume no credits.
---
## Auth0
Deliver Auth0's phone OTPs over WhatsApp first, SMS fallback, for Indian numbers — at MiniMoth's rate, via a small Action you paste into your Auth0 tenant.
Before you enable this
This is tenant-wide, not opt-in per notification type — it takes over every Auth0 phone notification, including
account-security SMS like blocked-account and password-change. MiniMoth's Action only delivers OTPs; anything else is
rejected, not delivered. Make sure you have another channel for those alerts first.
## How it fits together
Auth0 already owns the entire OTP lifecycle — it generates the code, verifies it, and issues the session. MiniMoth becomes the delivery backend an Auth0 custom phone provider Action calls out to. Unlike some integrations, Auth0 doesn't fix the shape of that call — the Action below is MiniMoth's own code for you to paste in as-is.
MiniMoth never sees the Auth0 session or the verification step — only the notification Auth0's Action forwards when it needs an OTP delivered. One consequence: this integration never returns a MiniMoth
identity_id — Auth0 already owns that user's identity as its own
user_id, so use that instead.
Every branch of this Action explicitly tells Auth0 whether to retry or give up — nothing is ever silently dropped without Auth0 knowing about it:
```javascript
exports.onExecuteCustomPhoneProvider = async (event, api) => {
if (!event.notification.message_type.startsWith('otp')) {
api.notification.drop(`MiniMoth only delivers OTP messages, not '${event.notification.message_type}'`)
return
}
if (event.notification.delivery_method !== 'text') {
api.notification.drop('MiniMoth does not support voice delivery')
return
}
let response
try {
response = await fetch(event.secrets.SERVICE_URL, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${event.secrets.TOKEN}` },
body: JSON.stringify({ phone: event.notification.recipient, code: event.notification.code }),
})
} catch (err) {
api.notification.retry(`Network error calling MiniMoth: ${err.message}`)
return
}
if (response.status >= 500) {
api.notification.retry(`MiniMoth returned ${response.status}`)
return
}
if (!response.ok) {
const body = await response.text()
api.notification.drop(`MiniMoth rejected the request (${response.status}): ${body}`)
}
}
```
## Setup
Wire up test mode first — it confirms the connection without sending a real message or touching your balance.
1. **MiniMoth dashboard** — open your project, find the Auth0 Hook card, and click `Enable Auth0 Hook`. Copy the `Test` Hook URL and Token, and copy the Action snippet shown in the card.
2. **Auth0 dashboard** — go to `Actions → Library`, create a new Custom action bound to the `Send Phone Message` (custom phone provider) trigger, paste in the Action code, then add two secrets on the Action: `SERVICE_URL` (the Test Hook URL you copied) and `TOKEN` (the Test Token). Deploy the Action.
3. **Verify the connection** — trigger a phone OTP flow from your app (or Auth0's own testing tools), then check your Auth0 tenant's Action logs for a successful call. Test mode sends no real message, so this is the only way to confirm the hook is wired correctly.
4. **Go live** — swap the Action's `SERVICE_URL`/`TOKEN` secrets for the `Live` Hook URL and Token from the same card. This is also the only way to see a real OTP actually delivered, since test mode by design never sends one.
## Setting up via Terraform
Managing your Auth0 tenant as code? The steps above translate to an `auth0_action`, wired to the custom phone provider trigger via `auth0_trigger_action`, with `auth0_phone_provider` enabling it:
```terraform
resource "auth0_action" "minimoth_custom_phone_provider" {
name = "MiniMoth Custom Phone Provider"
runtime = "node22"
deploy = true
code = <<-EOT
exports.onExecuteCustomPhoneProvider = async (event, api) => {
if (!event.notification.message_type.startsWith('otp')) {
api.notification.drop(`MiniMoth only delivers OTP messages, not '${event.notification.message_type}'`)
return
}
if (event.notification.delivery_method !== 'text') {
api.notification.drop('MiniMoth does not support voice delivery')
return
}
let response
try {
response = await fetch(event.secrets.SERVICE_URL, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${event.secrets.TOKEN}` },
body: JSON.stringify({ phone: event.notification.recipient, code: event.notification.code }),
})
} catch (err) {
api.notification.retry(`Network error calling MiniMoth: ${err.message}`)
return
}
if (response.status >= 500) {
api.notification.retry(`MiniMoth returned ${response.status}`)
return
}
if (!response.ok) {
const body = await response.text()
api.notification.drop(`MiniMoth rejected the request (${response.status}): ${body}`)
}
};
EOT
supported_triggers {
id = "custom-phone-provider"
version = "v1"
}
}
resource "auth0_trigger_action" "minimoth_custom_phone_provider" {
trigger = "custom-phone-provider"
actions {
id = auth0_action.minimoth_custom_phone_provider.id
display_name = auth0_action.minimoth_custom_phone_provider.name
}
depends_on = [
auth0_action.minimoth_custom_phone_provider
]
}
resource "auth0_phone_provider" "minimoth_custom_phone_provider" {
depends_on = [auth0_trigger_action.minimoth_custom_phone_provider]
name = "custom"
disabled = false
configuration {
delivery_methods = ["text"]
}
credentials {}
}
```
This resource doesn't manage the Action's `SERVICE_URL`/`TOKEN` secrets — set those separately, either in the Auth0 dashboard or via your own Terraform handling of Action secrets. Also note that Auth0 requires deleting any custom phone provider already configured manually via the dashboard before one can be created through Terraform.
Testing with real testers
Testing your integration before going live? MiniMoth's Test Group preview feature works with the Auth0 test hook too — up to 5 real testers can see OTPs sent through your Test Hook without a real message going out.
Requirements
Your Auth0 connection's OTP length must stay at Auth0's default of 6 digits — MiniMoth's SMS delivery uses a DLT-approved template that can't accommodate other lengths. A mismatched configuration means every send fails.
Billing
Real OTPs sent via the live hook are billed to your wallet balance. Test-mode calls cost nothing and consume no credits.
---
## Test Group
Give up to 5 testers a link where they can see the OTP your [Supabase](/docs/integrations/supabase) or [Auth0](/docs/integrations/auth0) Test Hook generates — without a real SMS or WhatsApp message ever going out, and without anyone digging through your provider's own logs to find the code.
## How it fits together
This only works together with the Supabase or Auth0 integration's `Test` Hook — it isn't a general-purpose way to test the OTP API. When your staging app triggers a phone OTP against a number wired to your Test Hook, Supabase or Auth0 sends the code to MiniMoth as usual, but test mode never actually delivers it. Test Group gives a human a place to see that code anyway: a tester who's part of the group and has logged in sees the code appear on their screen (or as a push notification) within seconds, and relays it to whoever needs to finish verification.
Test Group only ever captures OTPs sent through your project's Test Hook. Live/production OTPs — from the Live Hook, the direct API, or anywhere else — are never captured or exposed here.
## Setup
1. **Wire up your Supabase or Auth0 Test Hook first** — Test Group has nothing to show until your app is actually triggering OTPs through it. See the [Supabase](/docs/integrations/supabase) or [Auth0](/docs/integrations/auth0) integration guide.
2. **MiniMoth dashboard** — switch the project to `Test Mode`, open the `Test Group` card, and click `Set up a test group`.
3. **Add testers** — up to 5 real, reachable phone numbers. Each one has to log in with an actual OTP before they can see anything, so a number you don't control won't get you access.
4. **Share the invite link** — copy it from the same card and send it to your testers. You can rotate it any time to revoke the old link immediately; testers already logged in aren't affected.
5. **Tester logs in** — they open the link, enter their phone number, and verify a one-time code (this part is a real OTP, sent over MiniMoth's normal infrastructure). That signs them into a 10-day session, independent of your project's own session settings — logging out is the only thing that ends it early.
6. **Trigger a Test Hook OTP** — from your staging app, for one of the allowlisted numbers:
```typescript
// Trigger this against a phone number in your test group,
// with your Test Hook wired up in Supabase Auth
const { error } = await supabase.auth.signInWithOtp({ phone })
```
The code shows up on the tester's screen within about 10 seconds. They can also install the page as an app and enable notifications, so it arrives as a push instead of requiring the tab to stay open.
Billing
A tester's one-time login OTP is billed to your wallet like any other OTP, that's real delivery, so it's real cost. The OTPs they preview afterwards cost nothing: those come from your Test Hook, which never sends a real message in the first place.
Good to know
- Up to 5 numbers per project.
- A captured OTP is only held for 5 minutes — if nobody's watching the screen when it comes through, it's gone by the time they check.
- Removing a tester's number immediately logs out their session, if they had one.