← Back to blog

Send Email with C# and .NET using the Rovela API

Learn how to send transactional email from C# using HttpClient with dependency injection or standalone, with retry logic, idempotency keys, batch sending via SemaphoreSlim, and webhook verification for ASP.NET Core Minimal API and MVC controllers.

Updated November 3, 2026
Send Email with C# and .NET using the Rovela API

This guide covers sending transactional email from C# and .NET using the Rovela API. It includes HttpClient with the built-in JSON helpers, retry logic with idempotency keys, batch sending, and webhook verification for ASP.NET Core Minimal API and controllers.

Prerequisites

Set your API key as an environment variable or in appsettings.json:

export ROVELA_API_KEY="re_..."

Send with HttpClient

Register a typed HttpClient via dependency injection so the base address and auth header are set once.

// Program.cs (Minimal API / ASP.NET Core)
using System.Net.Http.Headers;

builder.Services.AddHttpClient<RovelaClient>(client =>
{
    client.BaseAddress = new Uri("https://api.rovela.dev/");
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("ROVELA_API_KEY"));
});
// RovelaClient.cs
using System.Net.Http.Json;

public class RovelaClient
{
    private readonly HttpClient _http;
    public RovelaClient(HttpClient http) => _http = http;

    public async Task<EmailResult?> SendAsync(SendEmailRequest req, CancellationToken ct = default)
    {
        var response = await _http.PostAsJsonAsync("emails", req, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<EmailResult>(ct);
    }
}

public record SendEmailRequest(
    string From,
    string To,
    string Subject,
    string Html,
    string? Text = null);

public record EmailResult(string Id);
// Usage
var result = await rovelaClient.SendAsync(new SendEmailRequest(
    From:    "Acme <noreply@yourdomain.com>",
    To:      "user@example.com",
    Subject: "Welcome aboard",
    Html:    "<p>Thanks for signing up!</p>"
));
Console.WriteLine(result?.Id);

Send without dependency injection

For console apps or scripts without a DI container:

using System.Net.Http.Headers;
using System.Net.Http.Json;

var apiKey = Environment.GetEnvironmentVariable("ROVELA_API_KEY")!;
using var http = new HttpClient { BaseAddress = new Uri("https://api.rovela.dev/") };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", apiKey);

var payload = new
{
    from    = "Acme <noreply@yourdomain.com>",
    to      = "user@example.com",
    subject = "Welcome aboard",
    html    = "<p>Thanks for signing up!</p>"
};

var response = await http.PostAsJsonAsync("emails", payload);
response.EnsureSuccessStatusCode();
var body = await response.Content.ReadAsStringAsync();
Console.WriteLine(body);

Retry with idempotency key

Generate the key once before the loop and pass the same value on every attempt. This prevents duplicate delivery when the network drops after the server accepts the request.

using System.Net;
using System.Net.Http.Json;

public async Task<HttpResponseMessage> SendWithRetryAsync(
    HttpClient http,
    object payload,
    int maxRetries = 4)
{
    var idempotencyKey = Guid.NewGuid().ToString(); // generated once, reused on every retry

    for (int attempt = 0; attempt < maxRetries; attempt++)
    {
        var request = new HttpRequestMessage(HttpMethod.Post, "emails")
        {
            Content = JsonContent.Create(payload)
        };
        request.Headers.Add("Idempotency-Key", idempotencyKey);

        var response = await http.SendAsync(request);
        if (response.StatusCode != HttpStatusCode.TooManyRequests)
            return response;

        double delay = Math.Pow(2, attempt) * 1000;
        if (response.Headers.TryGetValues("Retry-After", out var values) &&
            double.TryParse(values.First(), out var seconds))
            delay = seconds * 1000;

        await Task.Delay(TimeSpan.FromMilliseconds(delay));
    }

    throw new Exception($"Rate limit exceeded after {maxRetries} attempts");
}

Batch sending

Use SemaphoreSlim to cap concurrency when sending to many recipients in parallel.

using System.Net.Http.Json;

public async Task SendBatchAsync(HttpClient http, IEnumerable<Recipient> recipients)
{
    var semaphore = new SemaphoreSlim(5); // max 5 concurrent requests
    var tasks = recipients.Select(async r =>
    {
        await semaphore.WaitAsync();
        try
        {
            var payload = new
            {
                from    = "Acme <noreply@yourdomain.com>",
                to      = r.Email,
                subject = r.Subject,
                html    = r.Html
            };
            var response = await http.PostAsJsonAsync("emails", payload);
            Console.WriteLine($"{r.Email}: {(int)response.StatusCode}");
        }
        finally
        {
            semaphore.Release();
        }
    });

    await Task.WhenAll(tasks);
}

public record Recipient(string Email, string Subject, string Html);

Verify webhooks in ASP.NET Core Minimal API

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 CryptographicOperations.FixedTimeEquals to prevent timing attacks.

To receive the raw body before the JSON middleware consumes it, read HttpContext.Request.Body directly or use [FromBody] byte[].

// Program.cs
app.MapPost("/webhooks/rovela", async (HttpContext ctx) =>
{
    var secret = Environment.GetEnvironmentVariable("ROVELA_WEBHOOK_SECRET")!;

    // Read raw body before any JSON parsing
    using var ms = new MemoryStream();
    await ctx.Request.Body.CopyToAsync(ms);
    var rawBody = ms.ToArray();

    var sigHeader = ctx.Request.Headers["x-resend-signature"].FirstOrDefault() ?? "";
    var parts = sigHeader.Split(',')
        .Select(p => p.Split('=', 2))
        .Where(p => p.Length == 2)
        .ToDictionary(p => p[0], p => p[1]);

    if (!parts.TryGetValue("t", out var timestamp) || !parts.TryGetValue("v1", out var v1))
        return Results.BadRequest("invalid signature format");

    var signedString = $"{timestamp}.{Encoding.UTF8.GetString(rawBody)}";
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(signedString)))
                          .ToLowerInvariant();

    if (!CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected),
            Encoding.UTF8.GetBytes(v1)))
        return Results.BadRequest("invalid signature");

    var payload = JsonSerializer.Deserialize<JsonElement>(rawBody);
    var eventType = payload.GetProperty("type").GetString();
    switch (eventType)
    {
        case "email.delivered": /* mark delivered */ break;
        case "email.bounced":   /* handle bounce  */ break;
    }

    return Results.Ok("ok");
});

Required using statements:

using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

Verify webhooks in an MVC controller

[ApiController]
[Route("webhooks")]
public class RovelaWebhookController : ControllerBase
{
    private readonly string _secret =
        Environment.GetEnvironmentVariable("ROVELA_WEBHOOK_SECRET")!;

    [HttpPost("rovela")]
    public async Task<IActionResult> Handle()
    {
        using var ms = new MemoryStream();
        await Request.Body.CopyToAsync(ms);
        var rawBody = ms.ToArray();

        var sigHeader = Request.Headers["x-resend-signature"].FirstOrDefault() ?? "";
        var parts = sigHeader.Split(',')
            .Select(p => p.Split('=', 2))
            .Where(p => p.Length == 2)
            .ToDictionary(p => p[0], p => p[1]);

        if (!parts.TryGetValue("t", out var timestamp) || !parts.TryGetValue("v1", out var v1))
            return BadRequest("invalid signature format");

        var signedString = $"{timestamp}.{Encoding.UTF8.GetString(rawBody)}";
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secret));
        var expected = Convert.ToHexString(
            hmac.ComputeHash(Encoding.UTF8.GetBytes(signedString))).ToLowerInvariant();

        if (!CryptographicOperations.FixedTimeEquals(
                Encoding.UTF8.GetBytes(expected),
                Encoding.UTF8.GetBytes(v1)))
            return BadRequest("invalid signature");

        var payload = JsonSerializer.Deserialize<JsonElement>(rawBody);
        // handle event by payload.GetProperty("type").GetString()
        return Ok("ok");
    }
}

Disable the default body-buffering behaviour if you find Request.Body is empty — add app.Use(async (ctx, next) => { ctx.Request.EnableBuffering(); await next(); }); before your route registrations in Program.cs.