Send Email with Swift using the Rovela API
Learn how to send transactional email from Swift using URLSession callbacks or async/await, with retry logic and idempotency keys, concurrent batch sending via TaskGroup, and webhook signature verification using Apple's CryptoKit — including a Vapor server example.

This guide covers sending transactional email from Swift using the Rovela API. It includes URLSession with completion handlers, the modern async/await style, retry logic with idempotency keys, concurrent batch sending with TaskGroup, and webhook verification using Apple's CryptoKit framework — both standalone and inside a Vapor server.
Prerequisites
Set your API key as an environment variable:
export ROVELA_API_KEY="re_..."
Send with URLSession
The URLSession API is available on all Apple platforms and on Linux via Swift Foundation.
import Foundation
func sendEmail(
to: String,
subject: String,
html: String,
completion: @escaping (Result<Data, Error>) -> Void
) {
guard let apiKey = ProcessInfo.processInfo.environment["ROVELA_API_KEY"] else {
completion(.failure(URLError(.userAuthenticationRequired)))
return
}
var request = URLRequest(url: URL(string: "https://api.rovela.dev/emails")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: Any] = [
"from": "Acme <noreply@yourdomain.com>",
"to": to,
"subject": subject,
"html": html,
]
request.httpBody = try? JSONSerialization.data(withJSONObject: body)
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode),
let data = data else {
completion(.failure(URLError(.badServerResponse)))
return
}
completion(.success(data))
}.resume()
}
Send with async/await
Swift 5.5 and later expose an async overload of URLSession.data(for:) that fits naturally into structured concurrency:
import Foundation
struct EmailPayload: Encodable {
let from: String
let to: String
let subject: String
let html: String
}
struct EmailResult: Decodable {
let id: String
}
func sendEmail(_ payload: EmailPayload) async throws -> EmailResult {
let apiKey = ProcessInfo.processInfo.environment["ROVELA_API_KEY"]!
var request = URLRequest(url: URL(string: "https://api.rovela.dev/emails")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(payload)
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(EmailResult.self, from: data)
}
// Usage
let result = try await sendEmail(EmailPayload(
from: "Acme <noreply@yourdomain.com>",
to: "user@example.com",
subject: "Welcome aboard",
html: "<p>Thanks for signing up!</p>"
))
print(result.id)
Retry with idempotency key
Generate the idempotency key once before the retry loop and reuse it on every attempt. This prevents duplicate delivery when the network drops after the server accepts the request.
import Foundation
enum RovelaError: Error {
case rateLimitExceeded
case httpError(Int)
}
func sendWithRetry(payload: some Encodable, maxRetries: Int = 4) async throws -> Data {
let apiKey = ProcessInfo.processInfo.environment["ROVELA_API_KEY"]!
let idempotencyKey = UUID().uuidString // generated once, reused on every retry
var request = URLRequest(url: URL(string: "https://api.rovela.dev/emails")!)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(idempotencyKey, forHTTPHeaderField: "Idempotency-Key")
request.httpBody = try JSONEncoder().encode(payload)
for attempt in 0..<maxRetries {
let (data, response) = try await URLSession.shared.data(for: request)
let http = response as! HTTPURLResponse
if http.statusCode != 429 {
guard (200...299).contains(http.statusCode) else {
throw RovelaError.httpError(http.statusCode)
}
return data
}
let retryAfter = (response as? HTTPURLResponse)?
.value(forHTTPHeaderField: "Retry-After")
.flatMap(Double.init) ?? pow(2.0, Double(attempt))
try await Task.sleep(nanoseconds: UInt64(retryAfter * 1_000_000_000))
}
throw RovelaError.rateLimitExceeded
}
Batch sending with TaskGroup
Use withThrowingTaskGroup and a semaphore pattern to cap concurrency when sending to many recipients:
import Foundation
struct Recipient {
let email: String
let subject: String
let html: String
}
func sendBatch(recipients: [Recipient], concurrency: Int = 5) async throws {
let apiKey = ProcessInfo.processInfo.environment["ROVELA_API_KEY"]!
// slice the list into windows of `concurrency` and process each window sequentially
for chunk in stride(from: 0, to: recipients.count, by: concurrency)
.map({ recipients[$0..<min($0 + concurrency, recipients.count)] })
{
try await withThrowingTaskGroup(of: Void.self) { group in
for r in chunk {
group.addTask {
var req = URLRequest(url: URL(string: "https://api.rovela.dev/emails")!)
req.httpMethod = "POST"
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body: [String: String] = [
"from": "Acme <noreply@yourdomain.com>",
"to": r.email, "subject": r.subject, "html": r.html,
]
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (_, response) = try await URLSession.shared.data(for: req)
let http = response as! HTTPURLResponse
print("\(r.email): \(http.statusCode)")
}
}
try await group.waitForAll()
}
}
}
stride divides the list into windows of five; each window runs its tasks concurrently, and the next window starts only after all five in the current window finish. For finer-grained control (e.g., always keep exactly N tasks running), replace the chunking with an AsyncChannel or an actor-based token pool.
Verify webhooks with CryptoKit
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 in constant time.
Apple's CryptoKit provides HMAC.isValidAuthenticationCode(_:authenticating:using:), which performs a constant-time comparison internally — pass the decoded bytes of v1 as the tag.
import CryptoKit
import Foundation
enum WebhookError: Error {
case missingHeader, malformedSignature, invalidSignature
}
func verifyRovelaSignature(
secret: String,
body: Data,
signatureHeader: String
) throws {
// parse t=... and v1=...
var timestamp: String?
var v1Hex: String?
for part in signatureHeader.split(separator: ",") {
let kv = part.split(separator: "=", maxSplits: 1)
guard kv.count == 2 else { continue }
switch kv[0] {
case "t": timestamp = String(kv[1])
case "v1": v1Hex = String(kv[1])
default: break
}
}
guard let ts = timestamp, let hex = v1Hex else {
throw WebhookError.malformedSignature
}
// decode hex string → raw bytes
guard hex.count % 2 == 0 else { throw WebhookError.malformedSignature }
let v1Bytes = stride(from: 0, to: hex.count, by: 2).compactMap { i -> UInt8? in
let start = hex.index(hex.startIndex, offsetBy: i)
let end = hex.index(start, offsetBy: 2)
return UInt8(hex[start..<end], radix: 16)
}
guard v1Bytes.count == hex.count / 2 else { throw WebhookError.malformedSignature }
let signingInput = Data("\(ts).".utf8) + body
let key = SymmetricKey(data: Data(secret.utf8))
// isValidAuthenticationCode performs constant-time comparison
let valid = HMAC<SHA256>.isValidAuthenticationCode(
Data(v1Bytes),
authenticating: signingInput,
using: key
)
guard valid else { throw WebhookError.invalidSignature }
}
Verify webhooks in Vapor
In a Vapor application, read the raw body bytes before any JSON decoding and call the helper above:
import Vapor
import CryptoKit
func routes(_ app: Application) throws {
app.post("webhooks", "rovela") { req async throws -> HTTPStatus in
let secret = Environment.get("ROVELA_WEBHOOK_SECRET")!
let sigHeader = req.headers.first(name: "x-resend-signature") ?? ""
// collect() ensures the whole body is in memory before we read it
let body = try await req.body.collect(max: 1 << 20).map(Data.init) ?? Data()
try verifyRovelaSignature(secret: secret, body: body, signatureHeader: sigHeader)
// decode and handle the event
let event = try req.content.decode(RovelaEvent.self)
switch event.type {
case "email.delivered": break // mark delivered
case "email.bounced": break // handle bounce
default: break
}
return .ok
}
}
struct RovelaEvent: Content {
let type: String
}
Call req.body.collect() before req.content.decode() so the raw bytes are intact for signature verification. If you rely on Vapor's automatic body streaming, the bytes may already be consumed by the time you try to hash them.