← Back to blog

Send Email with Dart using the Rovela API

Learn how to send transactional email from Dart using the http package or Dio, with retry logic and idempotency keys, concurrent batch sending via Future.wait and a Semaphore, and webhook verification using the Shelf framework.

Updated November 3, 2026
Send Email with Dart using the Rovela API

This guide covers sending transactional email from Dart using the Rovela API. It includes the http package, Dio, retry logic with idempotency keys, concurrent batch sending with Future.wait, and webhook verification with the Shelf framework.

Prerequisites

Set your API key as an environment variable:

export ROVELA_API_KEY="re_..."

Add dependencies to pubspec.yaml:

dependencies:
  http: ^1.2.0

Send with the http package

import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;

Future<Map<String, dynamic>> sendEmail({
  required String to,
  required String subject,
  required String html,
}) async {
  final apiKey = Platform.environment['ROVELA_API_KEY']!;

  final response = await http.post(
    Uri.parse('https://api.rovela.dev/emails'),
    headers: {
      'Authorization': 'Bearer $apiKey',
      'Content-Type': 'application/json',
    },
    body: jsonEncode({
      'from': 'Acme <noreply@yourdomain.com>',
      'to': to,
      'subject': subject,
      'html': html,
    }),
  );

  if (response.statusCode >= 200 && response.statusCode < 300) {
    return jsonDecode(response.body) as Map<String, dynamic>;
  }
  throw Exception('HTTP ${response.statusCode}: ${response.body}');
}

Send with Dio

Dio offers interceptors and request cancellation, making it a good fit for larger projects.

Add dio to pubspec.yaml:

dependencies:
  dio: ^5.4.0
import 'dart:io';
import 'package:dio/dio.dart';

final dio = Dio(BaseOptions(
  baseUrl: 'https://api.rovela.dev',
  headers: {
    'Authorization': 'Bearer ${Platform.environment['ROVELA_API_KEY']}',
    'Content-Type': 'application/json',
  },
));

Future<Map<String, dynamic>> sendEmail({
  required String to,
  required String subject,
  required String html,
}) async {
  final response = await dio.post<Map<String, dynamic>>(
    '/emails',
    data: {
      'from': 'Acme <noreply@yourdomain.com>',
      'to': to,
      'subject': subject,
      'html': html,
    },
  );
  return response.data!;
}

Retry with idempotency key

Generate the idempotency key once before the retry loop and reuse it on every attempt. Only retry on HTTP 429 — other errors propagate immediately.

import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:http/http.dart' as http;

String _randomKey() {
  final rng = Random.secure();
  final bytes = List<int>.generate(16, (_) => rng.nextInt(256));
  return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
}

Future<Map<String, dynamic>> sendWithRetry(
  Map<String, dynamic> payload, {
  int maxRetries = 4,
}) async {
  final apiKey = Platform.environment['ROVELA_API_KEY']!;
  final idempotencyKey = _randomKey(); // generated once, reused on every retry

  for (var attempt = 0; attempt < maxRetries; attempt++) {
    final response = await http.post(
      Uri.parse('https://api.rovela.dev/emails'),
      headers: {
        'Authorization': 'Bearer $apiKey',
        'Content-Type': 'application/json',
        'Idempotency-Key': idempotencyKey,
      },
      body: jsonEncode(payload),
    );

    if (response.statusCode == 429) {
      final retryAfter = int.tryParse(response.headers['retry-after'] ?? '');
      final delay = retryAfter != null
          ? Duration(seconds: retryAfter)
          : Duration(milliseconds: (pow(2, attempt) * 1000).toInt());
      await Future.delayed(delay);
      continue;
    }

    if (response.statusCode >= 200 && response.statusCode < 300) {
      return jsonDecode(response.body) as Map<String, dynamic>;
    }
    throw Exception('HTTP ${response.statusCode}: ${response.body}');
  }
  throw Exception('Rate limit exceeded after $maxRetries attempts');
}

Batch sending with Future.wait

Future.wait runs multiple futures concurrently. Wrap it with a semaphore to cap the number of in-flight requests.

import 'dart:async';

class Semaphore {
  final int _max;
  int _current = 0;
  final _queue = <Completer<void>>[];

  Semaphore(this._max);

  Future<void> acquire() async {
    if (_current < _max) {
      _current++;
      return;
    }
    final completer = Completer<void>();
    _queue.add(completer);
    await completer.future;
  }

  void release() {
    if (_queue.isNotEmpty) {
      _queue.removeAt(0).complete();
    } else {
      _current--;
    }
  }
}

class Recipient {
  final String email, subject, html;
  Recipient(this.email, this.subject, this.html);
}

Future<List<Map<String, dynamic>>> sendBatch(
  List<Recipient> recipients, {
  int concurrency = 5,
}) async {
  final semaphore = Semaphore(concurrency);

  return Future.wait(recipients.map((r) async {
    await semaphore.acquire();
    try {
      final result = await sendEmail(to: r.email, subject: r.subject, html: r.html);
      return {'email': r.email, 'result': result};
    } catch (e) {
      return {'email': r.email, 'error': e.toString()};
    } finally {
      semaphore.release();
    }
  }));
}

Verify webhooks with Shelf

Rovela signs every delivery event with HMAC-SHA256. The signature arrives in the x-resend-signature header as t=<timestamp>,v1=<hex-signature>. Compute HMAC-SHA256 over <timestamp>.<raw-body> using your webhook secret and compare with a constant-time function.

Add shelf and crypto to pubspec.yaml:

dependencies:
  shelf: ^1.4.0
  shelf_router: ^1.1.0
  crypto: ^3.0.3
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart';

bool _constantTimeEqual(List<int> a, List<int> b) {
  if (a.length != b.length) return false;
  var result = 0;
  for (var i = 0; i < a.length; i++) {
    result |= a[i] ^ b[i];
  }
  return result == 0;
}

Map<String, String> _parseSigHeader(String header) {
  final parts = <String, String>{};
  for (final part in header.split(',')) {
    final kv = part.split('='); // hex payloads never contain '='
    if (kv.length == 2) parts[kv[0]] = kv[1];
  }
  return parts;
}

Response _badRequest(String message) =>
    Response(400, body: message);

Future<Response> webhookHandler(Request request) async {
  final secret = Platform.environment['ROVELA_WEBHOOK_SECRET']!;
  final rawBody = await request.readAsString();
  final sigHeader = request.headers['x-resend-signature'] ?? '';

  final parts = _parseSigHeader(sigHeader);
  final timestamp = parts['t'];
  final v1Hex = parts['v1'];

  if (timestamp == null) return _badRequest('missing timestamp');
  if (v1Hex == null) return _badRequest('missing v1');

  List<int> v1Bytes;
  try {
    v1Bytes = List.generate(
      v1Hex.length ~/ 2,
      (i) => int.parse(v1Hex.substring(i * 2, i * 2 + 2), radix: 16),
    );
  } catch (_) {
    return _badRequest('invalid hex in v1');
  }

  final signingInput = utf8.encode('$timestamp.$rawBody');
  final key = Hmac(sha256, utf8.encode(secret));
  final expected = key.convert(signingInput).bytes;

  if (!_constantTimeEqual(expected, v1Bytes)) {
    return _badRequest('invalid signature');
  }

  final payload = jsonDecode(rawBody) as Map<String, dynamic>;
  switch (payload['type']) {
    case 'email.delivered':
      // handle delivered
      break;
    case 'email.bounced':
      // handle bounce
      break;
  }

  return Response.ok('ok');
}

void main() async {
  final router = Router()
    ..post('/webhooks/rovela', webhookHandler);

  final handler = Pipeline()
      .addMiddleware(logRequests())
      .addHandler(router);

  final server = await shelf_io.serve(handler, InternetAddress.anyIPv4, 8080);
  print('Listening on port ${server.port}');
}

The _constantTimeEqual function compares every byte before returning, preventing timing attacks.