← Back to blog

Send Email with F# using the Rovela API

Learn how to send transactional email from F# using HttpClient, FsHttp or FSharp.Data, with retry logic and idempotency keys, bounded batch sending via SemaphoreSlim, and webhook verification in ASP.NET Core and Giraffe.

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

F# runs on .NET, so it inherits the platform's HTTP stack, its crypto primitives and its task workflows without leaving the language. This guide covers three ways to send with Rovela — raw HttpClient, the F#-first FsHttp library and FSharp.Data — plus retry with idempotency keys, bounded parallel batch sending with SemaphoreSlim, and webhook verification in ASP.NET Core and Giraffe.

Prerequisites

Set your API key as an environment variable:

export ROVELA_API_KEY="re_..."

Create a project and add the libraries used below:

dotnet new console --language F# --name RovelaSender
cd RovelaSender
dotnet add package FsHttp
dotnet add package FSharp.Data
dotnet add package Giraffe

Send with HttpClient

HttpClient needs no packages and works anywhere .NET runs. One F#-specific snag comes first: to is a keyword, so a record field cannot carry that name.

module Rovela.HttpClient

open System
open System.Net.Http
open System.Net.Http.Headers
open System.Text
open System.Text.Json
open System.Text.Json.Serialization

// `to` is a keyword in F#, so the record field carries the API's name.
type EmailPayload = {
    [<JsonPropertyName("from")>] Sender: string
    [<JsonPropertyName("to")>] Recipient: string
    [<JsonPropertyName("subject")>] Subject: string
    [<JsonPropertyName("html")>] Html: string
}

let apiKey = Environment.GetEnvironmentVariable "ROVELA_API_KEY"

let sendEmail (client: HttpClient) (payload: EmailPayload) =
    task {
        use request = new HttpRequestMessage(HttpMethod.Post, "https://api.rovela.dev/emails")
        request.Headers.Authorization <- AuthenticationHeaderValue("Bearer", apiKey)
        request.Content <-
            new StringContent(JsonSerializer.Serialize payload, Encoding.UTF8, "application/json")

        use! response = client.SendAsync request
        let! body = response.Content.ReadAsStringAsync()

        if not response.IsSuccessStatusCode then
            failwithf "HTTP %d: %s" (int response.StatusCode) body

        use document = JsonDocument.Parse body
        return document.RootElement.GetProperty("id").GetString()
    }

The JsonPropertyName attributes keep the wire format correct while the F# names stay idiomatic. SendAsync returns a Task, so sendEmail composes inside a task { } workflow and awaits like any other asynchronous call.

Send with FsHttp

FsHttp wraps the same stack in a computation expression, which is the shortest of the three:

module Rovela.FsHttpClient

open System
open FsHttp

let apiKey = Environment.GetEnvironmentVariable "ROVELA_API_KEY"

let sendEmail (payload: obj) =
    let response =
        http {
            POST "https://api.rovela.dev/emails"
            AuthorizationBearer apiKey
            body
            jsonSerialize payload
        }
        |> Request.send

    let text = response |> Response.toText

    let code = int response.statusCode
    if code < 200 || code > 299 then
        failwithf "HTTP %d: %s" code text

    text

Request.send is synchronous. If the caller is already asynchronous, Request.sendAsync returns a Task instead, and the rest of the function is unchanged.

Send with FSharp.Data

FSharp.Data is the smallest dependency footprint: one function, one call, and a plain string for the body.

module Rovela.FSharpData

open System
open System.Text.Json
open FSharp.Data
open FSharp.Data.HttpRequestHeaders

let apiKey = Environment.GetEnvironmentVariable "ROVELA_API_KEY"

/// `json` is the serialized payload, for example
/// {"from":"Acme <noreply@yourdomain.com>","to":"user@example.com",
///  "subject":"Welcome aboard","html":"<p>Thanks for signing up!</p>"}
let sendEmail (json: string) =
    let response =
        Http.Request(
            "https://api.rovela.dev/emails",
            httpMethod = HttpMethod.Post,
            headers = [
                Authorization("Bearer " + apiKey)
                ContentType HttpContentTypes.Json
            ],
            body = TextRequest json)

    match response.Body with
    | Text body when response.StatusCode >= 200 && response.StatusCode < 300 ->
        JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()
    | Text body ->
        failwithf "HTTP %d: %s" response.StatusCode body
    | _ ->
        failwith "Unexpected response body"

The payload is passed in already serialized. That is deliberate — the same string is what the retry section below needs to send unchanged on every attempt.

Retry with an idempotency key

The case an idempotency key exists for is the one where the request reached the server but the response never reached you. A blind retry then sends a second email. Generating the key once, before the loop, makes the retry safe: the API recognises the repeated key and returns the original result.

module Rovela.Retry

open System
open System.Net
open System.Net.Http
open System.Net.Http.Headers
open System.Text
open System.Text.Json
open System.Threading.Tasks
open Rovela.HttpClient

let apiUrl = "https://api.rovela.dev/emails"

/// The key is generated once, before the loop, and reused on every attempt.
/// If the connection drops after the server has accepted the request, the
/// retry returns the first result instead of sending a second email.
let sendWithRetry (client: HttpClient) (maxRetries: int) (payload: EmailPayload) =
    task {
        let idempotencyKey = Guid.NewGuid().ToString("N")
        let body = JsonSerializer.Serialize payload

        let rec attempt (current: int) =
            task {
                use request = new HttpRequestMessage(HttpMethod.Post, apiUrl)
                request.Headers.Authorization <- AuthenticationHeaderValue("Bearer", apiKey)
                request.Headers.Add("Idempotency-Key", idempotencyKey)
                request.Content <- new StringContent(body, Encoding.UTF8, "application/json")

                use! response = client.SendAsync request
                let! text = response.Content.ReadAsStringAsync()

                if response.IsSuccessStatusCode then
                    return text
                elif response.StatusCode = HttpStatusCode.TooManyRequests && current < maxRetries then
                    let delayMs =
                        let retryAfter = response.Headers.RetryAfter
                        if isNull retryAfter || not retryAfter.Delta.HasValue then
                            pown 2 current * 1000
                        else
                            int retryAfter.Delta.Value.TotalMilliseconds
                    do! Task.Delay delayMs
                    return! attempt (current + 1)
                else
                    return failwithf "HTTP %d: %s" (int response.StatusCode) text
            }

        return! attempt 0
    }

Only 429 is retried. A 500 from the API, or a 422 for a malformed address, will not improve by being sent again, and retrying it just multiplies the damage. When the response carries Retry-After, that value wins; otherwise the delay doubles each attempt.

Batch sending with bounded concurrency

Task.WhenAll on its own starts every request at the same instant. A few hundred recipients then produce a few hundred simultaneous connections — enough to hit the API's rate limit and the receiving side's connection limit at once. A semaphore caps how many are in flight:

module Rovela.Batch

open System.Net.Http
open System.Threading
open System.Threading.Tasks
open Rovela.HttpClient

/// At most `concurrency` requests are in flight at any moment. `Task.WhenAll`
/// alone would start every one of them at once and lose to a rate limit.
let sendBatch (client: HttpClient) (concurrency: int) (recipients: EmailPayload list) =
    task {
        use gate = new SemaphoreSlim(concurrency, concurrency)

        let sendOne (payload: EmailPayload) =
            task {
                do! gate.WaitAsync()
                try
                    try
                        let! id = sendEmail client payload
                        return Ok(payload.Recipient, id)
                    with ex ->
                        return Error(payload.Recipient, ex.Message)
                finally
                    gate.Release() |> ignore
            }

        return! recipients |> List.map sendOne |> Task.WhenAll
    }

Each result is either Ok (recipient, id) or Error (recipient, message), so one bad address does not abort the batch. Read the errors afterwards and report them; a list with three dead addresses should send the other ninety-seven.

Verify webhook signatures

Every event is signed with HMAC-SHA256. The signature arrives in the x-resend-signature header as t=<unix-seconds>,v1=<hex>, and the signed content is <timestamp>.<raw body>. Compute the same digest with your webhook secret and compare with CryptographicOperations.FixedTimeEquals, which does not leak the position of the first differing byte. Rejecting timestamps older than five minutes limits how long a captured request stays usable.

module Rovela.Webhook

open System
open System.Globalization
open System.Security.Cryptography
open System.Text

/// The header looks like "t=1717488123,v1=<hex>". The signed content is
/// "<t>.<raw body>", so a captured body cannot be replayed with a fresh
/// timestamp: changing either part invalidates the signature.
let verifySignature (secret: string) (header: string) (rawBody: string) (now: DateTimeOffset) =
    let parts =
        header.Split ','
        |> Array.choose (fun part ->
            match part.Split('=', 2) with
            | [| key; value |] -> Some(key.Trim(), value.Trim())
            | _ -> None)
        |> dict

    match parts.TryGetValue "t", parts.TryGetValue "v1" with
    | (true, timestamp), (true, signature) ->
        match Int64.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture) with
        | false, _ ->
            false
        | true, seconds ->
            let age = abs (now.ToUnixTimeSeconds() - seconds)
            if age > 300L then
                false
            else
                use hmac = new HMACSHA256(Encoding.UTF8.GetBytes secret)
                let expected =
                    hmac.ComputeHash(Encoding.UTF8.GetBytes $"{timestamp}.{rawBody}")
                    |> Convert.ToHexString
                    |> fun hex -> hex.ToLowerInvariant()
                CryptographicOperations.FixedTimeEquals(
                    Encoding.UTF8.GetBytes expected,
                    Encoding.UTF8.GetBytes(signature.ToLowerInvariant()))
    | _ ->
        false

Verify webhooks in ASP.NET Core

The minimal API handler reads the body as a stream and hands the raw string to the verifier. It deliberately does not bind a record: model binding consumes the body and produces a new serialization, and the signature covers the bytes that were sent.

module Program

open System
open System.IO
open System.Text
open System.Threading.Tasks
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Extensions.Hosting
open Rovela.Webhook

let webhookHandler (ctx: HttpContext) : Task =
    task {
        use reader = new StreamReader(ctx.Request.Body, Encoding.UTF8)
        let! raw = reader.ReadToEndAsync()
        let header = string ctx.Request.Headers["x-resend-signature"]
        let secret = Environment.GetEnvironmentVariable "ROVELA_WEBHOOK_SECRET"

        if verifySignature secret header raw DateTimeOffset.UtcNow then
            ctx.Response.StatusCode <- 200
            do! ctx.Response.WriteAsync "ok"
        else
            ctx.Response.StatusCode <- 400
            do! ctx.Response.WriteAsync "invalid signature"
    }

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder(args)
    let app = builder.Build()

    app.MapPost("/webhooks/rovela", RequestDelegate webhookHandler) |> ignore
    app.Run()
    0

Verify webhooks with Giraffe

The Giraffe handler is the same verifier behind an HttpHandler:

module Rovela.GiraffeApp

open System
open System.IO
open System.Text
open Giraffe
open Microsoft.AspNetCore.Http
open Rovela.Webhook

let webhookHandler: HttpHandler =
    fun next ctx ->
        task {
            use reader = new StreamReader(ctx.Request.Body, Encoding.UTF8)
            let! raw = reader.ReadToEndAsync()
            let header = string ctx.Request.Headers["x-resend-signature"]
            let secret = Environment.GetEnvironmentVariable "ROVELA_WEBHOOK_SECRET"

            if verifySignature secret header raw DateTimeOffset.UtcNow then
                return! text "ok" next ctx
            else
                return! (setStatusCode 400 >=> text "invalid signature") next ctx
        }

Both handlers read the body before touching JSON. Re-encoding a parsed model will almost never reproduce the exact bytes the sender signed, and a signature check that runs against a re-serialized body rejects valid events — or, worse, accepts nothing and is quietly disabled to make the endpoint work.