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.

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.