OTP Mailer
A lightweight, self-hosted email verification service. Send a numeric OTP the user types in, or a one-click magic link — two completely independent flows backed by a single Express API.
It handles generation, delivery, expiry, and verification so your backend only needs a single API call to start a flow. Built-in rate limiting protects against abuse, and webhook callbacks let you react instantly when a magic link is clicked.
/api prefix. Set Content-Type: application/json on every POST request.
Recommended reading order:
- Read Verification Flows to pick the right mode
- Follow Quick Start to send your first email
- Use the API Reference endpoints with live Try It panels
- Configure Environment Variables for production
Verification Flows
Two independent flows. Choose based on your UX preference — they never mix. A code email never contains a button; a link email never contains a code.
Code Flow
POST /api/otp/generatePOST /api/otp/verifyMagic Link Flow
POST /api/otp/send-linkQuick Start
Clone, configure, run — three commands to your first verification email.
1 — Clone and install
git clone https://github.com/pooraddyy/otp-mailer.git cd otp-mailer npm install
2 — Configure environment
cp sample.env .env
# Fill in MONGODB_URI, GMAIL_USER, GMAIL_PASS
3 — Run locally
npm run dev
# Listening on http://localhost:5001
Send your first email
# Code flow curl -X POST http://localhost:5001/api/otp/generate -H "Content-Type: application/json" -d '{"email":"you@example.com","organization":"Acme"}' # Magic link flow curl -X POST http://localhost:5001/api/otp/send-link -H "Content-Type: application/json" -d '{"email":"you@example.com","organization":"Acme"}'
Send OTP Code
Generates a new OTP and sends it to the recipient's inbox as a numeric or alphanumeric code.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Recipient email address | |
| type | numeric | alphanumeric | optional | OTP character set. Default: numeric |
| organization | string | optional | Sender brand name in the email. Default: Verification |
| subject | string | optional | Email subject line. Default: Your verification code |
Response — 200
{
"message": "Verification code sent to your email",
"mode": "code",
"requestId": "8f0a2c...",
"validityMinutes": 5
}
Send Magic Link
Sends a one-click "Verify my email" button. Optionally pass a webhookUrl to receive an instant POST callback when the user clicks.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
| string | required | Recipient email address | |
| organization | string | optional | Sender brand name. Default: Verification |
| subject | string | optional | Email subject. Default: Verify your email |
| webhookUrl | string | optional | Your endpoint to receive a POST when the user verifies. See Webhooks |
Response — 200
{
"message": "Verification link sent to your email",
"mode": "link",
"requestId": "b7e3...",
"validityMinutes": 5,
"webhookRegistered": true
}
Verify by Code
Validates the OTP code the user typed. Single-use — consumed on first success.
Request Body
{ "email": "user@example.com", "otp": "123456" }
Response — 200
{ "message": "Email verified successfully", "verified": true }
Verify by Link
Browser-facing endpoint embedded in magic link emails. Validates the token, marks the record as verified, and renders a styled confirmation page. You do not call this from your backend — the user's email client does.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | required | The single-use base64url token from the verification email |
Check Status
Returns the verification status of a request. Use this to poll in the magic link flow, or as a fallback if your webhook misses a delivery.
Response — 200
{
"found": true,
"verified": false,
"expired": false,
"email": "user@example.com"
}
| Field | Meaning |
|---|---|
| found | A request with this ID exists in the database |
| verified | The user has clicked the link or entered the correct code |
| expired | The validity window has elapsed |
Rate Limiting
Every request to /api/otp/generate and /api/otp/send-link goes through an in-memory rate limiter. Both IP address and email address are tracked independently.
429. Configure with RATE_LIMIT_MAX_REQUESTS and RATE_LIMIT_WINDOW_MINUTES.
429 Response
{
"error": "Too many requests. Try again in 12 minutes.",
"retryAfterSeconds": 720
}
Webhooks
Pass a webhookUrl to POST /api/otp/send-link and the service fires a POST to your server the instant the user clicks the verify button.
Payload delivered to your server
{
"event": "email.verified",
"email": "user@example.com",
"requestId": "b7e3a2f1...",
"verifiedAt": "2026-05-26T10:30:00.000Z"
}
- Fire-and-forget — the confirmation page does not wait for your server to respond
- 5-second timeout. Slow or unreachable URLs are aborted and logged server-side
- Only
http://andhttps://URLs are accepted - No automatic retries — use
GET /api/otp/status/:requestIdas a reliable fallback
Environment Variables
All variables live in sample.env. Copy it to .env for local development. For Vercel, add them under Settings → Environment Variables.
| Variable | Required | Default | Description |
|---|---|---|---|
| MONGODB_URI | required | — | MongoDB connection string |
| GMAIL_USER | required | — | Sender Gmail address. Must have an App Password enabled |
| GMAIL_PASS | required | — | 16-character Gmail App Password |
| OTP_VALIDITY_PERIOD_MINUTES | required | 5 | How long a code or link token stays valid |
| OTP_SIZE | required | 6 | Number of characters in the OTP |
| APP_BASE_URL | optional | auto | Public URL for magic links. Falls back to $VERCEL_URL then request host |
| RATE_LIMIT_MAX_REQUESTS | optional | 5 | Max requests per window per IP or email |
| RATE_LIMIT_WINDOW_MINUTES | optional | 15 | Length of the rate-limit rolling window in minutes |
| BLOCK_KEYWORDS_RULES | optional | — | Comma-separated keywords that auto-reject requests |
| ALLOWED_DOMAINS | optional | — | Comma-separated domain allow-list. Empty = all accepted |
| DOCS_API_URL | optional | auto | Base URL used by live Try It panels on the docs page |
| PORT | local only | 5001 | Dev server port. Vercel ignores this |
Error Reference
All errors return { "error": "message" }.
| Status | Error | Cause |
|---|---|---|
| 400 | Invalid email | Missing or malformed email field |
| 400 | Spam detected | IP or email is blocklisted, or body contains a blocked keyword |
| 400 | Maximum attempts reached | Same email hit the 3-attempt limit within the validity window |
| 400 | Invalid OTP | Code is wrong, expired, or already used |
| 400 | Verification link is invalid or has expired | No matching token found, or token has expired |
| 429 | Too many requests | Rate limit exceeded. Check retryAfterSeconds in the response |
| 500 | Internal server error | Unexpected database or runtime error |
OTP Mailer · MIT License · GitHub