SDKs

.NET

Use the official Lettermint .NET SDK to send email from a C# application. The package provides typed requests and responses for the Sending API and Team API, plus webhook signature verification. See SDKs and integrations for other packages.

This guide covers SDK 2.0. If your project uses 1.x, follow the .NET SDK 2.0 upgrade guide on GitHub.

Requirements

Before you start, you need:

1. Installation

Install the Lettermint NuGet package from your application directory:

TerminalCode
dotnet add package Lettermint

2. Send your first email

Set LETTERMINT_PROJECT_TOKEN in your application's environment. Use a Project API token for email sends.

Add this code to a console application's Program.cs:

C#Program.cs
using Lettermint; using Lettermint.Models; using var lettermint = new LettermintClient(new LettermintOptions { SendingToken = Environment.GetEnvironmentVariable("LETTERMINT_PROJECT_TOKEN"), }); var response = await lettermint.Emails.SendAsync(new SendMailRequest { From = "Example <hello@yourdomain.com>", To = ["recipient@example.com"], Subject = "Hello from Lettermint", Html = "<h1>Hello!</h1><p>This is a test email.</p>", Text = "Hello! This is a test email.", }); Console.WriteLine($"Email ID: {response.MessageId}"); Console.WriteLine($"Status: {response.Status}");

Replace the sender with an address on your verified domain. Replace the recipient with your test address, then run the application:

TerminalCode
dotnet run --project path/to/YourApp.csproj

The client stores nothing about a message and is thread-safe. Create it once and share it, for example as a singleton, including across concurrent requests. Dispose() disposes the HttpClient that the SDK created. You can also pass the token as a string, as in new LettermintClient(token). If a token is missing or has an unrecognised format, the constructor throws LettermintConfigException and never includes the token in the message.

The Team API uses a separate Team API token. Set TeamToken alone or next to SendingToken. Each part of the client uses its own token and never falls back to the other. See Team API below and the Team API quickstart.

Use the client with dependency injection

Register the client once as a singleton. Pass an HttpClient from IHttpClientFactory if you want to manage handlers yourself. Its handler must not follow redirects, and it must not set default Authorization or x-lettermint-token headers:

C#Code
builder.Services.AddHttpClient("lettermint") .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false }); builder.Services.AddSingleton(services => new LettermintClient(new LettermintOptions { SendingToken = builder.Configuration["Lettermint:ProjectToken"], HttpClient = services.GetRequiredService<IHttpClientFactory>().CreateClient("lettermint"), }));

The SDK has no dependency on Microsoft.Extensions.*, so it does not ship an AddLettermint() extension. If you do not pass an HttpClient, the SDK uses its own, and that is also fine as a singleton.

3. Email options

The examples below use the lettermint client from the previous section. lettermint.Emails.Compose() returns an immutable builder. Every setter returns a new builder and leaves the original unchanged, so one builder is safe to share and reuse. Keep the value that a setter returns when you build an email over several statements:

C#Code
var draft = lettermint.Emails.Compose() .From("Support <support@yourdomain.com>") .To("customer@example.com") .Subject("Your invoice"); if (copyAccountant) draft = draft.Cc("accountant@yourdomain.com"); await draft.Html("<p>Your invoice is ready.</p>").SendAsync();

Each builder example is equivalent to a SendMailRequest passed to lettermint.Emails.SendAsync(). Use whichever reads better. Build() returns the builder's message as a SendMailRequest.

Recipients, reply-to, and headers

C#Code
await lettermint.Emails.Compose() .From("Support <support@yourdomain.com>") .To("customer@example.com", "colleague@example.com") .Cc("manager@yourdomain.com") .Bcc("archive@yourdomain.com") .ReplyTo("help@yourdomain.com") .Subject("Support ticket 12345") .Headers(new Dictionary<string, string> { ["X-Ticket-ID"] = "12345", }) .Text("Your support ticket has an update.") .SendAsync();

Headers sets email headers. Set an idempotency key in the options of the send call instead.

Tags and metadata

C#Code
using Lettermint.Models; await lettermint.Emails.Compose() .From("orders@yourdomain.com") .To("customer@example.com") .Subject("Order 12345 confirmed") .Text("Your order is confirmed.") .Tags(new MessageTagInput { Name = "category", Value = "order-confirmation", }) .Metadata(new Dictionary<string, string> { ["order_id"] = "12345", ["customer_id"] = "cust_789", }) .SendAsync();

Use Tags for named tags or Tag("order-confirmation") for the legacy single tag. Metadata values must be strings. Lettermint includes metadata in webhook payloads but does not add it to the email headers, so you can connect delivery events to application records.

Tags accepts up to 20 name and value pairs. The SDK checks names and values before it sends the request and throws LettermintValidationException with a Field if one is invalid. A rejected tag leaves the builder unchanged. See tags for the tag rules.

Route and delivery settings

C#Code
using Lettermint.Models; await lettermint.Emails.Compose() .From("notifications@yourdomain.com") .To("recipient@example.com") .Subject("Your account is ready") .Text("You can now sign in.") .Route("transactional") .Settings(new SendMailRequestSettings { Tls = TlsPolicy.Enforced, }) .SendAsync();

Route selects a route by its slug. TlsPolicy.Enforced requires TLS for email delivery. See TLS for delivery behavior.

Idempotency

Pass an idempotency key so a retry never sends the same email twice. If a request with the same key is repeated within the idempotency window, Lettermint returns the original result instead of sending again. The key is an option of the call that sends the email:

C#Code
await lettermint.Emails.Compose() .From("orders@yourdomain.com") .To("customer@example.com") .Subject("Order 12345 confirmed") .Text("Your order is confirmed.") .SendAsync(new IdempotentRequestOptions { IdempotencyKey = "order-confirmation-12345" });

Emails.SendAsync(message, options) and Emails.SendBatchAsync(messages, options) take the same IdempotentRequestOptions. The key applies only to that call, and the SDK never retries on its own. Reuse the same key when you retry the same email. See idempotency for the API rules and retention window.

File attachments

Pass raw bytes, and the SDK base64-encodes them. To pass content that is already base64, use EmailAttachment.FromBase64:

C#Code
await lettermint.Emails.Compose() .From("invoices@yourdomain.com") .To("customer@example.com") .Subject("Your invoice") .Text("Your invoice is attached.") .Attach("invoice.pdf", await File.ReadAllBytesAsync("/path/to/invoice.pdf"), contentType: "application/pdf") .SendAsync();

For an inline image, set ContentId and use the matching cid: reference in the HTML body:

C#Code
await lettermint.Emails.Compose() .From("invoices@yourdomain.com") .To("customer@example.com") .Subject("Your invoice") .Html("<img src=\"cid:logo\"> Your invoice is attached.") .Attach(EmailAttachment.FromBase64("logo.png", logoBase64) with { ContentId = "logo" }) .SendAsync();

See inline images.

Scheduled emails

Pass a DateTimeOffset, an ISO 8601 string with an explicit time zone, or an English phrase such as tomorrow 9am:

C#Code
var scheduled = await lettermint.Emails.Compose() .From("reminders@yourdomain.com") .To("recipient@example.com") .Subject("Appointment reminder") .Text("Your appointment starts in one hour.") .ScheduledAt(DateTimeOffset.UtcNow.AddHours(1)) .SendAsync(); Console.WriteLine(scheduled.MessageId); Console.WriteLine(scheduled.ScheduledAt);

Change or cancel a scheduled email with lettermint.Messages.RescheduleAsync(messageId, new RescheduleMessageRequest { ScheduledAt = "2026-10-20T09:00:00Z" }) and lettermint.Messages.CancelAsync(messageId). Both accept either token: the team token if you set one, otherwise the sending token. See email scheduling for scheduling limits and cancellation behavior.

Batch sending

Send up to 500 emails in one request. The list can contain SendMailRequest records or builders:

C#Code
var weekly = lettermint.Emails.Compose() .From("John Doe <john@yourdomain.com>") .Subject("Your weekly summary"); IReadOnlyList<SendMailResponse> results = await lettermint.Emails.SendBatchAsync( [ new SendMailRequest { From = "John Doe <john@yourdomain.com>", To = ["user1@example.com"], Subject = "Hi", Text = "Hello", }, weekly.To("user2@example.com").Text("Your summary is ready.").Build(), ], new IdempotentRequestOptions { IdempotencyKey = "weekly-summary-2026-10-05" });

The result list has one SendMailResponse for each message, in the order you sent them.

4. Send an order confirmation

A real transactional send usually pulls several of these features together. This example sends a receipt with an HTML and plain-text body, tags it for analytics, attaches the order and customer IDs as metadata, and uses an idempotency key so a retry never emails the customer twice:

C#OrderConfirmation.cs
using Lettermint; using Lettermint.Models; public sealed record Order(string Id, string Number, string Total, string CustomerEmail, string CustomerId); public sealed class OrderMailer(LettermintClient lettermint) { public Task<SendMailResponse> SendOrderConfirmationAsync(Order order, CancellationToken cancellationToken = default) => lettermint.Emails.Compose() .From("Acme Store <orders@yourdomain.com>") .To(order.CustomerEmail) .ReplyTo("support@yourdomain.com") .Subject($"Order {order.Number} confirmed") .Html($"<h1>Thanks for your order</h1><p>Order {order.Number} totalling {order.Total} is confirmed.</p>") .Text($"Thanks for your order. Order {order.Number} totalling {order.Total} is confirmed.") .Tags(new MessageTagInput { Name = "type", Value = "order-confirmation" }) .Metadata(new Dictionary<string, string> { ["order_id"] = order.Id, ["customer_id"] = order.CustomerId, }) .Route("transactional") .SendAsync(new IdempotentRequestOptions { IdempotencyKey = $"order-confirmation-{order.Id}" }, cancellationToken); }

The metadata you attach here travels with every webhook event for the message, so a later delivery or bounce maps straight back to the order in your database.

5. Response

SendAsync() returns a SendMailResponse with MessageId and Status. When you schedule an email, ScheduledAt is set too. API acceptance does not confirm delivery. Use webhooks to receive delivery and bounce events.

C#Code
var response = await lettermint.Emails.Compose() .From("John Doe <john@yourdomain.com>") .To("recipient@example.com") .Subject("Test") .Text("Hello!") .SendAsync(); Console.WriteLine($"Email sent with ID: {response.MessageId}"); Console.WriteLine($"Status: {response.Status}");

Status is a MessageStatus. Enums in the SDK are open structs: a value that the API adds later still decodes and keeps its raw string. Compare with ==, as in response.Status == MessageStatus.Scheduled, and give switch statements a default branch.

6. Handle errors

Every exception the SDK throws extends LettermintException. API errors extend LettermintApiException, which carries StatusCode, Code, Message, Details, and Body. Catch the subclasses you want to handle first:

C#Code
using Lettermint; using Lettermint.Models; try { await lettermint.Emails.SendAsync(message, new IdempotentRequestOptions { IdempotencyKey = key }); } catch (ValidationException error) { Console.Error.WriteLine($"{error.Message} {string.Join(", ", error.Errors?.Keys ?? Enumerable.Empty<string>())}"); } catch (RateLimitException error) { await Task.Delay(error.RetryAfter ?? TimeSpan.FromSeconds(1)); // then retry with the same key } catch (LettermintTimeoutException) { // The outcome is unknown. Retry with the same idempotency key. } catch (LettermintApiException error) { Console.Error.WriteLine($"{(int)error.StatusCode} {error.Code} {error.Message}"); }

The SDK never retries on its own. Reuse the same idempotency key when you retry so the customer gets one email. Other exceptions include AuthenticationException (401), PermissionException (403), NotFoundException (404), ConflictException (409), ServerException (5xx), ConnectionException, UnexpectedResponseException for an empty body or an HTML error page, RedirectException, LettermintConfigException for a missing token or invalid option, and LettermintValidationException when the SDK rejects a request before it sends it.

If you cancel a request with a CancellationToken, the call throws OperationCanceledException, as usual in .NET. Lettermint.AuthenticationException has the same simple name as System.Security.Authentication.AuthenticationException, so qualify it if a file imports both namespaces.

Timeouts and cancellation

The default timeout is 30 seconds and covers the response headers and the body. Change it for the client with the Timeout option, or for one call with RequestOptions. Every method takes the options and a CancellationToken as its last two arguments:

C#Code
using var slow = new LettermintClient(new LettermintOptions { SendingToken = Environment.GetEnvironmentVariable("LETTERMINT_PROJECT_TOKEN"), Timeout = TimeSpan.FromSeconds(15), }); var pong = await slow.Emails.PingAsync(new RequestOptions { Timeout = TimeSpan.FromSeconds(5) }, cancellationToken);

A timeout throws LettermintTimeoutException, so it does not clash with System.TimeoutException.

Team API

Use a separate Team API token to manage team resources. Set it in LETTERMINT_TEAM_TOKEN. Pass it as TeamToken, alone or next to SendingToken, to the same client:

C#Code
using Lettermint; using Lettermint.Models; using var lettermint = new LettermintClient(new LettermintOptions { TeamToken = Environment.GetEnvironmentVariable("LETTERMINT_TEAM_TOKEN"), }); var page = await lettermint.Domains.ListAsync(new ListDomainsQuery { PageSize = 30 }); foreach (var domain in page.Data) { Console.WriteLine(domain.Domain); }

Query parameters are typed records. Every list response is a CursorPage<T> with Data and NextCursor. Every list also has an IterateAsync() method, an IAsyncEnumerable<T> that follows NextCursor until the last page:

C#Code
await foreach (var domain in lettermint.Domains.IterateAsync(new ListDomainsQuery { FilterStatus = DomainStatus.Verified })) { Console.WriteLine(domain.Domain); }

Stop early with break. The SDK requests the next page only when your loop reaches it.

A Team API token cannot send email. A client that has only a team token throws LettermintConfigException when you call a method of lettermint.Emails, before it sends a request. See the Team API quickstart for token setup and permissions.

Track delivery, opens, and bounces

Enable tracking on your sending route to record opens and clicks. Subscribe to webhooks for delivery and bounce events.

The SDK verifies each delivery with new Webhook(secret).Verify(rawBody, headers). Pass the original request body without changes. It throws WebhookVerificationException with a Reason when the delivery is not genuine. See Webhook signatures for a full handler and the headers it needs.

Next steps

Tags

Organize emails with tags.

Webhooks

Receive delivery and bounce events.

Test addresses

Test delivery and bounce handling.

Team API

Manage team resources.

GitHub repository

Read the source code or report an issue.