OTP Emails

Send One-Time Password (OTP) verification emails with Metigan. Support for custom templates, automatic rate limiting, and priority delivery for authentication flows.

Overview

OTP emails are critical for user authentication, account verification, and password recovery. Metigan provides a dedicated POST /api/otp endpoint optimized for OTP delivery with:

  • Priority delivery - OTP emails are sent with highest priority
  • Rate limiting - Built-in protection against abuse
  • Template support - Use custom templates or default design
  • Variable substitution - Dynamic code and app name replacement
  • Idempotency - Prevent duplicate sends with idempotency keys

Quick Start

Send your first OTP email in seconds using the default template:

send-otp-basic.tsTypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import Metigan from 'metigan';

const metigan = new Metigan({
  apiKey: process.env.METIGAN_API_KEY!
});

// Send OTP with default template
const result = await metigan.otp.send({
  to: 'user@example.com',
  from: 'auth@yourdomain.com',
  code: '123456',
  appName: 'MyApp',
  expiresInMinutes: 5
});

console.log(result);
// { success: true, queued: true, emailId: "otp-...", trackingId: "otp-..." }

OTP Without Template (Default)

When you don't specify a templateId, Metigan uses a clean, professional default template that displays your OTP code prominently.

otp-nodejs.jsJavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const Metigan = require('metigan');

const metigan = new Metigan({
  apiKey: process.env.METIGAN_API_KEY
});

async function sendVerificationCode(userEmail) {
  // Generate a random 6-digit code
  const code = Math.floor(100000 + Math.random() * 900000).toString();
  
  const result = await metigan.otp.send({
    to: userEmail,
    from: 'noreply@myapp.com',
    code: code,
    appName: 'MyApp',
    subject: 'Your verification code', // Optional custom subject
    expiresInMinutes: 10
  });

  if (result.success) {
    console.log('OTP sent! Tracking ID:', result.trackingId);
    // Store code in your database/cache for verification
    return { success: true, code }; // Don't return code in production!
  } else {
    console.error('Failed to send OTP:', result.error);
    return { success: false };
  }
}

sendVerificationCode('user@example.com');

Default Template Preview

The default OTP template displays:

  • Title: "Seu código de verificação" (Your verification code)
  • App name context message
  • Large, spaced OTP code for easy reading
  • Expiration notice (if provided)
  • Security disclaimer

OTP With Custom Template

Use your own branded templates for OTP emails. Templates support dynamic variables that are automatically replaced when sending.

Step 1: Create an OTP Template

Create a template with OTP-specific variables. Supported variables:

VariableDescriptionExample
{{code}}The OTP code123456
{{appName}}Your application nameMyApp
{{expiresInMinutes}}Code expiration time10
create-otp-template.tsTypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import Metigan from 'metigan';

const metigan = new Metigan({
  apiKey: process.env.METIGAN_API_KEY!
});

// Create a branded OTP template
const template = await metigan.templates.create({
  name: 'Branded OTP Template',
  subject: '{{appName}} - Verification Code',
  content: `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
      </head>
      <body style="margin: 0; padding: 0; font-family: 'Segoe UI', Arial, sans-serif; background-color: #f5f5f5;">
        <table width="100%" cellpadding="0" cellspacing="0" style="max-width: 600px; margin: 0 auto; background: white;">
          <tr>
            <td style="padding: 40px 30px; text-align: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
              <h1 style="margin: 0; color: white; font-size: 28px;">{{appName}}</h1>
            </td>
          </tr>
          <tr>
            <td style="padding: 40px 30px;">
              <h2 style="margin: 0 0 20px 0; color: #333; font-size: 24px;">Verification Code</h2>
              <p style="margin: 0 0 30px 0; color: #666; font-size: 16px; line-height: 1.6;">
                Enter the code below to verify your identity:
              </p>
              
              <!-- OTP Code Box -->
              <div style="background: #f8f9fa; border: 2px dashed #667eea; border-radius: 12px; padding: 30px; text-align: center; margin: 30px 0;">
                <span style="font-size: 42px; font-weight: bold; letter-spacing: 12px; color: #333; font-family: 'Courier New', monospace;">
                  {{code}}
                </span>
              </div>
              
              <p style="margin: 20px 0; color: #999; font-size: 14px; text-align: center;">
                ⏱️ This code expires in <strong>{{expiresInMinutes}} minutes</strong>
              </p>
              
              <hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">
              
              <p style="margin: 0; color: #999; font-size: 13px; line-height: 1.5;">
                If you didn't request this code, please ignore this email or contact our support team.
              </p>
            </td>
          </tr>
          <tr>
            <td style="padding: 20px 30px; background: #f8f9fa; text-align: center;">
              <p style="margin: 0; color: #999; font-size: 12px;">
                © {{appName}} - Sent via Metigan
              </p>
            </td>
          </tr>
        </table>
      </body>
    </html>
  `
});

console.log('Template ID:', template.id);
// Save this ID for sending OTPs

Step 2: Send OTP Using Template

Reference your template by ID when sending. Variables will be automatically replaced:

send-otp-with-template.tsTypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import Metigan from 'metigan';

const metigan = new Metigan({
  apiKey: process.env.METIGAN_API_KEY!
});

const BRANDED_OTP_TEMPLATE_ID = 'your_template_id_here';

async function sendBrandedOtp(userEmail: string) {
  const code = crypto.randomInt(100000, 999999).toString();
  
  const result = await metigan.otp.send({
    to: userEmail,
    from: 'security@myapp.com',
    code: code,
    appName: 'MyApp',
    expiresInMinutes: 10,
    templateId: BRANDED_OTP_TEMPLATE_ID // 🎨 Use custom template
  });

  if (result.success) {
    console.log('✅ Branded OTP sent!');
    console.log('Tracking ID:', result.trackingId);
    return { success: true, code };
  } else {
    console.error('❌ Failed:', result.error);
    return { success: false };
  }
}

// Send branded OTP
sendBrandedOtp('user@example.com');
Template Best Practices
  • • Make the OTP code large and easy to read (42px+ font size)
  • • Use letter-spacing for better digit separation
  • • Include expiration time prominently
  • • Add security disclaimers
  • • Test on mobile devices - most OTPs are read on phones

API Reference

POST /api/otp - Send an OTP email

ParameterTypeRequiredDescription
tostringYesRecipient email address (also accepts email)
fromstringYesSender email address (must be verified domain)
codestringYesThe OTP code (max 12 characters)
appNamestringNoYour application name (default: "Metigan")
subjectstringNoCustom subject line (default: "[appName] - Seu código de verificação")
expiresInMinutesnumberNoCode expiration time to display in email
templateIdstringNoCustom template ID to use instead of default
idempotencyKeystringNoUnique key to prevent duplicate sends

Rate Limits

OTP endpoints have built-in rate limiting to prevent abuse. Limits are per recipient, per user:

PlanOTPs per recipientTime window
Free35 minutes
Pro55 minutes
Growth65 minutes
Business85 minutes
Rate Limit Exceeded

If you exceed the rate limit, you'll receive a 429 Too Many Requests response. Implement exponential backoff and show users a "wait before requesting new code" message.

Security Best Practices

✅ Do

  • • Use 6-8 digit numeric codes
  • • Set short expiration (5-10 minutes)
  • • Store codes hashed in your database
  • • Invalidate code after successful verification
  • • Use idempotency keys for retries
  • • Log verification attempts

❌ Don't

  • • Never return the OTP code in API responses
  • • Don't use predictable codes
  • • Avoid very long expiration times
  • • Don't allow unlimited verification attempts
  • • Never log actual OTP codes
  • • Don't reuse the same code

Complete Example

Here's a full implementation of an OTP verification flow:

otp-verification-flow.tsTypeScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import Metigan from 'metigan';
import crypto from 'crypto';
import Redis from 'ioredis';

const metigan = new Metigan({
  apiKey: process.env.METIGAN_API_KEY!
});

const redis = new Redis(process.env.REDIS_URL!);
const OTP_EXPIRY_SECONDS = 600; // 10 minutes
const MAX_VERIFY_ATTEMPTS = 5;

// Generate secure OTP
function generateOtp(): string {
  return crypto.randomInt(100000, 999999).toString();
}

// Hash OTP for storage
function hashOtp(otp: string): string {
  return crypto.createHash('sha256').update(otp).digest('hex');
}

// Send OTP
export async function sendOtp(email: string, purpose: 'login' | 'register' | 'reset') {
  const code = generateOtp();
  const hashedCode = hashOtp(code);
  const key = `otp:${purpose}:${email}`;
  const attemptsKey = `otp:attempts:${email}`;
  
  // Store hashed OTP with expiry
  await redis.setex(key, OTP_EXPIRY_SECONDS, hashedCode);
  await redis.del(attemptsKey); // Reset attempts on new OTP
  
  // Send via Metigan
  const result = await metigan.otp.send({
    to: email,
    from: 'security@myapp.com',
    code: code,
    appName: 'MyApp',
    expiresInMinutes: Math.floor(OTP_EXPIRY_SECONDS / 60),
    idempotencyKey: `${purpose}-${email}-${Date.now()}`
  });
  
  return {
    success: result.success,
    message: result.success 
      ? 'Verification code sent to your email' 
      : 'Failed to send verification code'
  };
}

// Verify OTP
export async function verifyOtp(email: string, code: string, purpose: string) {
  const key = `otp:${purpose}:${email}`;
  const attemptsKey = `otp:attempts:${email}`;
  
  // Check attempts
  const attempts = await redis.incr(attemptsKey);
  await redis.expire(attemptsKey, OTP_EXPIRY_SECONDS);
  
  if (attempts > MAX_VERIFY_ATTEMPTS) {
    await redis.del(key); // Invalidate OTP after too many attempts
    return { success: false, error: 'Too many attempts. Request a new code.' };
  }
  
  // Get stored hash
  const storedHash = await redis.get(key);
  if (!storedHash) {
    return { success: false, error: 'Code expired or not found' };
  }
  
  // Verify
  const inputHash = hashOtp(code);
  if (inputHash !== storedHash) {
    return { 
      success: false, 
      error: 'Invalid code',
      attemptsRemaining: MAX_VERIFY_ATTEMPTS - attempts 
    };
  }
  
  // Success - invalidate OTP
  await redis.del(key);
  await redis.del(attemptsKey);
  
  return { success: true, message: 'Verification successful' };
}

// Usage in Express route
app.post('/api/auth/send-otp', async (req, res) => {
  const { email, purpose } = req.body;
  const result = await sendOtp(email, purpose);
  res.json(result);
});

app.post('/api/auth/verify-otp', async (req, res) => {
  const { email, code, purpose } = req.body;
  const result = await verifyOtp(email, code, purpose);
  res.json(result);
});
Next Steps

Learn more about Transactional Emails or explore the Templates API for creating custom email designs.