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.

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.