# Send Email in C# and .NET: API Tutorial

> Send email in C# with HttpClient and an email API instead of SmtpClient: a typed ASP.NET Core client with retries, templates, attachments and batches.

Canonical: https://www.cresca.xyz/blog/send-email-csharp
Author: Lalit Kumar Jangid
Published: 2026-10-27
Last updated: 2026-10-27

Microsoft's own documentation advises against `System.Net.Mail.SmtpClient` for new development, because it does not support many modern protocols. To **send email in C#** today you can use MailKit over SMTP, or skip SMTP and call an email API with `HttpClient`. This tutorial does the second: one JSON request, an email id back, and delivery from your own verified domain. It covers the basic request, a typed client with retries for ASP.NET Core, templates, attachments and batches.

**TL;DR**

- `PostAsJsonAsync` to `https://api.cresca.xyz/api/v1/emails` with a bearer token.
- Register a typed `HttpClient` with `AddHttpClient` in ASP.NET Core.
- Retry 429, 5xx and network errors with an `Idempotency-Key`.
- Use a `Dictionary` for template variables so their names are not camel-cased.

## Before you start

1. **Verify a sending domain.** In Cresca, open [Sending Domains](https://app.cresca.xyz/sending-domains), add your domain and publish the DNS records it shows (DKIM, a custom MAIL FROM domain and an ownership record). If your DNS is on Cloudflare, Cresca can publish them for you.
2. **Add a sender** on that domain, for example `hello@acme.com`. Every `from` address you send from must be an active sender on a verified domain.
3. **Create an API key** on the [API keys](https://app.cresca.xyz/api-keys) page. Choose *Sending access* for production: it can send email but cannot read logs or change templates. The key starts with `cr_live_` and is shown once, so store it as the environment variable `CRESCA_API_KEY`.

The examples target .NET 8 or later and use only the base class library. The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Send email in C#: the basic request

```csharp
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("CRESCA_API_KEY"));

var response = await http.PostAsJsonAsync("https://api.cresca.xyz/api/v1/emails", new
{
    @from = "Acme <hello@acme.com>",
    to = "ana@example.com",
    subject = "Welcome to Acme",
    html = "<p>Hi Ana, your account is ready.</p>",
});

var body = await response.Content.ReadFromJsonAsync<JsonElement>();
if (!response.IsSuccessStatusCode)
{
    throw new Exception($"{body.GetProperty("name")}: {body.GetProperty("message")}");
}
Console.WriteLine(body.GetProperty("id").GetString());
```

The response is `200 OK` with `{"id": "4ef9a417-..."}`. `@from` is how C# writes a member named `from`. Watch the naming policy: `PostAsJsonAsync` camel-cases property names, which is harmless for lowercase names like these but would turn a template variable `NAME` into `name`. Template variables therefore go in a `Dictionary`, whose keys are sent as they are.

## A typed C# client with retries

The basic request is fine for a script. In production you want three more things, and `CrescaClient` below adds all of them:

- **An `Idempotency-Key` header.** If the connection drops after Cresca accepted the email, a retry with the same key returns the first result instead of sending a second email. Keys last 24 hours. Build them from the event, such as `welcome/user-123` or `invoice/inv-2026-0042`, never from the time or a random value. See [idempotency](https://www.cresca.xyz/docs/idempotency).
- **Retries only where they help.** Retry network errors, timeouts, `429 rate_limit_exceeded` and `5xx` with exponential backoff, and wait for the `retry-after` header when Cresca sends it. Do not retry other `4xx` errors: the same request fails the same way.
- **Errors you can branch on.** Every error body has a stable `name` and a readable `message`, for example `{"statusCode": 422, "name": "validation_error", "message": "..."}`. Branch on `name`, because messages can be reworded.

```csharp
using System.Net.Http.Json;
using System.Text.Json;

/// <summary>A small client for the Cresca email API.</summary>
public sealed class CrescaClient(HttpClient http)
{
    private const int Attempts = 4;

    public async Task<JsonElement> PostAsync(
        string path, object payload, string? idempotencyKey = null, CancellationToken ct = default)
    {
        for (var attempt = 1; ; attempt++)
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, path)
            {
                Content = JsonContent.Create(payload),
            };
            if (idempotencyKey is not null)
            {
                request.Headers.Add("Idempotency-Key", idempotencyKey);
            }

            HttpResponseMessage response;
            try
            {
                response = await http.SendAsync(request, ct);
            }
            catch (Exception e) when (e is HttpRequestException ||
                                      (e is TaskCanceledException && !ct.IsCancellationRequested))
            {
                // Network error or timeout: the idempotency key makes a retry safe.
                if (attempt == Attempts) throw;
                await Task.Delay(Backoff(attempt), ct);
                continue;
            }

            using (response)
            {
                var body = await ReadJsonAsync(response, ct);
                if (response.IsSuccessStatusCode) return body;

                var status = (int)response.StatusCode;
                var name = body.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "unknown_error";
                var retryable = status >= 500
                    || (status == 429 && name != "monthly_quota_exceeded")
                    || name == "concurrent_idempotent_requests";
                if (!retryable || attempt == Attempts)
                {
                    var message = body.TryGetProperty("message", out var m) ? m.GetString() ?? "" : "";
                    throw new CrescaException(status, name, message);
                }
                await Task.Delay(response.Headers.RetryAfter?.Delta ?? Backoff(attempt), ct);
            }
        }
    }

    private static async Task<JsonElement> ReadJsonAsync(HttpResponseMessage response, CancellationToken ct)
    {
        try
        {
            return await response.Content.ReadFromJsonAsync<JsonElement>(ct);
        }
        catch (Exception e) when (e is JsonException or NotSupportedException)
        {
            // A proxy error may have no JSON body.
            return JsonSerializer.Deserialize<JsonElement>("{}");
        }
    }

    // 1s, 2s, 4s... plus up to 250 ms of jitter.
    private static TimeSpan Backoff(int attempt) =>
        TimeSpan.FromMilliseconds(1000 * Math.Pow(2, attempt - 1) + Random.Shared.Next(250));
}

public sealed class CrescaException(int status, string name, string message)
    : Exception($"{status} {name}: {message}")
{
    public int Status { get; } = status;
    public string Name { get; } = name;
}
```

## Register it in ASP.NET Core

`AddHttpClient` manages the underlying connections for you. Note the trailing slash on the base address and no leading slash on paths such as `emails`: that is how `HttpClient` joins them.

```csharp
using System.Net.Http.Headers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHttpClient<CrescaClient>(http =>
{
    http.BaseAddress = new Uri("https://api.cresca.xyz/api/v1/");
    http.Timeout = TimeSpan.FromSeconds(10);
    http.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", builder.Configuration["Cresca:ApiKey"]);
});

var app = builder.Build();

app.MapPost("/signup", async (SignupRequest signup, CrescaClient cresca) =>
{
    var userId = await CreateUserAsync(signup); // your own code

    await cresca.PostAsync("emails", new
    {
        to = signup.Email,
        template = new
        {
            id = "welcome",
            variables = new Dictionary<string, object> { ["NAME"] = signup.FirstName },
        },
    }, $"welcome/{userId}");

    return Results.Created($"/users/{userId}", null);
});

app.Run();

public record SignupRequest(string Email, string FirstName);
```

Set `Cresca:ApiKey` with the environment variable `Cresca__ApiKey` or user secrets, never in appsettings.json in source control. For high volume, send from a hosted background service fed by a queue so requests never wait on email.

## Send a template with variables

Instead of building HTML in your code, you can design the email in Cresca's template editor, mark the parts your app fills in with triple braces such as `{{{RESET_LINK}}}`, and publish it. Your app then sends only the template alias and the values. Sends always use the *published* version, so you can edit a draft without touching live email. Values are HTML-escaped, so a user's name can never change your markup. A missing variable with no fallback returns `422 validation_error` naming it. More in [templates and variables](https://www.cresca.xyz/docs/templates).

```csharp
await cresca.PostAsync("emails", new
{
    to = user.Email,
    template = new
    {
        id = "password-reset", // the template's alias or id
        variables = new Dictionary<string, object>
        {
            ["NAME"] = user.FirstName,
            ["RESET_LINK"] = resetUrl,
        },
    },
}, $"password-reset/{resetRequestId}");
```

## Add attachments

Attachments are sent inline as base64: up to 20 files and 7 MB in total per email. Each item has `filename`, `content` and optionally `content_type`. Set `content_id` to show an inline image with `<img src="cid:logo">`. Executable types such as .exe and .js are refused, and attaching by URL is not supported yet.

```csharp
var pdf = await File.ReadAllBytesAsync("invoices/inv-2026-0042.pdf");

await cresca.PostAsync("emails", new
{
    @from = "Acme Billing <billing@acme.com>",
    to = customer.Email,
    subject = "Your invoice INV-2026-0042",
    html = "<p>Thanks for your payment. Your invoice is attached.</p>",
    attachments = new[]
    {
        new { filename = "inv-2026-0042.pdf", content = Convert.ToBase64String(pdf), content_type = "application/pdf" },
    },
}, "invoice/inv-2026-0042");
```

## Send a batch of up to 100 emails

`POST /emails/batch` takes a JSON array of up to 100 emails, each with the same fields as a single send except `attachments` and `scheduled_at`. It is all or nothing: if one email is invalid, nothing is sent and the error message names the failing email by its zero-based index. The response is `{"data": [{"id": "..."}, ...]}` in the same order. One request counts as one request against your rate limit, so batching is how you send thousands of emails per minute.

```csharp
var emails = users.Select(u => new
{
    @from = "Acme <hello@acme.com>",
    to = u.Email,
    subject = "Your weekly summary",
    html = RenderSummary(u),
});

var index = 0;
foreach (var chunk in emails.Chunk(100))
{
    var result = await cresca.PostAsync("emails/batch", chunk, $"weekly-summary/2026-10-27/{index}");
    Console.WriteLine($"Batch {index}: {result.GetProperty("data").GetArrayLength()} emails accepted");
    index++;
}
```

Escape user input with `System.Net.WebUtility.HtmlEncode` before putting it in HTML, or use templates, which escape variables for you.

## SmtpClient, MailKit or an email API?

`SmtpClient` is not recommended for new code. MailKit is the usual replacement and a good choice if you have an SMTP server. An HTTP API needs nothing beyond `HttpClient`, returns JSON errors with a stable `name`, and makes retries safe with idempotency keys. Cresca does not offer an SMTP relay yet, so with Cresca you use the API as shown here.

## Errors to handle when you send email with C#

These are the errors a sending app actually meets. The full list is on the [errors page](https://www.cresca.xyz/docs/errors).

| Status | name | What it means | Retry? |
| --- | --- | --- | --- |
| 403 | `invalid_from_address` | The from address is not an active sender on a verified domain. | No, fix the sender |
| 422 | `validation_error` | A field is invalid, or a template variable is missing. | No, fix the request |
| 422 | `missing_required_field` | No to, from or subject (and no template that supplies them). | No |
| 429 | `rate_limit_exceeded` | More requests per second than your plan allows. | Yes, after retry-after |
| 429 | `monthly_quota_exceeded` | The plan's email allowance is used up. | No |
| 409 | `concurrent_idempotent_requests` | The same Idempotency-Key is still being processed. | Yes, shortly |
| 5xx | `application_error` | A problem on Cresca's side. | Yes, with backoff |

Recipients on your suppression list (earlier hard bounces, complaints and unsubscribes) are skipped, but the request still succeeds. See [suppression](https://www.cresca.xyz/docs/suppression).

## Check that the email was delivered

A `200` response means Cresca accepted the email, not that it reached the inbox. Open [Transactional](https://app.cresca.xyz/transactional) in the app to see each email's timeline, or look it up with a full-access key:

```bash
curl https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
  -H "Authorization: Bearer $CRESCA_API_KEY"
```

The `last_event` field moves from `queued` to `sent` and then `delivered` or `bounced`. See [email lifecycle](https://www.cresca.xyz/docs/email-lifecycle) for every status.

## Frequently asked questions

### How do I send email in C# without SMTP?

Use an email API with HttpClient. With Cresca you POST a JSON body with from, to, subject and html to https://api.cresca.xyz/api/v1/emails with your API key as a bearer token, using PostAsJsonAsync or a typed client registered with AddHttpClient.

### Is SmtpClient deprecated?

Microsoft's documentation says SmtpClient is not recommended for new development because it does not support many modern protocols, and suggests MailKit or other libraries. It still works, but new code should use MailKit for SMTP or an email API over HTTPS.

### Is there an official Cresca SDK for .NET?

No, and you do not need one. The API is plain HTTPS and JSON, so the HTTP client you already use in .NET works. The helper in this tutorial is about 40 lines you can copy into your project and own.

### Why are my template variables not filled in?

System.Text.Json's web defaults camel-case property names, so an anonymous object with NAME is sent as name. Put template variables in a Dictionary, whose keys are sent unchanged, and the names will match the template.

### How many emails can I send per request?

One email can have up to 50 recipients across to, cc and bcc, and a batch request carries up to 100 emails. Requests per second depend on your plan: 2 on Free, 10 on Professional, 20 on Premium, 50 on Ultra and 100 on Enterprise. Each recipient counts as one email against the monthly allowance.

### Is Cresca free to try?

Yes. The Free plan gives you 30 days, 50 contacts and 50 emails with no credit card, which is enough to build and test your integration. API email shares your plan's email allowance with campaigns; paid plans are on the pricing page.

## The same tutorial in other languages

[Node.js](https://www.cresca.xyz/blog/send-email-nodejs) · [Python](https://www.cresca.xyz/blog/send-email-python) · [PHP](https://www.cresca.xyz/blog/send-email-php) · [Go](https://www.cresca.xyz/blog/send-email-golang) · [Ruby](https://www.cresca.xyz/blog/send-email-ruby) · [Java](https://www.cresca.xyz/blog/send-email-java) · [Next.js](https://www.cresca.xyz/blog/send-email-nextjs) · [Laravel](https://www.cresca.xyz/blog/send-email-laravel) · [Django](https://www.cresca.xyz/blog/send-email-django) · [Ruby on Rails](https://www.cresca.xyz/blog/send-email-rails)

For every field of the Emails API with cURL examples, read the [send email API guide](https://www.cresca.xyz/blog/send-email-api). For the big picture, start with the [transactional email API overview](https://www.cresca.xyz/blog/transactional-email-api). Still deciding how to send? Read [SMTP vs API](https://www.cresca.xyz/blog/smtp-vs-api-sending).

## Start sending

Verify a domain, create a sending-access key and send your first email with C# in a few minutes. **[Try Cresca free for 30 days](https://app.cresca.xyz/signup)**, no credit card required, then follow the [quickstart](https://www.cresca.xyz/docs/quickstart) or browse the [API docs](https://www.cresca.xyz/docs).
