Send transactional emails with .NET

Contents

The .NET SDK sends transactional emails from version 2.20.0. You need a transactional email and a project secret API key first. The quick start sets up both and sends a test email in a few minutes.

Prompt for your coding agent

Paste this into Claude Code, Cursor, or another coding agent. It sends the agent to this page and gives it the rules that are easy to get wrong.

text
Add PostHog transactional email to this .NET app with the PostHog NuGet package, or PostHog.AspNetCore for ASP.NET Core.
Read https://posthog.com/docs/workflows/transactional-emails/dotnet.md first. Follow it over anything you remember about PostHog.
- Reuse the app's PostHog client. Set SecretKey from the PostHog:SecretKey configuration value, such as user secrets or the POSTHOG_PROJECT_SECRET_API_KEY environment variable. In ASP.NET Core, inject IPostHogEmails where you send email.
- Keep the secret key in server configuration. Never commit it, and never send it to a browser or a mobile app.
- Build each IdempotencyKey from the record that caused the email, such as the password reset request ID or the order ID. Never use a random value or a timestamp.
- SendAsync throws EmailException when PostHog doesn't queue the email. Branch on its Code, never on its Message.
- Retry only network_error, idempotency_key_in_flight, throttled, and delivery_unavailable, with the same IdempotencyKey. Never resend the email through another email provider. Finish every retry within 24 hours, because PostHog keeps an idempotency key for 24 hours.
- Pass the DistinctId that the app passes to IdentifyAsync. Leave it out for people without an account, such as invitees.
- Use transactional email only for email that a person needs, such as password resets, sign-in codes, and receipts. Never use it for marketing.
- Add an xUnit test that gives PostHogClient an IHttpClientFactory with a fake HttpMessageHandler and checks the request.
Ask me for the template ID and the names of its variables. Don't create the API key. I create it.

Install

Terminal
dotnet add package PostHog

For ASP.NET Core, install PostHog.AspNetCore instead.

Set up the client

Set SecretKey on the PostHog client that your app already has. The project token keeps capturing events. The secret key sends email, so keep it in your server configuration and never ship it to a browser or a mobile app.

// Program.cs
using PostHog;
// Set PostHog:ProjectToken and PostHog:SecretKey in your configuration,
// such as user secrets or environment variables
builder.AddPostHog();
// PasswordResetService.cs: inject IPostHogEmails where you send email
using PostHog.Emails;
public class PasswordResetService(IPostHogEmails emails)
{
// ...
}

The client loads flag definitions only when it evaluates a flag. If your app also evaluates feature flags locally with this client, give the key the Feature flag read scope too.

Dispose of the client when your app exits, not after each send. If your app sends through a reverse proxy or a self-hosted PostHog, see use a proxy or self-hosted PostHog.

Send an email

C#
using PostHog.Emails;
public async Task<string> SendPasswordResetAsync(User user, string resetLink, string resetRequestId)
{
// Throws EmailException when PostHog doesn't queue the email. Branch on its Code
var result = await emails.SendAsync(new SendTemplateEmailOptions
{
To = user.Email,
TemplateId = Guid.Parse("<template_id>"),
TemplateData = new Dictionary<string, object>
{
["first_name"] = user.FirstName,
["reset_link"] = resetLink,
},
DistinctId = user.Id,
// Identifies this one email, so a retry never sends it twice
IdempotencyKey = $"password-reset-{resetRequestId}",
});
return result.Id;
}

SendAsync returns a SendEmailResult, and result.Id is the message ID. When PostHog doesn't queue the email, SendAsync throws an EmailException, and its Code is one of the codes in the errors table. A wrong argument throws ArgumentException before any request.

A network_error or a 503 doesn't mean that the email failed, because PostHog can still have queued it. Retry with the same idempotency key. If you resend the email through another email provider, the recipient can get it twice.

Timeouts and retries

The client retries for you. It retries network errors, rate limits, an in-flight idempotency key, and a 503 up to two times, with the same idempotency key. It waits about 1 second and then 2 seconds, or as long as the server's Retry-After asks, up to 30 seconds. When Retry-After asks for more than 30 seconds, the client throws at once. Each request times out after 15 seconds.

To change this, set EmailRequestTimeout and EmailRequestMaxRetries in PostHogOptions, or MaxRetries on one send.

Send from a background job

Send from a job queue when the email shouldn't slow down the request, such as an email address check after a signup. Let the queue own the retries:

  • Set MaxRetries = 0, so that the client doesn't retry inside the job.
  • Keep the idempotency key the same on every attempt.
  • Finish the retries within 24 hours. PostHog remembers an idempotency key for 24 hours after it queues the email, so a later retry can send the email twice.
  • Retry only the errors that a later attempt can fix. Every other code fails the same way again.

This example uses Hangfire. AutomaticRetry retries the job when it throws, except on the exception types in ExceptOn. A job that throws one of those goes straight to the failed state.

C#
using Hangfire;
using PostHog.Emails;
public class PasswordResetEmailJob(IPostHogEmails emails)
{
// A later attempt can fix these. Any other code fails the same way again.
static readonly HashSet<string> RetryableCodes =
["network_error", "idempotency_key_in_flight", "throttled", "delivery_unavailable"];
[AutomaticRetry(
Attempts = 5,
DelaysInSeconds = new[] { 10, 60, 300 },
ExceptOn = new[] { typeof(PermanentEmailFailure), typeof(ArgumentException) })]
public async Task SendAsync(string userId, string email, string firstName, string resetLink, string resetRequestId)
{
try
{
await emails.SendAsync(new SendTemplateEmailOptions
{
To = email,
TemplateId = Guid.Parse("<template_id>"),
TemplateData = new Dictionary<string, object>
{
["first_name"] = firstName,
["reset_link"] = resetLink,
},
DistinctId = userId,
// The same key on every attempt, so a retried job never sends the email twice
IdempotencyKey = $"password-reset-{resetRequestId}",
// Hangfire retries the job, so the client doesn't retry on its own
MaxRetries = 0,
});
}
catch (EmailException error) when (!RetryableCodes.Contains(error.Code))
{
throw new PermanentEmailFailure(error);
}
}
}
public class PermanentEmailFailure(EmailException error) : Exception($"{error.Code}: {error.Message}", error);

Queue the job with the same values that the send needs:

C#
BackgroundJob.Enqueue<PasswordResetEmailJob>(job =>
job.SendAsync(user.Id, user.Email, user.FirstName, resetLink, resetRequestId));

Pass the distinct ID that you pass to IdentifyAsync as DistinctId. This is usually your user's ID. PostHog then records the email's engagement events, such as sent, opened, and clicked, on that user's person, in the same timeline as their product events.

  • No distinct ID - PostHog records the email on a person whose distinct ID is the email address. That's a different person from your user, unless your app identifies users by their email address.
  • Not a user yet - For an invite, or an email address check before the account exists, leave DistinctId out.

Send content from your code

SendAsync also takes content that your code rendered, with no template in PostHog. Use it when your emails already live in your codebase, or when you move from another email provider. Pass SendCodeEmailOptions, name the email with TemplateKey, and set Subject with Html, Text, or both. PostHog sends the content as you wrote it, so render it with your template engine first, such as Razor.

C#
public async Task<string> SendPasswordResetAsync(User user, string html, string resetRequestId)
{
var result = await emails.SendAsync(new SendCodeEmailOptions
{
To = user.Email,
TemplateKey = "password-reset",
Subject = "Reset your password",
Html = html,
From = "Hedgebox <security@example.com>",
DistinctId = user.Id,
IdempotencyKey = $"password-reset-{resetRequestId}",
});
return result.Id;
}
  • TemplateKey - Names the email, such as password-reset. PostHog groups the email's metrics and sends under this key, so use the same key every time you send this email.
  • Text - If you leave it out, PostHog makes it from Html.
  • From - Needs an address on a domain that your project verified. Without it, PostHog uses the project's default sender. ReplyTo takes a list of addresses.
  • CaptureEngagement - PostHog captures the email's engagement events unless you set CaptureEngagement = false.

SendTemplateEmailOptions sends a template made in PostHog, and SendCodeEmailOptions sends your content. Content from your code takes no template data, because PostHog renders nothing. See content from your code for each field and its checks.

Check an email's status

C#
public async Task<bool?> EmailBouncedAsync(string messageId)
{
try
{
var status = await emails.GetAsync(messageId);
return status.Status == "bounced";
}
catch (EmailException error) when (error.Code == "not_found")
{
// The first status can take a few seconds to arrive. Try again later.
return null;
}
}

GetAsync returns an EmailStatus with Id, Status, CreatedAt, StatusAt, and Reason. The two times are DateTimeOffset values. See statuses and reasons for what each value means.

A not_found right after a send usually means that the first status hasn't arrived yet. It doesn't mean that the email failed. The client doesn't retry a not_found, so try again a few times with a short wait before you treat the email as missing.

Test without sending email

PostHogClient takes an IHttpClientFactory. Give it an HttpClient with your own HttpMessageHandler to see exactly what your code sends, with no network call. This tests your integration, not delivery. To test delivery, send a real email to your own address and check the email's Sends tab.

C#
using System.Net;
using System.Text;
using PostHog;
using PostHog.Emails;
using Xunit;
public class PasswordResetEmailTests
{
[Fact]
public async Task SendsTheIdempotencyKey()
{
var handler = new FakeHandler();
await using var posthog = new PostHogClient(
new PostHogOptions { ProjectToken = "phc_test", SecretKey = "phs_test" },
httpClientFactory: new FakeHttpClientFactory(new HttpClient(handler)));
var result = await posthog.Emails.SendAsync(new SendTemplateEmailOptions
{
To = "jane@example.com",
TemplateId = Guid.Parse("0190f6d2-7c1e-7b3a-9f5e-1c2d3e4f5a6b"),
IdempotencyKey = "password-reset-1",
});
Assert.Equal("msg_1", result.Id);
Assert.Equal("password-reset-1", Assert.Single(handler.IdempotencyKeys));
}
}
sealed class FakeHandler : HttpMessageHandler
{
public List<string> IdempotencyKeys { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
IdempotencyKeys.Add(request.Headers.GetValues("Idempotency-Key").Single());
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.Accepted)
{
Content = new StringContent("""{"message_id": "msg_1", "replayed": false}""", Encoding.UTF8, "application/json"),
});
}
}
sealed class FakeHttpClientFactory(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
}

Use a proxy or self-hosted PostHog

On PostHog Cloud, the client sends email to your region's app host, even when HostUrl is an ingestion host such as us.i.posthog.com. If HostUrl is a reverse proxy or a self-hosted PostHog, set AppHost to your PostHog app URL. AppHost must use https. The client doesn't send email requests to a host that might be a proxy.

Flag definitions for local evaluation still load from HostUrl with the secret key when you evaluate a flag. If that host is a proxy that must not see the key, leave SecretKey off that client. Send email from a second PostHogClient whose HostUrl is your PostHog host.

Still have questions?

Was this page useful?