Official SDK for Node.js, Express, and Next.js. Handles phone normalisation, token management, local JWT validation, and auto-refresh. Published as <a href="https://www.npmjs.com/package/@minimoth/sdk-node" target="_blank" rel="noopener noreferrer">@minimoth/sdk-node</a> on npm.

<div className="mb-10">
  <Callout variant="accent">
    <p><span className="text-saffron font-medium">Unlike raw API calls, you never write refresh logic.</span> The SDK's <code className="font-mono text-xs text-ink/80">session.validate()</code> automatically detects expired access tokens and refreshes them using the stored refresh token — returning the new tokens in <code className="font-mono text-xs text-ink/80">session.newTokens</code> so you can forward them to the client. One call, no manual token rotation.</p>
  </Callout>
</div>

## Installation

```bash
npm install @minimoth/sdk-node
```

Requires Node.js 18+. View the package on <a href="https://www.npmjs.com/package/@minimoth/sdk-node" target="_blank" rel="noopener noreferrer">npm</a>.

## 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 <a href="/docs/international-whatsapp" className="text-saffron hover:underline">International WhatsApp OTP</a> 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.
```

<h2 id="identity" className="font-medium text-xl mt-14 mb-3 scroll-mt-8">Identity</h2>

Every successful `verify()` recognises the same phone number across logins — see <a href="/docs/identity" className="text-saffron hover:underline">Identity</a> 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)
```

<h2 id="express" className="font-medium text-xl mt-14 mb-3 scroll-mt-8">Express — complete example</h2>

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 })
})
```

<div className="mt-4 bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-xs text-ink/50 leading-relaxed space-y-2">
  <p><b className="text-ink/70">How auto-refresh works here:</b> when <code className="font-mono">safeValidate</code> finds an expired access token, it silently calls <code className="font-mono">session/refresh</code> using the stored refresh token and returns the new tokens in <code className="font-mono">session.newTokens</code>. Your middleware forwards them via <code className="font-mono">X-New-Access-Token</code> header and a new cookie. The user never sees a logout.</p>
  <p><b className="text-ink/70">Client must handle <code className="font-mono">X-New-Access-Token</code>:</b> 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 <a href="/docs/recipes#refresh-loop" className="text-saffron hover:underline">Recipe 3</a> for the client-side pattern.</p>
</div>

<h2 id="nextjs" className="font-medium text-xl mt-14 mb-3 scroll-mt-8">Next.js App Router — complete example</h2>

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
}
```

<h2 id="validatemode" className="font-medium text-xl mt-14 mb-3 scroll-mt-8">validateMode</h2>

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
  },
})
```

<h2 id="redis" className="font-medium text-xl mt-14 mb-3 scroll-mt-8">Custom session store (Redis)</h2>

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' })
  }
}
```

<div className="mt-10 border border-white/10 rounded-lg px-5 py-4 text-sm text-ink/60 leading-relaxed">
  <p><span className="text-ink font-medium">Not using Node.js?</span> Use the <a href="/docs/quickstart" className="text-saffron hover:underline">REST API directly</a> — MiniMoth is a plain HTTP API and works with any language or framework.</p>
</div>

---

Full documentation index: https://minimoth.dev/llms.txt
