Limited registrations open — claim your spot
minimoth

Node.js SDK

Official SDK for Node.js, Express, and Next.js. Handles phone normalisation, token management, local JWT validation, and auto-refresh.

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

npm install @minimoth/sdk-node

Requires Node.js 18+.

Setup

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.

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

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.

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

// lib/minimoth.ts
import { MiniMoth } from '@minimoth/sdk-node'

export const mm = new MiniMoth({
  apiKey: process.env.MINIMOTH_API_KEY!,
})

Send OTP

// 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

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

// 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

// 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

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

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 for the full list.

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.

Try the full OTP flow interactively in the sandbox — no SMS sent, no credits consumed. The same Playground also has a live test to send a real OTP to your phone (uses your credits).

Test in Playground →