An API key.
Created in the dashboard under API keys, shown once. A sending access key is enough for this guide and cannot read contacts or delete domains if it leaks.
There is no Notix package for .NET, and you do not need one: the email API is one POST with a bearer token. By the end of this guide an ASP.NET Core minimal API will send a real email through a typed HttpClient from IHttpClientFactory, read the key from configuration, retry without ever double-sending, and verify the webhook that tells it what happened.
Created in the dashboard under API keys, shown once. A sending access key is enough for this guide and cannot read contacts or delete domains if it leaks.
Add your domain, publish the SPF, DKIM and DMARC records Notix gives you, and the from address on it becomes valid. The quickstart walks through it.
For minimal APIs, primary constructors and HMACSHA256.HashData as used here. Nothing to install: System.Net.Http.Json and System.Text.Json ship with the SDK.
The free plan covers 5,000 emails a month and 200 a day, and the free plan does not ask for a card.
One options class holds the key, the base URL and the webhook secret. Configuration binds it from any source: user secrets while you develop,Notix__ApiKey in the environment in production. The double underscore is how .NET maps a nested section to a variable name.
// NotixOptions.cs
public sealed class NotixOptions
{
public const string SectionName = "Notix";
// Read from configuration, never from source. In development use
// `dotnet user-secrets set Notix:ApiKey nx_live_...`; in production an
// environment variable named Notix__ApiKey binds to the same property.
public string ApiKey { get; set; } = "";
// Pointing at a different Notix base URL (a staging environment, say)?
// Override here; the default is the hosted API.
public string BaseUrl { get; set; } = "https://app.usenotix.dev/api/v1/";
public string WebhookSecret { get; set; } = "";
}
AddHttpClient("notix") sets the base address, a 10 second timeout and the Authorization header once; the factory pools connections and recycles handlers so a long-running service does not exhaust sockets. The endpoint maps a route to a send and returns the emailId or the API’s refusal.
// Program.cs
using System.Net.Http.Headers;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<NotixOptions>(
builder.Configuration.GetSection(NotixOptions.SectionName));
// One named client, one base address, the bearer token set once. The
// factory pools connections and rotates handlers for you.
builder.Services.AddHttpClient("notix", (services, client) =>
{
var options = services.GetRequiredService<IOptions<NotixOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", options.ApiKey);
});
builder.Services.AddScoped<NotixClient>();
var app = builder.Build();
app.MapPost("/orders/{orderId}/receipt", async (string orderId, NotixClient notix) =>
{
var result = await notix.SendAsync(new SendEmailRequest(
From: "receipts@acme.com",
To: "customer@example.com",
Subject: $"Your receipt for order {orderId}",
Html: "<p>Thanks for your order.</p>",
Text: "Thanks for your order."),
idempotencyKey: $"order-{orderId}-receipt");
return result.Match(
ok => Results.Ok(new { emailId = ok.EmailId }),
error => Results.Json(new { error.Code, error.Message }, statusCode: 502));
});
app.Run();
The client POSTs to emails with JsonContent.Create, sets the Idempotency-Key header per request, and reads either the emailId or the error envelope. Send both html and text; some clients only show one. The third tab is what goes over the wire, so you can check the client against curl.
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed record SendEmailRequest(
string From,
string To,
string Subject,
string? Html = null,
string? Text = null,
string? TemplateId = null,
Dictionary<string, string>? Variables = null);
public sealed record SendEmailResponse(string EmailId);
public sealed record ApiError(string Code, string Message);
sealed record ErrorEnvelope(ApiError Error);
public sealed class NotixClient(IHttpClientFactory factory)
{
// The API speaks camelCase; the records above are PascalCase.
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task<Result<SendEmailResponse, ApiError>> SendAsync(
SendEmailRequest email, string idempotencyKey, CancellationToken ct = default)
{
var http = factory.CreateClient("notix");
using var request = new HttpRequestMessage(HttpMethod.Post, "emails")
{
Content = JsonContent.Create(email, options: Json),
};
// One key per business event, so a retry can never send twice.
request.Headers.Add("Idempotency-Key", idempotencyKey);
using var response = await http.SendAsync(request, ct);
if (response.IsSuccessStatusCode)
{
var ok = await response.Content.ReadFromJsonAsync<SendEmailResponse>(Json, ct);
return Result<SendEmailResponse, ApiError>.Ok(ok!);
}
// Every refusal is { "error": { "code", "message" } }; a 413 for a body
// over 20 MB is the one answer with no JSON.
var envelope = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(Json, ct);
return Result<SendEmailResponse, ApiError>.Fail(
envelope?.Error ?? new ApiError("HTTP_" + (int)response.StatusCode, "No error body"));
}
}
// A small discriminated result so callers must handle the refusal.
// Use OneOf, ErrorOr or LanguageExt if your project already has one.
public readonly struct Result<TOk, TError>
{
private readonly TOk? _ok;
private readonly TError? _error;
public bool IsOk { get; }
private Result(TOk? ok, TError? error, bool isOk) { _ok = ok; _error = error; IsOk = isOk; }
public static Result<TOk, TError> Ok(TOk value) => new(value, default, true);
public static Result<TOk, TError> Fail(TError error) => new(default, error, false);
public T Match<T>(Func<TOk, T> ok, Func<TError, T> fail) =>
IsOk ? ok(_ok!) : fail(_error!);
}
curl -X POST https://app.usenotix.dev/api/v1/emails \
-H "Authorization: Bearer $NOTIX_API_KEY" \
-H "Idempotency-Key: order-4471-receipt" \
-H "Content-Type: application/json" \
-d '{ "from": "receipts@acme.com", "to": "customer@example.com",
"subject": "Your receipt for order 4471",
"html": "<p>Thanks for your order.</p>", "text": "Thanks for your order." }'
# 200 { "emailId": "eml_abc123" }
# 400 { "error": { "code": "BAD_REQUEST", "message": "..." } } your request
# 401 { "error": { "code": "UNAUTHORIZED", "message": "..." } } bad or missing key
# 409 { "error": { "code": "NOT_UNIQUE", "message": "..." } } same key, different body
# 429 { "error": { "code": "RATE_LIMITED", "message": "Rate limit exceeded. Try again in 60 seconds." } }
A 2xx carries emailId: store it next to the order or the user, because it is what every webhook event for that message carries as data.id. Anything else is { error: { code, message } }: BAD_REQUEST for a from address on an unverified domain, UNAUTHORIZED for a bad key, NOT_UNIQUE for an idempotency key reused with a different body, RATE_LIMITED with a Retry-After header, RISK_REFUSED when a one-time code request is refused, and INSUFFICIENT_BALANCE for an SMS with an empty wallet. A body over 20 MB is refused at the edge with 413 and no JSON, which is why the client falls back to a synthetic error.
The framework gives you the resilience handler. The webhook is thirty lines of the base class library.
Keep the same Idempotency-Key on every attempt at the same logical send. A replay with the same body returns the original emailId; a different body answers 409; keys are up to 256 characters and expire after 24 hours. The idempotency page has the full semantics.
Transport exceptions, timeouts, 429 and 5xx are worth a second try with backoff. A 400, 401, 403 or 409 describes your request and will answer the same way next time. The standard resilience handler in Microsoft.Extensions.Http.Resilience does this for the named client if you would rather not hand-roll it.
Pass the request’s CancellationToken into the send so a client that hangs up cancels the outbound call, and keep the named client’s timeout short: a slow send should fail your handler, not hold it open.
Delivery, bounces, complaints, opens and clicks arrive as email.* events on an endpoint you register in the dashboard. Each request is signed with HMAC-SHA256 over timestamp.rawBody, sent as X-Notix-Signature: v1=… with the timestamp in X-Notix-Timestamp. Buffer the body, hash the raw bytes, compare in constant time, and answer 2xx within 10 seconds.
// Retry only what is safe to retry: a transport failure, 429 and 5xx.
// A 400, 401, 403 or 409 describes your request and will not change.
// The same Idempotency-Key on every attempt makes the retry harmless.
public static async Task<Result<SendEmailResponse, ApiError>> SendWithRetryAsync(
NotixClient notix, SendEmailRequest email, string key, CancellationToken ct)
{
Result<SendEmailResponse, ApiError> last = default;
for (var attempt = 0; attempt < 4; attempt++)
{
try
{
last = await notix.SendAsync(email, key, ct);
if (last.IsOk) return last;
var retryable = last.Match(_ => false,
e => e.Code is "RATE_LIMITED" or "INTERNAL_SERVER_ERROR" or "SERVICE_UNAVAILABLE");
if (!retryable) return last;
}
catch (HttpRequestException) { /* DNS, TLS, connection reset: retry */ }
catch (TaskCanceledException) when (!ct.IsCancellationRequested) { /* timeout: retry */ }
await Task.Delay(TimeSpan.FromMilliseconds(500 * (1 << attempt)), ct);
}
return last;
}
// Or let the platform do it: Microsoft.Extensions.Http.Resilience adds a
// standard retry and circuit breaker to the named client in one line,
// builder.Services.AddHttpClient("notix", ...).AddStandardResilienceHandler();
// Webhooks.cs
using System.Security.Cryptography;
using System.Text;
// Notix signs every delivery as HMAC-SHA256(secret, "<timestamp>.<rawBody>")
// and sends it as X-Notix-Signature: v1=<hex>, with the Unix timestamp in
// milliseconds in X-Notix-Timestamp. Verify the raw bytes: a re-serialised
// body no longer matches the signature.
app.MapPost("/webhooks/notix", async (HttpRequest request, IOptions<NotixOptions> options) =>
{
request.EnableBuffering();
using var reader = new StreamReader(request.Body, Encoding.UTF8, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
request.Body.Position = 0;
var timestamp = request.Headers["X-Notix-Timestamp"].ToString();
var signature = request.Headers["X-Notix-Signature"].ToString();
// Five minutes of tolerance keeps a captured request from being replayed later.
if (!long.TryParse(timestamp, out var sentAt) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - sentAt) > 5 * 60 * 1000)
{
return Results.Unauthorized();
}
var key = Encoding.UTF8.GetBytes(options.Value.WebhookSecret);
var expected = "v1=" + Convert.ToHexString(
HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"))).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(signature)))
{
return Results.Unauthorized();
}
switch (request.Headers["X-Notix-Event"].ToString())
{
case "email.delivered":
case "email.bounced":
case "email.complained":
// Deserialise rawBody; data.id is the emailId you were given at send time.
break;
}
// Answer 2xx within 10 seconds; queue the slow work.
return Results.Ok();
});
Keep the key and the webhook secret in configuration, give the service that only sends a sending-access key, and keep a full-access key for the jobs that manage domains and contacts. The same shape with a shell and nothing else is in the curl guide; the signature check is explained step by step on the webhook signature page.
If your service already sends through MailKit or System.Net.Mail, the SMTP relay is the shortest path: host smtp.usenotix.dev, port 587 with STARTTLS (or 465 with implicit TLS in MailKit), username notix, and an API key as the password. You give up the idempotency key and the emailId in the response, which only the API exposes, but every message still lands in the same log, honours the same suppression list and fires the same webhooks.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesOne POST with curl from a shell script, with the headers, the JSON body, the response and the error codes you will meet.
LearnWhy a retried request must not send twice, how the Idempotency-Key header works, and how to choose a key.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
ProductPoint any framework's mailer at one host and port; the relay turns SMTP into the same tracked, suppressed send.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
Verify a domain, copy an API key, run the app. The free plan does not ask for a card.