← Back to blog

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.

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

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.