Official .NET SDK for MillionSend — a self-hostable, Resend-wire-compatible email API.
The API is wire-compatible with Resend, so migrating is mostly swapping the package. MillionSend Cloud works with just an API key; a self-hosted instance sets its origin. Targets net8.0.
dotnet add package MillionSendusing MillionSend;
var client = new MillionSendClient("ms_123"); // MillionSend Cloud
// var client = new MillionSendClient("ms_123", "https://mail.acme.dev"); // self-hosted
var res = await client.EmailSendAsync(new EmailMessage
{
From = "Acme <onboarding@acme.dev>",
To = "success@simulator.amazonses.com",
Subject = "Hello from MillionSend",
Html = "<strong>It works!</strong>",
});
if (res.Success)
Console.WriteLine($"sent {res.Content!.Id}");
else
Console.Error.WriteLine($"{res.Exception!.ErrorName}: {res.Exception.Message}");To, Cc, Bcc, and ReplyTo accept a single address or an array — assign a
string or a string[] directly.
Construct with the convenience constructor, or with MillionSendClientOptions:
var client = new MillionSendClient(new MillionSendClientOptions
{
ApiToken = "ms_123",
ApiUrl = "https://mail.acme.dev",
});ApiTokenfalls back to theMILLIONSEND_API_KEYenvironment variable. Missing key → throws at construction.ApiUrlfalls back toMILLIONSEND_BASE_URL, thenhttps://api.millionsend.com(MillionSend Cloud). Self-hosting? Set this to your deployment's origin.- Plain
http://is only accepted for loopback hosts (localhost,127.0.0.1,::1); any otherhttp://URL throwsArgumentExceptionat construction, since the API key is sent as a bearer header. SetAllowInsecureHttp = trueon the options to talk to a non-TLS instance elsewhere (e.g. inside a private network).
You may pass your own HttpClient as the second argument (for proxies, custom
handlers, or tests): new MillionSendClient(options, httpClient).
No method throws for an API or transport error. Every call returns a
MillionSendResponse<T>:
Success—truewhen the call succeeded.Content— the deserialized response body on success.Exception— aMillionSendExceptionon failure, carryingStatusCode(int?),ErrorName(the stable snake_case discriminant, e.g.validation_error,not_found,forbidden,rate_limit_exceeded,sending_paused,all_recipients_suppressed),RetryAfter(int?: the response'sretry-afterheader in seconds,nullwhen it has none) andMessage.
EmailSendAsync and EmailBatchAsync answer 422 all_recipients_suppressed when
every To recipient is on the suppression list or opted out of the send's TopicId.
When the suppression list alone empties To, EmailSendAsync also keeps a
metadata-only suppressed email, which fires email.suppressed; each retry keeps
another, even with the same idempotency key.
Inboxes add inbox_send_policy (403, a send the inbox's rules or the shared domain
hold), inbox_send_cap_reached (429, the inbox's or the team's daily cap),
approval_required (403), email_quarantined (403), email_blocked (410, a download
of an email that carries malware), too_many_waits (429, too many concurrent
long-polls) and name_review_unavailable (503, a name on the shared domain can't be
reviewed yet). A quarantine release adds trust_not_allowed (403) and
held_by_screening (409), see Inboxes; it answers forbidden (403) and
screening_unavailable (503) only to MCP clients.
Transport and client-side failures (the request never reached the API) carry
StatusCode == null and ErrorName == "application_error".
var res = await client.EmailRetrieveAsync(id);
if (!res.Success && res.Exception!.ErrorName == "not_found")
{
// ...
}The SDK never retries on its own. Worth retrying after RetryAfter seconds:
503 name_review_unavailable, 429 too_many_waits, and 429 rate_limit_exceeded
when RetryAfter is 60 or less (the per-minute limits). Not worth retrying in the same
process: 429 inbox_send_cap_reached and the daily name-review budget (429
rate_limit_exceeded with a RetryAfter that runs to the next UTC midnight), and any
403, 404, 409 or 422. An unknown route answers 404 not_found (application_error
from servers before v0.13.0, which answer it in plain text).
Resend a send with the same idempotency key: a batch the API already accepted gets
its stored response back. A held draft (IsDraft) is not a failure; never resend it.
InboxCreateAsync takes no idempotency key: after a 429 or 503 nothing was created,
but after a timeout a resend can create a second inbox. With a chosen Username, a
409 on the resend usually means the first attempt succeeded: read it with
InboxRetrieveAsync(address).
PATCH bodies distinguish "leave unchanged" (key omitted) from "clear" (JSON
null). Fields that the API lets you clear are typed Optional<T>?: assign a
plain value as usual, leave it unset (or assign C# null) to keep the current
value, or assign Optional<T>.Null to send an explicit null.
await client.ContactUpdateAsync(new ContactUpdateOptions
{
Id = contactId,
FirstName = "Ada", // sets
LastName = Optional<string>.Null, // clears
});
await client.BroadcastUpdateAsync(id, new BroadcastUpdateOptions { TopicId = Optional<Guid?>.Null });Available on contact FirstName/LastName, broadcast TopicId, template
Subject/Text/Alias, domain TrackingSubdomain, contact-property
FallbackValue, inbox ExpiresIn, safety SendCap, draft
To/Cc/Bcc/Subject/Html/Text and webhook InboxIds. A null
value inside Properties clears that key.
List methods take a ListOptions (Limit, and the keyset cursors After and
Before) and return Data and HasMore. Lists run newest first (MillionSend v0.13.0
and later): After takes the last id of a page and pages toward older items, Before
takes the first id and pages toward newer ones, and HasMore refers to that direction.
Threads sort by last activity, drafts by last update, a thread's emails run oldest
first, and labels and topics are not paginated.
// Incremental sync: page toward newer items, starting from the newest id you stored
Guid? before = newestStoredId;
while (true)
{
var page = await client.EmailListAsync(new ListOptions { Before = before, Limit = 100 });
if (!page.Success || page.Content!.Data.Count == 0) break;
Save(page.Content.Data); // newest first within the page
if (!page.Content.HasMore) break;
before = page.Content.Data[0].Id;
}A cursor whose item was deleted answers 422 validation_error (404 not_found on
the inbox lists): start again without a cursor and stop at the ids you already have.
await client.EmailSendAsync(message, idempotencyKey: "unique-key"); // POST /emails
await client.EmailSendAsync("unique-key", message); // same, resend-dotnet argument order
await client.EmailRetrieveAsync(id); // GET /emails/{id}
await client.EmailListAsync(new ListOptions { Limit = 50 }); // GET /emails
await client.EmailRescheduleAsync(id, "2026-09-01T09:00:00Z"); // PATCH /emails/{id}
await client.EmailCancelAsync(id); // POST /emails/{id}/cancel (scheduled only)
await client.EmailDeleteAsync(id); // DELETE /emails/{id}
await client.EmailInsightsRetrieveAsync(id); // GET /emails/{id}/insightsEmailMessage carries every wire field: From, To, Subject, Html, Text,
Cc, Bcc, ReplyTo, ScheduledAt, Tags, TopicId, Attachments
(Filename, base64 Content, ContentType, ContentId, Path), Headers, and
Template (passed through; the server answers 422 until template sending ships).
Subject takes up to 998 characters. A To, Cc or Bcc address on a domain that
can never receive mail (example.com, .test, .invalid, .localhost, a TLD that
doesn't exist, …) answers 422 validation_error; to test sending without emailing
anyone, use success@simulator.amazonses.com. An inbox's address on the shared domain
can be the From too, under the rules in
Sending from the shared domain.
Batches take up to 100 messages. BatchValidationMode.Permissive sends the
x-batch-validation header so valid items go out and invalid ones come back in
Errors (the default, Strict, rejects the whole batch):
var res = await client.EmailBatchAsync(new[] { a, b }, BatchValidationMode.Permissive, idempotencyKey: "k");
foreach (var err in res.Content!.Errors ?? new())
Console.WriteLine($"item {err.Index}: {err.Message}");Batch items never send from the shared domain: a strict batch fails with 403
inbox_send_policy (the message starts with emails.<index>: ), a permissive one
lists the item in Errors.
The sends take an Idempotency-Key: EmailSendAsync, EmailBatchAsync, replies,
forwards and InboxDraftSendAsync.
EmailRetrieveAsync includes a Score (0–10 best-practice score, null when
the email has no insights). EmailInsightsRetrieveAsync returns the full
pre-send report — score, band, and per-check results — or a not_found error
when insights are not available for the email.
Contacts are team-global: one record per email address (case-insensitive), no
audiences. Creating a duplicate returns a 409 validation_error.
await client.ContactAddAsync(new ContactCreateOptions
{
Email = "ada@acme.dev",
FirstName = "Ada",
Properties = new() { ["plan"] = "pro" },
Segments = new() { new SegmentRef { Id = segmentId } },
Topics = new() { new ContactTopicUpdate { Id = topicId, Subscription = TopicSubscription.OptIn } },
});
await client.ContactRetrieveAsync(new ContactAddress { Email = "ada@acme.dev" });
await client.ContactRetrieveAsync(new ContactAddress { Id = contactId }); // by id or email (email wins)
await client.ContactUpdateAsync(new ContactUpdateOptions { Id = contactId, Unsubscribed = true });
await client.ContactDeleteAsync(new ContactAddress { Email = "ada@acme.dev" }); // their emails stay in the log
// Erase (MillionSend extension): also scrub the address from email history, event
// payloads and API logs — a GDPR/LGPD erasure // ?erase=true
await client.ContactDeleteAsync(new ContactAddress { Email = "ada@acme.dev" }, new ContactDeleteOptions { Erase = true });
await client.ContactListAsync(new ListOptions { Limit = 50 });
// Bulk read (MillionSend extension): attach Properties and Topics to every row, so an
// audience reads in one request per 100 contacts instead of one per contact // ?include=properties,topics
await client.ContactListAsync(new ContactListOptions { Limit = 100, Include = new() { ContactInclude.Properties, ContactInclude.Topics } });
// Topic subscriptions (granular unsubscribe)
await client.ContactTopicsUpdateAsync(new ContactTopicsUpdateOptions
{
Email = "ada@acme.dev",
Topics = new() { new ContactTopicUpdate { Id = topicId, Subscription = TopicSubscription.OptOut } },
});
// Every topic with the contact's effective subscription: their explicit choice,
// else the topic default (Explicit == false) // GET /contacts/{id}/topics
var topics = await client.ContactListTopicsAsync(new ContactAddress { Email = "ada@acme.dev" });
foreach (var t in topics.Content!.Data)
Console.WriteLine($"{t.Name}: {t.Subscription}{(t.Explicit ? "" : " (default)")}");
// Segment membership
await client.ContactAddToSegmentAsync(new ContactAddress { Id = contactId }, segmentId); // POST /contacts/{id}/segments/{segmentId}
await client.ContactRemoveFromSegmentAsync(new ContactAddress { Id = contactId }, segmentId); // DELETE …
// Bulk import (MillionSend extension): up to 1000 items per call
var batch = await client.ContactBatchAsync(contacts, new ContactBatchOptions
{
OnConflict = ContactBatchOnConflict.Upsert, // ?on_conflict=error|skip|upsert
Validation = BatchValidationMode.Permissive, // x-batch-validation header
});
Console.WriteLine($"{batch.Content!.Counts.Created} created, {batch.Content.Counts.Failed} failed");
foreach (var err in batch.Content.Errors ?? new()) // permissive only: what strict mode would answer per item
Console.WriteLine($"item {err.Index}: {err.StatusCode} {err.Name} {err.Message}");
// Bulk lookup (MillionSend extension): up to 1000 contacts by id or email in one request,
// in request order; unknown entries are listed, not errors — one request against the rate limit
var found = await client.ContactBatchGetAsync( // POST /contacts/batch/get
new[] { new ContactAddress { Id = contactId }, new ContactAddress { Email = "b@acme.dev" } },
new ContactBatchGetOptions { Include = new() { ContactInclude.Topics } });
found.Content!.Data; // the contacts found: Id, Email, …, plus Properties/Topics per Include
found.Content.Missing; // [{ Index, Id?, Email? }] — request entries that matched nobody
// Bulk delete (MillionSend extension): up to 1000 per call, by emails or by ids;
// the response lists only the rows actually deleted. Their emails stay in the log;
// Erase also scrubs each address, as on a single delete // POST /contacts/batch/remove
await client.ContactBatchRemoveAsync(new[] { "a@acme.dev", "b@acme.dev" });
await client.ContactBatchRemoveAsync(new[] { id1, id2 }, new ContactDeleteOptions { Erase = true });
// Preference-center link (MillionSend extension): the contact's hosted preferences
// page, the same one their unsubscribe links open. No expiry — hand it only to the
// contact. 422 when the instance cannot build hosted links. // POST /contacts/{id}/preferences-link
var link = await client.ContactPreferencesLinkAsync(new ContactAddress { Email = "ada@acme.dev" });
Console.WriteLine(link.Content!.Url);A contact is addressable by id or email; when both are set, email wins.
ContactListTopicsAsync items carry the topic's Visibility; the hosted preference
page lists public topics only.
await client.ContactPropCreateAsync(new ContactPropertyCreateOptions
{
Key = "plan", Type = ContactPropertyType.String, FallbackValue = "free",
});
await client.ContactPropCreateAsync(new ContactPropertyCreateOptions
{
Key = "last_login", Type = ContactPropertyType.Date, // MillionSend extension
});
await client.ContactPropListAsync();
await client.ContactPropRetrieveAsync(id);
await client.ContactPropUpdateAsync(id, new ContactPropertyUpdateOptions { FallbackValue = "trial" });
await client.ContactPropDeleteAsync(id);Type is String or Number, as on Resend, or Date. A date property's values,
and its FallbackValue, must read as a date, or the write answers 422
validation_error:
- an ISO 8601 date such as
2026-01-08, read as midnight UTC; - a date-time such as
2026-01-08T10:30:00Zor2026-01-08 10:30, read as UTC when it names no zone; - Unix time in seconds or milliseconds, such as
1767225600, between 2000 and 2100.
A contact's date comes back in Properties as
{ "type": "date", "value": "2026-01-08" }, the text as stored. A type the server
adds after this SDK version reads as ContactPropertyType.Unknown.
await client.TopicAddAsync(new TopicCreateOptions
{
Name = "Product updates",
DefaultSubscription = TopicSubscription.OptIn,
Visibility = TopicVisibility.Public, // always shown on the unsubscribe page; default Private
});
await client.TopicRetrieveAsync(id);
await client.TopicListAsync(); // unpaginated: bare { data }
await client.TopicUpdateAsync(id, new TopicUpdateOptions { Name = "Product news" });
await client.TopicDeleteAsync(id);Targeting is an optional SegmentId and/or TopicId — set neither to send to
every contact of the team.
var broadcast = await client.BroadcastAddAsync(new BroadcastCreateOptions
{
From = "Acme <news@acme.dev>",
Subject = "Launch",
Html = "<p>Hi {{{FIRST_NAME|there}}}</p>",
PreviewText = "It's here",
SegmentId = segmentId, // optional
Send = true, // send now instead of saving a draft
ScheduledAt = "2026-09-01T09:00:00Z", // requires Send = true
});
await client.BroadcastListAsync();
await client.BroadcastRetrieveAsync(id);
await client.BroadcastUpdateAsync(id, new BroadcastUpdateOptions { Subject = "Launch 🚀" }); // draft only
await client.BroadcastSendAsync(id, scheduledAt: "2026-09-01T09:00:00Z"); // omit to send now
await client.BroadcastCancelAsync(id); // scheduled only
await client.BroadcastDeleteAsync(id); // draft onlySubject takes up to 998 characters. A broadcast never sends from the shared domain:
BroadcastAddAsync with Send = true and BroadcastSendAsync answer 403
inbox_send_policy.
Dynamic segments are a saved filter over the team's contacts — a MillionSend superset with no Resend equivalent.
await client.SegmentAddAsync(new SegmentCreateOptions
{
Name = "Pro plan",
Filter = new SegmentFilter
{
Match = SegmentMatch.All,
Conditions = new() { new SegmentCondition { Field = "property:plan", Op = "equals", Value = "pro" } },
},
});
await client.SegmentRetrieveAsync(id); // includes a live contact_count
await client.SegmentListAsync();
await client.SegmentUpdateAsync(id, new SegmentUpdateOptions { Name = "Pro tier" });
await client.SegmentContactListAsync(id, new ListOptions { Limit = 100 }); // GET /segments/{id}/contacts
await client.SegmentContactListAsync(id, new ContactListOptions { Include = new() { ContactInclude.Properties } }); // ?include=properties
await client.SegmentDeleteAsync(id);created_at and every property:<key> also take date ops. before and after
take an ISO 8601 date or date-time. in_last_days and not_in_last_days take a
whole number of days, from 1 to 36500, as a string:
new SegmentCondition { Field = "property:last_login", Op = "not_in_last_days", Value = "90" }await client.SuppressionAddAsync("bounced@acme.dev", SuppressionOrigin.Manual);
await client.SuppressionListAsync(new SuppressionListOptions { Limit = 50, Origin = SuppressionOrigin.Bounce });
await client.SuppressionRetrieveAsync("bounced@acme.dev"); // by id or email
await client.SuppressionRemoveAsync(id.ToString());
await client.SuppressionBatchAddAsync(new[] { "a@acme.dev", "b@acme.dev" }, SuppressionOrigin.Unsubscribe);
await client.SuppressionBatchRemoveAsync(new[] { "a@acme.dev" }); // by emails…
await client.SuppressionBatchRemoveAsync(new[] { id1, id2 }); // …or by idsMail received at your receiving domains and inboxes. Everything in a received email was written by its sender, so show its HTML only in a sandbox.
var page = await client.ReceivedEmailListAsync(new ReceivedEmailListOptions
{
InboxId = inboxId, From = "acme.dev", SubjectContains = "code", Limit = 20, // newest first
});
var email = await client.ReceivedEmailRetrieveAsync(id); // htmlFormat: "cid" keeps cid: references
Console.WriteLine(email.Content!.ExtractedText); // the reply without quotes, signature or hidden text
Console.WriteLine(email.Content.Screening?.SenderTrust); // owner, allowlisted, known_thread, external, …
Console.WriteLine(email.Content.Extracted?.Code); // a verification code, when one was found
// Long-poll: returns as soon as matching mail arrives (or an empty page after 30 s)
var next = await client.ReceivedEmailListAsync(new ReceivedEmailListOptions { InboxId = inboxId, Wait = 30 });
var held = next.Content!.Held; // matching mail held in quarantine, ids only
var reply = await client.ReceivedEmailReplyAsync(id, new EmailReplyOptions { Text = "Thanks, confirmed." },
idempotencyKey: $"reply-{id}");
if (reply.Content!.IsDraft)
Console.WriteLine(string.Join(", ", reply.Content.Draft!.ApprovalReasons!)); // held for a person (HTTP 202)
else
Console.WriteLine(reply.Content.Sent!.ThreadId);
await client.ReceivedEmailForwardAsync(id, new EmailForwardOptions { To = "ops@acme.dev", Mode = "attachment" });
await client.ReceivedEmailDeleteAsync(id); // erase now
await client.ReceivedEmailAttachmentListAsync(id); // signed download links, valid one hour
await client.ReceivedEmailAttachmentRetrieveAsync(id, attachmentId);Held mail is invisible to inbox-scoped keys: a detail read answers 403
email_quarantined. IncludeQuarantined = true lists it for full-access keys,
Quarantined = true lists only held mail.
Wait holds the request for up to 50 seconds, which the default HttpClient
timeout (100 s) covers. Unread needs InboxId with a team key; an inbox-scoped key
reads its own inbox. An InboxId that doesn't exist answers 404 not_found at once,
even with Wait.
An inbox is one address that receives mail, read by an agent (Kind = "agent",
the default) or by people ("human"), with threads in both directions. The
paths mirror Resend's Inboxes API; Kind, Shared, ExpiresAt, safety,
quarantine and codes are MillionSend extensions. Folder, verdict, kind and
status values are plain strings, so new server values never break decoding.
var inbox = await client.InboxCreateAsync(new InboxCreateOptions
{
Name = "Signup bot", // no EmailAddress: an address on the shared domain
Username = "signup-bot", // signup-bot@<team>.<shared domain>; omit it for a random one
ExpiresIn = "24h", // "1h" to "365d"; omitted, it never expires
Safety = new InboxSafetyOptions { AcceptFrom = "anyone" },
});
var inboxId = inbox.Content!.Id;
await client.InboxCreateAsync(new InboxCreateOptions { DomainId = domainId, Username = "orders" }); // orders@ a DomainListAsync row
await client.InboxListAsync(new InboxListOptions { Kind = "agent" });
await client.InboxRetrieveAsync("bot@acme.dev"); // by id or address
await client.InboxUpdateAsync(inboxId, new InboxUpdateOptions { ExpiresIn = Optional<string>.Null }); // never expires
await client.InboxDeleteAsync(inboxId);
// Verification codes and sign-in links (links are never fetched)
var code = await client.InboxCodeLatestAsync(inboxId, new InboxCodeLatestOptions { From = "example.com", Wait = 30 });
if (code.Content!.Status == "found")
Console.WriteLine(code.Content.Code ?? code.Content.Url);
// The next sign-in: only a code received after the one already used
await client.InboxCodeLatestAsync(inboxId, new InboxCodeLatestOptions { Since = code.Content.ReceivedAt, Wait = 30 });
// Safety rules (inbox-scoped keys can read them, never change them)
await client.InboxSafetyRetrieveAsync(inboxId);
await client.InboxSafetyUpdateAsync(inboxId, new InboxSafetyOptions
{
AcceptFrom = "allowlist", Allowlist = new() { "*@partner.dev" }, SendPolicy = "reply_only", SendCap = 100,
});
// Quarantine: held mail is kept and listed until a person releases it
await client.InboxQuarantineListAsync(inboxId);
await client.InboxQuarantineReleaseAsync(inboxId, emailId);
// Threads and their emails
await client.InboxThreadListAsync(inboxId, new InboxThreadListOptions { Folders = new() { "inbox", "archive" }, Read = false });
await client.InboxThreadListAsync(inboxId, new InboxThreadListOptions { IncludeQuarantined = true }); // owner views: a Quarantined count per thread
await client.InboxThreadSearchAsync(inboxId, new InboxThreadSearchOptions { Query = "invoice -draft", From = new() { "acme" } });
await client.InboxThreadRetrieveAsync(inboxId, threadId);
await client.InboxThreadUpdateAsync(inboxId, threadId, new InboxThreadUpdateOptions { Read = true, Folder = "archive" });
await client.InboxThreadDeleteAsync(inboxId, threadId); // to trash, purged after 30 days
await client.InboxThreadEmailListAsync(inboxId, threadId); // oldest first
await client.InboxThreadEmailRetrieveAsync(inboxId, threadId, emailId); // includeQuarantined: true reads held mail (owner views)
await client.InboxThreadEmailReplyAsync(inboxId, threadId, emailId, new EmailReplyOptions { Text = "On it" });
await client.InboxThreadEmailForwardAsync(inboxId, threadId, emailId, new EmailForwardOptions { To = "ops@acme.dev" });
// Labels
var label = await client.InboxLabelCreateAsync(inboxId, new InboxLabelOptions { Name = "Billing" });
await client.InboxLabelListAsync(inboxId);
await client.InboxLabelUpdateAsync(inboxId, label.Content!.Id, new InboxLabelOptions { Color = "#FF8800" });
await client.InboxLabelDeleteAsync(inboxId, label.Content.Id);
// Drafts, including the ones the send rules hold for approval
var draft = await client.InboxDraftCreateAsync(inboxId, new InboxDraftCreateOptions
{
To = "ada@acme.dev", Subject = "Hello", Text = "Hi Ada",
});
var draftId = draft.Content!.Id;
await client.InboxDraftListAsync(inboxId);
await client.InboxDraftRetrieveAsync(inboxId, draftId);
await client.InboxDraftUpdateAsync(inboxId, draftId, new InboxDraftUpdateOptions { Subject = Optional<string>.Null });
var sent = await client.InboxDraftSendAsync(inboxId, draftId); // InboxSendResult<InboxDraft>
await client.InboxDraftRejectAsync(inboxId, draftId); // approvers only
await client.InboxDraftDeleteAsync(inboxId, draftId);Reply, forward and draft send answer an InboxSendResult<T>: IsDraft (and
Kind == "draft") when the inbox's send rules held the message for a person to
approve (the API's 202), else Sent. An agent's reply to a Reply-To at another
organization is held that way too, with reply_to_redirect among its
ApprovalReasons. An inbox-scoped key sending a pending draft gets 403
approval_required. Hosted agent settings (/inboxes/{id}/agent) are not exposed:
MillionSend runs no agent of its own.
On an agent inbox with Screening on, mail held before screening ran
(not_allowlisted, unauthenticated_sender, failed_auth) is screened at release
when screening can run: if it finds prompt injection or phishing, the email stays held
as injection or phishing (409 held_by_screening). trustSender: true also adds
the sender to the allowlist, only for mail held as not_allowlisted from a sender that
authenticated (SenderTrust "external"); anything else answers 403
trust_not_allowed.
An inbox never expires unless ExpiresIn says so, and its Name takes up to 320
characters. DomainId takes a DomainListAsync row instead of EmailAddress: the
Shared row works like leaving both out, and one of your own domains needs a
Username (and receiving on, or Forwarding = true).
On the shared domain, an inbox unused for 90 days (no mail in or out, no API call on
it) is deleted, which fires inbox.expired, then inbox.deleted. A chosen Username
or FromName there is reviewed first: reserved names (postmaster, admin,
support, noreply, …), a fwd- prefix and look-alikes of brands answer 422, a
username in use 409, and 429 means today's review budget is spent. 503
name_review_unavailable means try again after RetryAfter seconds: the review is
down, or the team's new subdomain still waits for its own, and then every shared or
forwarding create gets it. Safety rules differ there too: SendPolicy = "allowlist"
answers 422, and so does "open" unless the team's SendingMode is open;
OwnerAddresses don't make senders trusted; ForwardTo takes team members only.
What the team's shared addresses may send is the SendingMode of the Shared row of
DomainListAsync: open starts conversations, reply_only only answers inside a
thread to its own correspondents, and receive_only sends nothing. The same answers
come back from EmailSendAsync, replies, forwards and drafts:
- 403
inbox_send_policy: the mode doesn't allow this send (or the inbox's name looks like a brand, which only replies even underopen); - 429
inbox_send_cap_reached: the team's daily cap for the shared domain, with aRetryAfterthat runs to the next UTC midnight; - 422
validation_error: more than 10 distinct recipients acrossTo,CcandBcc.
EmailSendAsync takes a shared address in open mode only: the inbox's exact
EmailAddress (no +tag), a display name left out or equal to its FromName, and no
ReplyTo, Attachments, Headers or ScheduledAt. A key restricted to a domain
gets 403 restricted_api_key. Batch items and broadcasts never send from the shared
domain. Switch on ErrorName and StatusCode; the messages may change.
var domain = await client.DomainAddAsync(new DomainCreateOptions
{
Name = "acme.dev",
Region = "us-east-1", // optional
CustomReturnPath = "send", // optional
OpenTracking = true, ClickTracking = true, TrackingSubdomain = "track",
});
foreach (var r in domain.Content!.Records!)
Console.WriteLine($"{r.Type} {r.Name} {r.Value}"); // DNS records to publish
await client.DomainListAsync();
await client.DomainRetrieveAsync(id);
await client.DomainVerifyAsync(id);
await client.DomainUpdateAsync(id, new DomainUpdateOptions { ClickTracking = false });
await client.DomainUpdateAsync(id, new DomainUpdateOptions { ReceivingCatchAll = true }); // paid plans on Cloud
await client.DomainDeleteAsync(id);Domain.ReceivingCatchAll: on, mail to addresses without an inbox lands in the
received list; off, those addresses are refused at SMTP time.
On an instance with a shared inbox domain, the team's subdomain of it is the last row
of DomainListAsync (Shared == true; MillionSend v0.14.0 and later). It is verified,
has no DNS records, and its id is the DomainId of the team's shared inboxes. Its
SendingMode (open, reply_only or receive_only) says what those inboxes may
send, and SendingModeReason (plan or instance, null under open) why it is
held below open. It is read-only: DomainUpdateAsync, DomainVerifyAsync and
DomainDeleteAsync answer 403 forbidden, and an API key can't be restricted to it.
It comes after every own domain whatever its CreatedAt, alone on the next page when
they exactly fill one. A team whose new subdomain still waits for its name review has
no such row yet. Older servers send no shared, which reads as false.
It sends only from its inboxes' own addresses, so leave it out when picking a domain to send from:
var domains = await client.DomainListAsync(new ListOptions { Limit = 100 });
var sender = domains.Content?.Data.FirstOrDefault(d => d.Status == "verified" && !d.Shared);var hook = await client.WebhookCreateAsync(new WebhookCreateOptions
{
Endpoint = "https://acme.dev/hooks/millionsend",
Events = new() { "email.delivered", "email.bounced", "email.complained" },
});
Console.WriteLine(hook.Content!.SigningSecret); // also returned by WebhookRetrieveAsync
await client.WebhookListAsync();
await client.WebhookRetrieveAsync(id);
await client.WebhookUpdateAsync(id, new WebhookUpdateOptions { Status = WebhookStatus.Disabled });
await client.WebhookDeleteAsync(id);
// Rotate the signing secret (MillionSend extension). For OverlapHours (0–72) every
// delivery is signed with both secrets, so the receiver switches without a gap.
var rotated = await client.WebhookRotateAsync(id, new WebhookRotateOptions { OverlapHours = 24 }); // POST /webhooks/{id}/rotate
Console.WriteLine($"{rotated.Content!.SigningSecret} (old one signs until {rotated.Content.PreviousSecretExpiresAt})");
await client.WebhookRotateAsync(id); // mint a secret with the server's default overlapWebhookRetrieveAsync also reports PreviousSecretExpiresAt while a rotation's
overlap window is open. Subscribable events are the email.* set plus
deliverability.*, quota.*, contact.created|updated|deleted|unsubscribed|resubscribed|topic_opt_in|topic_opt_out
and suppression.added|removed.
email.scheduled, email.failed and email.suppressed need MillionSend v0.15.1 or
later; an older server refuses a webhook naming them with 422 validation_error.
email.failed says why in data.failed.reason, and email.suppressed in
data.suppressed (type, message, reason); treat their values as open sets.
suppression.added and suppression.removed carry data.source_id, the email whose
bounce or complaint created the entry (null otherwise).
email.prefetched and the inbox events (inbox.created|updated|deleted|expired,
inbox.thread.created|folder.updated|labels.updated,
inbox.email.received|sent|quarantined, inbox.draft.created|updated|sent|deleted)
reach only endpoints that name them; an endpoint subscribed to all events skips
them but still gets email.received. InboxIds narrows the inbox events and
email.received to some inboxes; Optional<List<Guid>>.Null on update removes
the filter. Inbox events from sends made in the dashboard carry
data.source: "dashboard".
var key = await client.ApiKeyCreateAsync("ci", ApiKeyPermission.SendingAccess, domainId);
Console.WriteLine(key.Content!.Token); // shown once
await client.ApiKeyListAsync();
await client.ApiKeyDeleteAsync(key.Content.Id);
// A key restricted to one inbox: it reads that inbox and sends from its address only
await client.ApiKeyCreateAsync(new ApiKeyCreateOptions { Name = "support-agent", InboxId = inboxId });DomainId takes one of your own domains: the Shared row of DomainListAsync
answers 422 validation_error.
Addressed by id or alias. MillionSend templates have no draft/publish cycle:
TemplatePublishAsync is a no-op kept for resend-dotnet compatibility.
await client.TemplateCreateAsync(new TemplateCreateOptions
{
Name = "Welcome", Alias = "welcome", Subject = "Hi {{{FIRST_NAME}}}", Html = "<p>…</p>",
});
await client.TemplateListAsync();
await client.TemplateRetrieveAsync("welcome");
await client.TemplateUpdateAsync("welcome", new TemplateUpdateOptions { Subject = "Welcome!" });
await client.TemplateDuplicateAsync("welcome");
await client.TemplatePublishAsync("welcome");
await client.TemplateDeleteAsync("welcome");var usage = await client.UsageRetrieveAsync(); // GET /usage
Console.WriteLine($"{usage.Content!.Today.EmailsSent}/{usage.Content.Limits.EmailsPerDay} today");The account-level deliverability score over the trailing window. Scores are
0–10; Score/Band are null when there is not enough data yet.
var res = await client.DeliverabilityRetrieveAsync(); // GET /deliverability
if (res.Success)
Console.WriteLine($"{res.Content!.Score} ({res.Content.Band}), guardrail: {res.Content.GuardrailStatus}");The resend-dotnet SDK exposes resources as client.Emails.SendAsync(...) and
throws ResendException. MillionSend flattens the surface to
client.EmailSendAsync(...) and returns a MillionSendResponse<T> (no throw).
Method names and payload shapes otherwise line up. Notes:
- Idempotency: both argument orders work —
EmailSendAsync(message, idempotencyKey: k)and resend-dotnet'sEmailSendAsync(k, message); same forEmailBatchAsync. - Batch validation:
BatchValidationModeis resend-dotnet'sEmailBatchValidationMode; it also drivesContactBatchAsync. - Contact addressing: methods that take a contact (
ContactRetrieveAsync,ContactListTopicsAsync, …) accept aContactAddresswith an id or an email where resend-dotnet takes aGuid. - No audiences: contacts are team-global, one record per email address.
The
/audiences/*routes on the API are a compatibility shim and are not part of this SDK; Resend's audience grouping maps to MillionSend's dynamic segments (saved filters) above. - Templates exist but template-based sending does not yet:
EmailMessage.Templateis put on the wire and the server answers 422. - MillionSend extensions (no Resend counterpart): segments, the
Datecontact property type, contact batch import, batch get and batch remove,include=on contact lists,eraseon contact deletes, contact preference links, webhook secret rotation, usage, email insights, deliverability, and on received mail and inboxes the long-pollWait, received-mail reply/forward/delete, inbox safety, codes, quarantine, draft reject,UsernameandDomainIdonInboxCreateAsync, and the extension fields; alsoMillionSendException.RetryAfter.
On a self-hosted instance, each feature needs at least this MillionSend version:
| MillionSend | Features |
|---|---|
| v0.10.0 | Received mail and inboxes; InboxIds on webhooks, InboxId on API keys, ReceivingCatchAll on domains |
| v0.11.0 | 422 for recipients that can never receive mail; a batch idempotency key replayed before validation |
| v0.12.0 | Username; inboxes that never expire by default; sends from the shared domain under its mode; name_review_unavailable; StatusCode and Name on contacts batch errors |
| v0.13.0 | Newest-first lists; Since on InboxCodeLatestAsync; Quarantined on thread items; inbox names up to 320 characters; Unread with an inbox-scoped key; 404 at once for an unknown InboxId; a JSON 404 for unknown routes |
| v0.14.0 | Shared, SendingMode and SendingModeReason on domains; DomainId on InboxCreateAsync; Subject up to 998 characters |
| v0.15.1 | email.scheduled, email.failed and email.suppressed webhook events; source_id on suppression.added and suppression.removed; the suppressed email kept for an all-suppressed EmailSendAsync |
| v0.16.0 | Release rules: trustSender only for a not_allowlisted hold from an authenticated sender (trust_not_allowed); screening at release (held_by_screening) |
| v0.22.0 | ContactPropertyType.Date; the before, after, in_last_days and not_in_last_days segment ops on created_at and properties |
Older servers ignore options they don't know: before v0.12.0 Username is dropped
and the address is random, before v0.14.0 DomainId is dropped, and before v0.13.0
Since is dropped, so a code you already used can come back. If you must support them,
check the created inbox's EmailAddress, and treat a code whose ReceivedAt isn't
after Since as none.
dotnet test # unit tests (the e2e tests stay inert without MILLIONSEND_E2E)The e2e tests run only when MILLIONSEND_E2E=1 opts in (plus the API key):
MILLIONSEND_E2E=1 MILLIONSEND_API_KEY=ms_... dotnet test --filter Category=e2eMIT