← Back to blog

Send OTP Emails from Flutter (2026) via a Backend HTTP API

Build Flutter signup/login OTP with a Dart shelf or cloud-function BFF: Random.secure + HMAC, Idempotency-Key POST /emails—never put the sending_access key in the mobile client; Rovela delivers only.

Updated November 4, 2026
Send OTP Emails from Flutter (2026) via a Backend HTTP API

Why Flutter needs a backend for email OTP

Flutter is excellent for signup and login UI—text fields, focus nodes, and a six-digit input that feels native on iOS and Android. It is a poor place to hold a Rovela sending_access key. Anything you ship in the APK/IPA (or in a Dart isolate that talks to the internet from the device) can be extracted. Putting ROVELA_API_KEY in --dart-define, a .env asset, or Firebase Remote Config still leaves a secret on every phone.

What you need is the recipe in Email verification codes: a server (Dart shelf, Cloud Functions, Cloud Run, or any BFF) mints a six-digit code with a CSPRNG, stores an HMAC of it (never the digits), sets expiry and attempt limits, then POSTs https://api.rovela.dev/emails with both html and text and a stable Idempotency-Key. The Flutter app only calls your /auth/otp/send and /auth/otp/verify over HTTPS. Rovela queues, DKIM-signs, retries, and records events. It does not generate codes, check them, or create sessions.

Rovela does not provide an OTP feature. Your backend owns cryptography, the store entry, resend throttling, redeem logic, and the session / JWT. Rovela only delivers the mail.

This article is Flutter + Dart focused: a shelf (or Cloud Function) OTP API that talks to Rovela, plus a minimal Flutter client that never sees the sending key. It is not Sending email with Dart (generic Dart/http send, no OTP store), and not the web/Node OTP siblings (Express, Hono, Go). Product choice between digits and URLs: Email verification: code vs link. Language-agnostic OTP: Sending OTP codes by email.

What you own vs what Rovela sends

Your server owns:

  • CSPRNG → six digits, zero-padded (Random.secure() in Dart—never Random())
  • HMAC-SHA256 of email + ":" + code with a server pepper (CODE_PEPPER) via package:crypto
  • Expiry (docs example: ~10 minutes), attempt counter, and “one send per minute” resend gate
  • Upsert by normalized email so a new send invalidates the previous code (memory map, Redis, or SQL)
  • Constant-time compare on redeem; delete / invalidate on success
  • Rate limits on /auth/otp/send and /auth/otp/verify
  • Issuing the session cookie / JWT after a successful verify

Your Flutter app owns:

  • Collecting email + code, calling your BFF, storing the session securely (flutter_secure_storage or platform keychain)
  • UX: resend cooldown timer, error copy, offline handling

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (server env only—never in the Flutter binary, never in dart-define for release builds that ship to stores)
  • Queueing, signing, retries, bounce/complaint handling

A 200 from the send endpoint means accepted and queued, not “in the inbox.”

Architecture: phone → your API → Rovela

Flutter (UI)
   │  HTTPS JSON  (no Rovela key)
   ▼
Dart shelf / Cloud Function / Cloud Run  (ROVELA_API_KEY + CODE_PEPPER)
   │  POST /emails + Idempotency-Key
   ▼
api.rovela.dev  →  mailbox

Prefer a thin Dart BFF if the rest of your stack is already Dart, so OTP crypto and the mobile API stay in one language. Node, Go, or Python BFFs work the same—the Flutter side does not change. Do not call Rovela from package:http inside the Flutter project for production OTP.

Env on the server (never in the app)

# server only — Cloud Run / Function / VM secrets
export ROVELA_API_KEY="re_..."          # sending_access
export CODE_PEPPER="long-random-pepper"
export OTP_FROM="Acme <security@mail.acme.com>"
export OTP_TTL_MINUTES=10
export OTP_RESEND_COOLDOWN_SEC=60
export OTP_MAX_ATTEMPTS=5

Wire keys like any other server secret (Authentication, Quick Start). A sending_access key is enough—and preferred—for production OTP senders.

Dart server: mint, HMAC, POST /emails (shelf sketch)

// pubspec.yaml (server): shelf, shelf_router, http, crypto, uuid
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'dart:typed_data';

import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';
import 'package:uuid/uuid.dart';

final _pepper = utf8.encode(Platform.environment['CODE_PEPPER']!);
final _apiKey = Platform.environment['ROVELA_API_KEY']!;
final _from = Platform.environment['OTP_FROM'] ?? 'Acme <security@mail.acme.com>';
final _ttl = Duration(minutes: int.parse(Platform.environment['OTP_TTL_MINUTES'] ?? '10'));
final _cooldown = Duration(seconds: int.parse(Platform.environment['OTP_RESEND_COOLDOWN_SEC'] ?? '60'));
const _maxAttempts = 5;

String normalizeEmail(String raw) => raw.trim().toLowerCase();

String mintCode() {
  final n = Random.secure().nextInt(1000000); // CSPRNG — not Random()
  return n.toString().padLeft(6, '0');
}

String hashCode(String email, String code) {
  final mac = Hmac(sha256, _pepper);
  return mac.convert(utf8.encode('$email:$code')).toString(); // hex
}

bool safeEqualHex(String a, String b) {
  final aa = utf8.encode(a);
  final bb = utf8.encode(b);
  if (aa.length != bb.length) return false;
  var diff = 0;
  for (var i = 0; i < aa.length; i++) {
    diff |= aa[i] ^ bb[i];
  }
  return diff == 0;
}

class Record {
  Record({required this.codeHash, required this.expiresAt, required this.sentAt, this.attempts = 0});
  String codeHash;
  int attempts;
  DateTime expiresAt;
  DateTime sentAt;
}

final store = <String, Record>{}; // demo only — use Redis/SQL for multi-instance

Future<Map<String, dynamic>> sendOtpEmail({
  required String to,
  required String code,
  required String idempotencyKey,
  required int ttlMinutes,
}) async {
  final body = {
    'from': _from,
    'to': to,
    'subject': 'Your Acme code: $code',
    'html':
        '<p>Your verification code is</p>'
        '<p style="font-size:28px;font-weight:600;letter-spacing:4px">$code</p>'
        '<p>It expires in $ttlMinutes minutes. If you did not ask for it, ignore this email.</p>',
    'text':
        'Your verification code is $code. It expires in $ttlMinutes minutes. '
        'If you did not ask for it, ignore this email.',
  };
  final res = await http.post(
    Uri.parse('https://api.rovela.dev/emails'),
    headers: {
      'Authorization': 'Bearer $_apiKey',
      'Content-Type': 'application/json',
      'Idempotency-Key': idempotencyKey,
      'Accept': 'application/json',
    },
    body: jsonEncode(body),
  ).timeout(const Duration(seconds: 15));
  if (res.statusCode >= 300) {
    throw HttpException('rovela send failed: ${res.statusCode} ${res.body}');
  }
  return jsonDecode(res.body) as Map<String, dynamic>;
}

Future<Response> handleSend(Request req) async {
  final payload = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
  final email = normalizeEmail('${payload['email'] ?? ''}');
  if (email.isEmpty || !email.contains('@')) {
    return Response(400, body: jsonEncode({'error': 'invalid_email'}));
  }
  final now = DateTime.now().toUtc();
  final existing = store[email];
  if (existing != null && now.difference(existing.sentAt) < _cooldown) {
    return Response(429, body: jsonEncode({'error': 'too_soon'}));
  }

  final code = mintCode();
  final codeHash = hashCode(email, code);
  store[email] = Record(
    codeHash: codeHash,
    expiresAt: now.add(_ttl),
    sentAt: now,
  );

  // Stable per logical send: hash prefix. New code ⇒ new key.
  final idem = 'otp-${codeHash.substring(0, 32)}';
  await sendOtpEmail(
    to: email,
    code: code,
    idempotencyKey: idem,
    ttlMinutes: _ttl.inMinutes,
  );

  // Uniform copy — product choice whether to reveal account existence
  return Response.ok(jsonEncode({
    'message': 'If that address can receive mail, a code is on its way.',
  }));
}

Future<Response> handleVerify(Request req) async {
  final payload = jsonDecode(await req.readAsString()) as Map<String, dynamic>;
  final email = normalizeEmail('${payload['email'] ?? ''}');
  final code = '${payload['code'] ?? ''}'.trim();
  if (code.length != 6 || int.tryParse(code) == null) {
    return Response(400, body: jsonEncode({'error': 'invalid'}));
  }
  final rec = store[email];
  final now = DateTime.now().toUtc();
  if (rec == null) return Response(400, body: jsonEncode({'error': 'invalid'}));
  if (now.isAfter(rec.expiresAt)) {
    store.remove(email);
    return Response(400, body: jsonEncode({'error': 'expired'}));
  }
  if (rec.attempts >= _maxAttempts) {
    return Response(429, body: jsonEncode({'error': 'locked'}));
  }
  if (!safeEqualHex(hashCode(email, code), rec.codeHash)) {
    rec.attempts++;
    return Response(400, body: jsonEncode({'error': 'invalid'}));
  }
  store.remove(email);
  // issue session / JWT here
  return Response.ok(jsonEncode({'ok': true, 'token': 'replace-with-jwt'}));
}

void main() async {
  final router = Router()
    ..post('/auth/otp/send', handleSend)
    ..post('/auth/otp/verify', handleVerify);
  final handler = const Pipeline()
      .addMiddleware(logRequests())
      // add IP rate-limit middleware in production
      .addHandler(router.call);
  final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
  print('OTP BFF on :${server.port}');
}

Suggested SQL shape (one active code per email), matching the verification-code example:

CREATE TABLE verification_codes (
  email      TEXT PRIMARY KEY,
  code_hash  TEXT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL,
  attempts   INT NOT NULL DEFAULT 0,
  sent_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Cloud Function note: the same sendOtpEmail / HMAC helpers work in a Dart or Node function triggered by HTTPS. Keep secrets in the function’s secret manager—not in Flutter.

Putting the code in the subject helps lock-screen previews—fine for many products; skip it on highly shared screens. Always send both html and text with the digits in the text part (POST /emails).

Flutter client: call your BFF only

// lib/auth/otp_api.dart — NO Rovela key here
import 'dart:convert';
import 'package:http/http.dart' as http;

class OtpApi {
  OtpApi(this.baseUrl); // e.g. https://api.yourapp.com
  final String baseUrl;

  Future<void> sendCode(String email) async {
    final res = await http.post(
      Uri.parse('$baseUrl/auth/otp/send'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email.trim()}),
    );
    if (res.statusCode == 429) {
      throw StateError('too_soon');
    }
    if (res.statusCode >= 300) {
      throw StateError('send_failed');
    }
  }

  Future<String> verifyCode(String email, String code) async {
    final res = await http.post(
      Uri.parse('$baseUrl/auth/otp/verify'),
      headers: {'Content-Type': 'application/json'},
      body: jsonEncode({'email': email.trim(), 'code': code.trim()}),
    );
    if (res.statusCode >= 300) {
      throw StateError('invalid_or_locked');
    }
    final map = jsonDecode(res.body) as Map<String, dynamic>;
    return map['token'] as String; // store securely
  }
}
// lib/screens/otp_login.dart (sketch)
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class OtpLoginScreen extends StatefulWidget {
  const OtpLoginScreen({super.key, required this.api});
  final OtpApi api;

  @override
  State<OtpLoginScreen> createState() => _OtpLoginScreenState();
}

class _OtpLoginScreenState extends State<OtpLoginScreen> {
  final emailCtrl = TextEditingController();
  final codeCtrl = TextEditingController();
  var step = 0; // 0 = email, 1 = code
  var busy = false;
  String? error;

  Future<void> _send() async {
    setState(() { busy = true; error = null; });
    try {
      await widget.api.sendCode(emailCtrl.text);
      setState(() => step = 1);
    } catch (e) {
      setState(() => error = e.toString());
    } finally {
      setState(() => busy = false);
    }
  }

  Future<void> _verify() async {
    setState(() { busy = true; error = null; });
    try {
      final token = await widget.api.verifyCode(emailCtrl.text, codeCtrl.text);
      // persist token with flutter_secure_storage; navigate home
      debugPrint('session ready (${token.length} chars)');
    } catch (e) {
      setState(() => error = e.toString());
    } finally {
      setState(() => busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Sign in')),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            if (step == 0) ...[
              TextField(
                controller: emailCtrl,
                keyboardType: TextInputType.emailAddress,
                autofillHints: const [AutofillHints.email],
                decoration: const InputDecoration(labelText: 'Email'),
              ),
              const SizedBox(height: 16),
              FilledButton(
                onPressed: busy ? null : _send,
                child: Text(busy ? 'Sending…' : 'Send code'),
              ),
            ] else ...[
              TextField(
                controller: codeCtrl,
                keyboardType: TextInputType.number,
                inputFormatters: [FilteringTextInputFormatter.digitsOnly],
                maxLength: 6,
                autofillHints: const [AutofillHints.oneTimeCode],
                decoration: const InputDecoration(labelText: '6-digit code'),
              ),
              const SizedBox(height: 16),
              FilledButton(
                onPressed: busy ? null : _verify,
                child: Text(busy ? 'Checking…' : 'Verify'),
              ),
              TextButton(
                onPressed: busy ? null : _send,
                child: const Text('Resend code'),
              ),
            ],
            if (error != null) Text(error!, style: const TextStyle(color: Colors.red)),
          ],
        ),
      ),
    );
  }
}

Use AutofillHints.oneTimeCode so iOS/Android can suggest codes from the notification. Still treat SMS Autofill libraries as optional—email OTP arrives in the Mail app, not as an SMS Retriever payload.

Idempotency-Key: retries without double codes

Workers, Cloud Functions retries, and flaky mobile networks all retry. Without Idempotency-Key, a timeout can deliver two codes that disagree in the user’s inbox.

  • Header ≤ 256 characters; Rovela remembers it for 24 hours and returns the original response on replay (API: emails).
  • Derive the key from the code hash (otp- + first 32 hex chars) so one logical mint maps to one queued message.
  • A deliberate resend after cooldown mints a new code → new hash → new key. Do not reuse the old key for a new code.
  • The Flutter client does not set this header on Rovela; your BFF does when it calls POST /emails.
curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer $ROVELA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: otp-smoke-flutter-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "you@example.com",
    "subject": "Your Acme code: 482913",
    "html": "<p>Your verification code is <b>482913</b>. Expires in 10 minutes.</p>",
    "text": "Your verification code is 482913. Expires in 10 minutes."
  }'

Why not call Rovela from the Flutter app

BFF → POST /emails (this post)Flutter → Rovela directly
sending_access keyServer secret managerExtractable from the binary
OTP mint + HMAC storeServerHard to share across devices safely
Rate limitsPer IP + per email on your edgeAttacker uses your key as a free relay
IdempotencyStable key per mint on serverEasy to get wrong on flaky mobile retries
Session issueAfter verify on serverStill need a backend for a real session

Generic Dart/http “send a welcome mail from a CLI or server” patterns live in Sending email with Dart. That post is transport-focused; this post is signup/login OTP with a mobile client boundary.

Deliverability notes for auth digits

OTP mail is short and sensitive. Prefer a verified subdomain such as security@mail.example.com, aligned SPF/DKIM/DMARC, and low HTML chrome. Codes in the body (and often the subject) do not need clickable links—so click-tracking rewrites matter less than for magic links—but open/click tracking settings still belong on a domain you control.

If codes land in spam, use the checklist in Why OTP and magic-link emails go to spam. For whether your product should use digits at all versus a confirmation URL, see code vs link.

OTP codes vs magic links for Flutter

Six-digit OTP (this post)Magic link
User actionType digits (good on mobile keyboards)Open URL (deep link / App Link)
Secret entropyLow → HMAC + pepper requiredHigh URL token → SHA-256 of token is usually enough
Docs shapeVerification codesPassword reset / link pattern
Flutter surfaceEmail + PIN fields → your BFFDeep link handler → your BFF
Click trackingOptional concernPrefer off on the security sending domain

Both call the same POST /emails from the server. Keep separate stores—do not reuse an OTP record as a magic-link token.

Checklist before you ship

  1. Random.secure() (or OS CSPRNG) + HMAC pepper on the server; never store raw codes; never use non-secure Random() for OTP.
  2. ~10 minute TTL, attempt cap, per-email resend cooldown, per-IP rate limit on the BFF.
  3. Upsert / overwrite invalidates the previous code; verify deletes on success.
  4. html and text; optional subject code for previews.
  5. ROVELA_API_KEY (sending_access) only on the server—not in Flutter, not in --dart-define for store builds.
  6. Idempotency-Key stable per logical send; new code → new key.
  7. Uniform messaging if you hide account existence.
  8. Flutter talks only to your API; use AutofillHints.oneTimeCode; store session tokens in secure storage.
  9. Verified from-domain; spam checklist if inbox placement slips.

Related reading

  1. Docs: Email verification codes · POST /emails · Quick Start · Authentication.
  2. Product-agnostic OTP: Sending OTP codes by email · Email OTP security.
  3. UX choice: Email verification: code vs link.
  4. Deliverability: Why OTP and magic-link emails go to spam.
  5. Framework siblings: Express OTP · Go OTP · Hono OTP · Django OTP.
  6. Dart transport only (not OTP): Sending email with Dart.

Bottom line: keep Flutter as the OTP UI, put mint/HMAC/verify and the Rovela sending_access key on a Dart shelf (or any) BFF, call POST /emails with an Idempotency-Key, and never ship the sending key inside the mobile client—Rovela delivers the mail; your app still owns auth.