Official Java SDK for MillionSend — a self-hostable, Resend-compatible email API on AWS SES.
The API is wire-compatible with Resend, and this SDK deliberately mirrors the
shape of resend-java, so migrating is
mostly a find-and-replace: swap the dependency and the class name. MillionSend
Cloud works with just the key; a self-hosted instance also sets its origin.
Maven:
<dependency>
<groupId>com.millionsend</groupId>
<artifactId>millionsend-java</artifactId>
<version>0.10.1</version>
</dependency>Gradle:
implementation 'com.millionsend:millionsend-java:0.10.1'Requires Java 11+.
import com.millionsend.MillionSend;
import com.millionsend.MillionSendException;
import com.millionsend.model.CreateEmailOptions;
import com.millionsend.model.CreateEmailResponse;
MillionSend ms = new MillionSend("ms_123"); // MillionSend Cloud
// MillionSend ms = new MillionSend("ms_123", "https://mail.acme.dev"); // self-hosted
try {
CreateEmailResponse sent = ms.emails().send(
CreateEmailOptions.builder()
.from("Acme <onboarding@acme.dev>")
.to("success@simulator.amazonses.com")
.subject("Hello from MillionSend")
.html("<strong>It works!</strong>")
.build());
System.out.println("sent " + sent.getId());
} catch (MillionSendException e) {
System.err.println(e.getName() + ": " + e.getMessage());
}new MillionSend(); // apiKey + baseUrl from the environment
new MillionSend(apiKey); // baseUrl from MILLIONSEND_BASE_URL or MillionSend Cloud
new MillionSend(apiKey, baseUrl); // explicit base URL (self-hosted)
new MillionSend(apiKey, baseUrl, true); // also accept a non-loopback http:// base URLapiKeyfalls back toMILLIONSEND_API_KEY. A missing key throwsIllegalArgumentExceptionat construction.baseUrlfalls back toMILLIONSEND_BASE_URL, then MillionSend Cloud (https://api.millionsend.com). Self-hosting? Set it to your instance's origin.- Plain
http://is only accepted for loopback hosts (localhost,127.0.0.1,::1); any otherhttp://URL throwsIllegalArgumentExceptionat construction, since the API key is sent as a bearer header. PassallowInsecureHttp = true(third constructor argument) to talk to a non-TLS instance elsewhere (e.g. inside a private network).
Every call throws a single checked MillionSendException on a non-2xx response.
It carries the API's error shape:
getName()— a stable snake_case code you can switch on (validation_error,not_found,forbidden,restricted_api_key,rate_limit_exceeded,sending_paused,all_recipients_suppressed, …)getMessage()— a human-readable description; it may change, so don't parse itgetStatusCode()— the HTTP status, ornullwhen the request never reached the API (a client-side or transport failure)getRetryAfter()— the response'sretry-afterheader in seconds, ornullwhen it has none
try {
ms.emails().get(id);
} catch (MillionSendException e) {
if ("not_found".equals(e.getName())) {
// …
}
}POST /emails and POST /emails/batch answer 422 all_recipients_suppressed
("All recipients are suppressed") when every to recipient is on the
suppression list or opted out of the send's topicId. From v0.15.1, when the
suppression list alone empties to, POST /emails also keeps a metadata-only
suppressed email that fires email.suppressed; each retry keeps another, even with
the same Idempotency-Key (a refused send doesn't hold its key).
Inboxes add inbox_send_policy (403, a send the inbox's rules or the shared domain
refuse; one they hold is a 202 draft), inbox_send_cap_reached (429, the inbox's daily
cap, or the team's on the shared domain), 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 right now). 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.
An unknown route answers 404 not_found. Servers before v0.13.0 answer it in plain
text, which surfaces as application_error with status 404.
The SDK never retries on its own. Worth retrying after getRetryAfter() seconds:
503 name_review_unavailable, 429 too_many_waits, and 429 rate_limit_exceeded when
getRetryAfter() 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 (also
rate_limit_exceeded, with a getRetryAfter() that runs to the next UTC midnight), and
any 403, 404, 409 or 422.
Retry a send with the same idempotency key (emails().send, batch().send, replies,
forwards, drafts().send): a batch the API already accepted gets its stored response
back, even if a recipient was refused or suppressed since. A held draft (isDraft()) is
not a failure; never send it again. inboxes().create takes no idempotency key: after a
429 or 503 nothing was created, but after a timeout a retry can create a second inbox.
With a chosen username, a 409 on the retry usually means the first attempt succeeded:
read it with inboxes().get(address).
Every request field you set reaches the wire — nothing is dropped client-side.
Fields the server does not support yet (template on send, from/replyTo/variables
on templates) are forwarded as-is and answered with a 422.
emails().send, batch().send, contacts().batch().create and the inbox
sends (receiving().reply/forward, threads().emails().reply/forward,
drafts().send) accept a RequestOptions (the same builder shape as resend-java):
import com.millionsend.core.RequestOptions;
import com.millionsend.model.BatchValidation;
RequestOptions opts = RequestOptions.builder()
.idempotencyKey("order-123") // Idempotency-Key (setIdempotencyKey also works)
.batchValidation(BatchValidation.PERMISSIVE) // x-batch-validation on the batch endpoints
.add("X-Trace", "abc") // any extra header
.build();
ms.emails().send(options, opts);
ms.emails().send(options, "order-123"); // the plain idempotency-key overload still worksList methods take limit, after and before (keyset cursors) and return a
ListResponse (getData(), isHasMore()). 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 isHasMore() 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
String before = newestStoredId;
while (true) {
ListResponse<Email> page = ms.emails().list(ListOptions.builder().before(before).limit(100).build());
if (page.getData().isEmpty()) break;
save(page.getData()); // newest first within the page
if (!page.isHasMore()) break;
before = page.getData().get(0).getId();
}A cursor whose item was deleted answers 422 validation_error (404 not_found on the
inboxes() lists): start again without a cursor and stop at the ids you already have.
Servers before v0.13.0 list oldest first; walking every page with after returns every
item on both.
ms.emails().send(options); // POST /emails
ms.emails().send(options, requestOptions); // with an Idempotency-Key / extra headers
ms.emails().get(id); // GET /emails/{id} — includes a nullable score (0-10)
ms.emails().list(ListOptions.builder().limit(50).build()); // GET /emails
ms.emails().update(id, UpdateEmailOptions.builder().scheduledAt("2026-09-01T09:00:00Z").build());
ms.emails().getInsights(id); // GET /emails/{id}/insights (404 not_found until computed)
ms.emails().cancel(id); // POST /emails/{id}/cancel (scheduled only)
ms.emails().remove(id); // DELETE /emails/{id} (MillionSend extension)Send options are camelCase and mapped to the wire: replyTo → reply_to,
scheduledAt → scheduled_at, topicId → topic_id. to/cc/bcc/replyTo
accept one or more addresses. Also available: .tag(new Tag(name, value)),
.attachment(Attachment.builder().fileName("a.pdf").content(base64).build())
(or .path(url), plus contentType/contentId), .header(name, value) and
.template(...) (forwarded; the server answers 422 — send html/text).
resend-java's addTag/addAttachment/addHeader spellings work too.
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.
CreateBatchEmailsResponse res = ms.batch().send(List.of(a, b),
RequestOptions.builder().idempotencyKey(key).batchValidation(BatchValidation.PERMISSIVE).build());
res.getData(); // accepted ids
res.getErrors(); // permissive mode: [{ index, message }] for rejected items; null when all acceptedStrict mode (the default) rejects the whole batch on the first invalid item.
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 getErrors().
Contacts are team-global — one list per team, no audiences.
ms.contacts().create(CreateContactOptions.builder()
.email("ada@acme.dev").firstName("Ada")
.properties(Map.of("plan", "pro"))
.segment(segmentId) // initial segment membership
.topic(topicId, Subscription.OPT_OUT) // initial topic choice
.build()); // 409 validation_error on duplicate email
ms.contacts().get(ContactAddress.email("ada@acme.dev"));
ms.contacts().get(contactId); // bare id shorthand
ms.contacts().update(UpdateContactOptions.builder()
.id(id).unsubscribed(true).firstName(null).build()); // null clears a field
ms.contacts().remove(ContactAddress.email("ada@acme.dev")); // the contact's emails stay in the log
ms.contacts().remove(RemoveContactOptions.builder()
.email("ada@acme.dev").erase(true).build()); // ?erase=true — also scrubs the address from history
ms.contacts().list(ListOptions.builder().limit(50).build());
// Bulk read (MillionSend extension): attach properties and topic subscriptions to every item,
// so an audience reads in one request per 100 contacts instead of one per contact
ms.contacts().list(ListContactsOptions.builder().limit(100)
.include(ContactInclude.PROPERTIES, ContactInclude.TOPICS).build()); // ?include=properties,topics
// Topic subscriptions (granular unsubscribe)
ms.contacts().topics().update(UpdateContactTopicsOptions.builder()
.email("ada@acme.dev").topic(topicId, Subscription.OPT_OUT).build());
ListResponse<ContactTopic> topics = ms.contacts().topics().list("ada@acme.dev"); // GET /contacts/{idOrEmail}/topics
topics.getData().get(0).getSubscription(); // effective choice: the contact's own, else the topic default
topics.getData().get(0).isExplicit(); // false when it is the topic default
topics.getData().get(0).getVisibility(); // "public" | "private" — the hosted page lists public topics only
// Preference-center link (MillionSend extension): the contact's hosted preference page,
// the same one their emails' unsubscribe links open. No expiry — show it only to the contact.
PreferencesLink link = ms.contacts().preferencesLink(ContactAddress.email("ada@acme.dev")); // POST /contacts/{idOrEmail}/preferences-link
link.getUrl(); // 422 when the instance cannot build hosted links
// Segment membership
ms.contacts().segments().add(contactIdOrEmail, segmentId); // POST /contacts/{id}/segments/{segmentId}
ms.contacts().segments().remove(contactIdOrEmail, segmentId); // DELETE …
// Bulk creation (MillionSend extension): up to 1000 per call
CreateBatchContactsResponse res = ms.contacts().batch().create(items,
ConflictMode.UPSERT, // ?on_conflict=error|skip|upsert
RequestOptions.builder().batchValidation(BatchValidation.PERMISSIVE).build());
res.getData(); // [{ index, id, status: created|updated|skipped }]
res.getCounts(); // created / updated / skipped / failed
res.getErrors(); // permissive mode: [{ index, statusCode, name, message }], as strict mode would answer
// 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
BatchGetContactsResponse found = ms.contacts().batch().get(
List.of(ContactAddress.id(contactId), ContactAddress.email(email)), ContactInclude.TOPICS);
found.getData(); // [{ object: "contact", id, email, ..., topics }] — the contacts found
found.getMissing(); // [{ index, email }] — request entries that matched nobody
// Bulk removal (MillionSend extension): up to 1000 per call, by ids or by emails;
// their emails stay in the log unless erase also scrubs the addresses from history
ms.contacts().batch().remove(RemoveContactsOptions.builder().emails(List.of(a, b)).build()); // or .ids(...)
ms.contacts().batch().remove(RemoveContactsOptions.builder().ids(List.of(a)).erase(true).build());
// → getData(): [{ contact, deleted: true }] for the rows actually deletedContact.getProperties() returns Map<String, ContactPropertyValue> — each
value carries its declared type (string/number/date) and the value, a
String for a date.
Contact.getTopics() returns the same rows as contacts().topics().list(...);
on list and batch reads both are null unless include asked for them.
ms.contactProperties().create(CreateContactPropertyOptions.builder()
.key("plan").type("string").fallbackValue("free").build());
ms.contactProperties().get(id);
ms.contactProperties().list();
ms.contactProperties().update(UpdateContactPropertyOptions.builder().id(id).fallbackValue(null).build()); // null clears
ms.contactProperties().remove(id);type is string, number or date (MillionSend extension). A date property's
values and fallbackValue must read as an ISO 8601 date (2026-01-08, midnight UTC),
an ISO 8601 date-time (2026-01-08T10:30:00Z or 2026-01-08 10:30, UTC when it names
no zone) or Unix time in seconds or milliseconds from 2000 to 2100; anything else is
refused with 422 validation_error.
ms.topics().create(CreateTopicOptions.builder()
.name("Product updates").defaultSubscription(Subscription.OPT_IN).visibility("public").build());
ms.topics().get(id);
ms.topics().list(); // bare { data } — topics are unpaginated
ms.topics().update(id, UpdateTopicOptions.builder().name("Product news").build());
ms.topics().remove(id);Id b = ms.broadcasts().create(CreateBroadcastOptions.builder()
.from("Acme <news@acme.dev>").subject("Launch")
.segmentId(segmentId) // optional; also .topicId(...) — neither sends to all contacts
.previewText("It's here")
.html("<p>Hi {{{FIRST_NAME|there}}}</p>")
.send(true).scheduledAt("in 1 hour") // send/schedule on create instead of saving a draft
.build());
ms.broadcasts().list();
ms.broadcasts().get(id);
ms.broadcasts().update(id, UpdateBroadcastOptions.builder().subject("Launch 🚀").topicId(null).build()); // draft only; topicId(null) clears
ms.broadcasts().send(id, SendBroadcastOptions.builder().scheduledAt("2026-09-01T09:00:00Z").build());
ms.broadcasts().send(id); // send now
ms.broadcasts().cancel(id); // scheduled only
ms.broadcasts().remove(id); // draft onlysubject takes up to 998 characters. A broadcast never sends from the shared domain:
create with send(true) and send answer 403 inbox_send_policy.
Dynamic segments are a saved filter over your contacts — a MillionSend superset with no Resend equivalent.
ms.segments().create(CreateSegmentOptions.builder()
.name("Pro plan")
.filter(SegmentFilter.builder().match("all")
.condition(new SegmentCondition("property:plan", "equals", "pro")).build())
.build());
ms.segments().get(id); // includes a live contactCount
ms.segments().list();
ms.segments().contacts(id, ListOptions.builder().limit(100).build()); // matching contacts
ms.segments().contacts(id, ListContactsOptions.builder()
.include(ContactInclude.PROPERTIES).build()); // + properties per item
ms.segments().update(id, UpdateSegmentOptions.builder().name("Pro tier").build());
ms.segments().remove(id);ms.suppressions().add(AddSuppressionOptions.builder()
.email("bounced@example.com").origin(SuppressionOrigin.MANUAL).build());
ms.suppressions().get(idOrEmail);
ms.suppressions().list(ListSuppressionsOptions.builder().origin(SuppressionOrigin.BOUNCE).limit(50).build());
ms.suppressions().remove(idOrEmail);
ms.suppressions().batch().add(AddSuppressionsOptions.builder().emails(List.of(a, b)).build()); // up to 1000
ms.suppressions().batch().remove(RemoveSuppressionsOptions.builder().emails(List.of(a)).build()); // or .ids(...)Mail 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.
ReceivedEmailListResponse page = ms.receiving().list(ListReceivedEmailsOptions.builder()
.inboxId(inboxId).from("acme.dev").subjectContains("code").limit(20).build()); // newest first
ReceivedEmail email = ms.receiving().get(id); // GET /emails/receiving/{id}
ms.receiving().get(id, "cid"); // leave inline images as cid: references
email.getExtractedText(); // the reply without quotes, signature or hidden text
email.getScreening().getSenderTrust(); // owner, allowlisted, known_thread, external, …
email.getExtracted().getCode(); // a verification code, when one was found
// Long-poll: returns as soon as matching mail arrives (or an empty page after 30 s)
ReceivedEmailListResponse next = ms.receiving().list(ListReceivedEmailsOptions.builder()
.inboxId(inboxId).waitSeconds(30).build());
next.getHeld(); // matching mail held in quarantine, ids only
SendResult<ThreadEmail> r = ms.receiving().reply(id,
ReplyOptions.builder().text("Thanks, confirmed.").build(),
RequestOptions.idempotencyKey("reply-" + id));
if (r.isDraft()) {
r.getDraft().getApprovalReasons(); // held for a person to approve (HTTP 202)
} else {
r.getSent().getThreadId();
}
ms.receiving().forward(id, ForwardOptions.builder().to("ops@acme.dev").mode("attachment").build());
ms.receiving().remove(id); // erase now
ms.receiving().attachments().list(id); // signed download links, valid one hour
ms.receiving().attachments().get(id, attachmentId);Held mail is invisible to inbox-scoped keys: a detail read answers 403
email_quarantined. For full-access keys, includeQuarantined(true) lists it
too and quarantined(true) lists only held mail. 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 waitSeconds.
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.
Inbox inbox = ms.inboxes().create(CreateInboxOptions.builder()
.name("Signup bot")
.username("signup-bot") // no emailAddress: signup-bot@<team>.<shared domain>; omit it for a random one
.expiresIn("24h") // "1h" to "365d"; left out, the inbox never expires
.safety(InboxSafetyOptions.builder().acceptFrom("anyone").build())
.build());
ms.inboxes().create(CreateInboxOptions.builder().domainId(domainId).username("orders").build()); // a domains().list() id: orders@ that domain
ms.inboxes().list(ListInboxesOptions.builder().kind("agent").build());
ms.inboxes().get("bot@acme.dev"); // by id or address
ms.inboxes().update(inbox.getId(), UpdateInboxOptions.builder().expiresIn(null).build()); // null: never expires
ms.inboxes().remove(inbox.getId());
// Verification codes and sign-in links (links are never fetched)
VerificationCode code = ms.inboxes().codes().latest(inboxId,
LatestCodeOptions.builder().from("github.com").waitSeconds(30).build());
if ("found".equals(code.getStatus())) {
code.getCode(); // or code.getUrl() for a sign-in link
}
// The next sign-in: only a code received after the one already used
ms.inboxes().codes().latest(inboxId,
LatestCodeOptions.builder().since(code.getReceivedAt()).waitSeconds(30).build());
// Safety rules (inbox-scoped keys can read them, never change them)
ms.inboxes().safety().get(inboxId);
ms.inboxes().safety().update(inboxId, InboxSafetyOptions.builder()
.acceptFrom("allowlist").allowlist("*@partner.dev").sendPolicy("reply_only").sendCap(100).build());
// Quarantine: held mail is kept and listed until a person releases it
ms.inboxes().quarantine().list(inboxId);
ms.inboxes().quarantine().release(inboxId, emailId);
// Threads and their emails
ms.inboxes().threads().list(inboxId, ListThreadsOptions.builder().folders("inbox", "archive").read(false).build());
ms.inboxes().threads().list(inboxId, ListThreadsOptions.builder().includeQuarantined(true).build()); // owner views: getQuarantined() per thread
ms.inboxes().threads().search(inboxId, SearchThreadsOptions.builder().query("invoice -draft").from("acme").build());
ms.inboxes().threads().get(inboxId, threadId);
ms.inboxes().threads().update(inboxId, threadId, UpdateThreadOptions.builder().read(true).folder("archive").build());
ms.inboxes().threads().remove(inboxId, threadId); // to trash, purged after 30 days
ms.inboxes().threads().emails().list(inboxId, threadId); // oldest first
ms.inboxes().threads().emails().get(inboxId, threadId, emailId);
ms.inboxes().threads().emails().reply(inboxId, threadId, emailId, ReplyOptions.builder().text("On it").build());
ms.inboxes().threads().emails().forward(inboxId, threadId, emailId, ForwardOptions.builder().to("ops@acme.dev").build());
// Labels
InboxLabel label = ms.inboxes().labels().create(inboxId, InboxLabelOptions.builder().name("Billing").build());
ms.inboxes().labels().list(inboxId);
ms.inboxes().labels().update(inboxId, label.getId(), InboxLabelOptions.builder().color("#FF8800").build());
ms.inboxes().labels().remove(inboxId, label.getId());
// Drafts, including the ones the send rules hold for approval
InboxDraft d = ms.inboxes().drafts().create(inboxId, CreateDraftOptions.builder()
.to("ada@acme.dev").subject("Hello").text("Hi Ada").build());
ms.inboxes().drafts().list(inboxId);
ms.inboxes().drafts().get(inboxId, d.getId());
ms.inboxes().drafts().update(inboxId, d.getId(), UpdateDraftOptions.builder().subject(null).build()); // null clears
SendResult<InboxDraft> s = ms.inboxes().drafts().send(inboxId, d.getId());
ms.inboxes().drafts().reject(inboxId, d.getId()); // approvers only
ms.inboxes().drafts().remove(inboxId, d.getId());Reply, forward and draft send return a SendResult: isDraft() is true when
the inbox's send rules held the message for a person (the API answers 202); an
inbox-scoped key sending a pending draft gets 403 approval_required. An agent's
reply to a Reply-To at another organization is held that way too, with
reply_to_redirect among its approval reasons. Folder, verdict, kind and status
values are plain strings, so new server values never break decoding. 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). release(inboxId, emailId, true) also adds the sender to the
allowlist, only for mail held as not_allowlisted from a sender that authenticated
(getSenderTrust() "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 domains().list() 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 rate_limit_exceeded means today's review budget is spent.
503 name_review_unavailable means try again after getRetryAfter() 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 shared row'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 getSendingMode() of the shared row of
domains().list(): open starts conversations, reply_only only answers inside a
thread to its own correspondents, and receive_only sends nothing. An inbox whose name
looks like a brand only replies, even under open. The same answers come back from
emails().send, replies, forwards and drafts:
| Status | getName() |
When |
|---|---|---|
| 403 | inbox_send_policy |
the mode doesn't allow this send (getSendingModeReason() says whether the plan or the instance holds it), the inbox's name looks like a brand, or it is a batch or broadcast send |
| 429 | inbox_send_cap_reached |
the team's daily cap for mail from the shared domain; getRetryAfter() runs to the next UTC midnight |
| 422 | validation_error |
more than 10 distinct recipients across to, cc and bcc (drafts().create too); on emails().send, a from or a field the shared domain doesn't take |
emails().send 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. A person or a team key sending a draft skips the inbox's own
rules, never the shared domain's. Switch on getName() and getStatusCode(); the
messages may change.
Domain d = ms.domains().create(CreateDomainOptions.builder()
.name("acme.dev").region("us-east-1").customReturnPath("send")
.clickTracking(true).trackingSubdomain("links").build());
d.getRecords(); // DNS records to publish
ms.domains().get(id);
ms.domains().list();
ms.domains().verify(id); // re-check DNS
ms.domains().update(UpdateDomainOptions.builder().id(id).openTracking(true).trackingSubdomain(null).build()); // null clears
ms.domains().update(UpdateDomainOptions.builder().id(id).receivingCatchAll(true).build()); // paid plans on Cloud
ms.domains().remove(id);Domain.getReceivingCatchAll(): on, mail to addresses without an inbox lands in
receiving().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 domains().list() (isShared() is 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 getSendingMode() (open, reply_only or receive_only) says what those
inboxes may send, and getSendingModeReason() (plan or instance, null under
open) why it is held below open. It is read-only: update, verify and remove
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:
Domain sender = ms.domains().list(ListOptions.builder().limit(100).build()).getData().stream()
.filter(d -> "verified".equals(d.getStatus()) && !d.isShared())
.findFirst()
.orElse(null);CreateWebhookResponse w = ms.webhooks().create(CreateWebhookOptions.builder()
.endpoint("https://acme.dev/hooks/millionsend")
.events(WebhookEvent.EMAIL_DELIVERED, WebhookEvent.EMAIL_BOUNCED)
.build());
w.getSigningSecret(); // verify payload signatures with this
ms.webhooks().get(id); // also returns the signing secret and previousSecretExpiresAt
ms.webhooks().list();
ms.webhooks().update(id, UpdateWebhookOptions.builder().status("disabled").build());
ms.webhooks().remove(id);
// Rotate the signing secret (MillionSend extension)
RotateWebhookResponse r = ms.webhooks().rotate(id); // minted secret, default overlap
ms.webhooks().rotate(id, RotateWebhookOptions.builder()
.signingSecret("whsec_...") // bring your own; omit to mint
.overlapHours(48) // 0-72 hours the previous secret keeps signing too; 0 drops it at once
.build());
r.getSigningSecret(); // sign-with from now on
r.getPreviousSecretExpiresAt(); // when the previous secret stops signing; null when noneDuring the overlap every delivery carries both signatures, so a receiver
holding either verifies. deliverability.*, quota.*, contact.unsubscribed,
contact.resubscribed, contact.topic_opt_in and contact.topic_opt_out events
are MillionSend extensions.
EMAIL_SCHEDULED, EMAIL_FAILED and EMAIL_SUPPRESSED need MillionSend v0.15.1 or
later: an older server answers 422 validation_error to a webhook naming them.
email.scheduled: an email was accepted with a futurescheduledAt; rescheduling it fires nothing.email.failed: the email failed for good (it won't be sent, or SES refused it afteremail.sent);data.failed.reasonsays why.email.suppressed: the email won't be sent (the suppression list, an unsubscribe or a topic opt-out stopped it, or its contact, broadcast or topic was deleted or changed first);data.suppressedcarriestype,messageandreason. It also fires when the suppression list alone makes aPOST /emailsanswer422 all_recipients_suppressed. Dropping only some recipients fires nothing.
Treat reason and type as open sets. suppression.added and suppression.removed
carry source_id: the email whose bounce or complaint created the entry, else null.
EMAIL_PREFETCHED, a MillionSend extension, is opt-in: it reaches only endpoints that
name it. It fires when a machine fetched the tracking pixel or followed a link (for
example Apple Mail Privacy Protection, Gmail's prefetch, a security scanner or a fetch
seconds after delivery);
data.open.reason or data.click.reason says which.
The inbox.* events (INBOX_CREATED … INBOX_DRAFT_DELETED) are delivered
only to endpoints that name them; an endpoint subscribed to all events skips
them (it still gets email.received). inboxIds(...) narrows them and
email.received to some inboxes (UpdateWebhookOptions.builder().inboxIds(null)
removes the filter).
inbox.expired and inbox.email.quarantined are MillionSend extensions. Inbox events
from sends made in the dashboard carry data.source: "dashboard".
CreateApiKeyResponse k = ms.apiKeys().create(CreateApiKeyOptions.builder()
.name("ci").permission("sending_access").domainId(domainId).build());
k.getToken(); // shown only once
ms.apiKeys().list();
ms.apiKeys().remove(id);
// A key restricted to one inbox: it reads that inbox and sends from its address only
ms.apiKeys().create(CreateApiKeyOptions.builder().name("support-agent").inboxId(inboxId).build());domainId takes one of your own domains: the shared row of domains().list() answers
422 validation_error.
Every identifier argument accepts the template id or its alias.
ms.templates().create(CreateTemplateOptions.builder()
.name("Welcome").alias("welcome").subject("Hi {{{FIRST_NAME|there}}}").html("<p>…</p>").build());
ms.templates().get("welcome");
ms.templates().list();
ms.templates().update("welcome", UpdateTemplateOptions.builder().html("<p>new</p>").subject(null).build()); // null clears subject/text/alias
ms.templates().publish(id); // no-op kept for compatibility: every save is already live
ms.templates().duplicate(id);
ms.templates().remove(id);The account deliverability score over the trailing 30 days.
DeliverabilityReport report = ms.deliverability().get();
report.getScore(); // Double, 0-10 — null until there is enough data
report.getBand(); // "excellent" | "good" | "needs_attention" | "at_risk" | null
report.getGuardrailStatus(); // "ok" | "warning" | "paused"Bands, check severities/statuses and the guardrail status are plain strings (the server may add values over time), and check ids are an open set.
UsageReport u = ms.usage().get();
u.getPlan(); // "free" | "pro" | "scale" | null when self-hosted
u.getLimits().getEmailsPerDay(); // null when unlimited
u.getToday().getEmailsSent();Each feature needs this MillionSend version or later. Older instances answer 404 on
paths they lack and ignore options they don't know, so an inbox created with username
or domainId on one can get another address (check its getEmailAddress()), and before
v0.13.0 since is ignored, so the code already used can come back.
| MillionSend | Features |
|---|---|
| v0.10.0 | inboxes(); the receiving long-poll, inbox filters, reply and forward |
| 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 codes().latest; quarantined on thread items; inbox names up to 320 characters; unread with an inbox-scoped key; a JSON 404 for unknown routes |
| v0.14.0 | the shared row of domains().list() (isShared(), getSendingMode(), getSendingModeReason()); domainId on inboxes().create; subject up to 998 characters |
| v0.15.1 | the EMAIL_SCHEDULED, EMAIL_FAILED and EMAIL_SUPPRESSED webhook events; source_id on suppression.* events; the metadata-only suppressed email kept when the suppression list alone refuses an emails().send |
| 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 | the date contact property type; the segment ops before and after (an ISO 8601 date or date-time) and in_last_days and not_in_last_days (a day count from "1" to "36500") on created_at and property:<key> |
- import com.resend.Resend;
- Resend resend = new Resend("re_123");
+ import com.millionsend.MillionSend;
+ MillionSend ms = new MillionSend("ms_123");Self-hosting? Pass your instance's origin as the second argument (or set
MILLIONSEND_BASE_URL).
Accessor and method names match (resend.emails().send(...) →
ms.emails().send(...), resend.suppressions().batch().add(...) →
ms.suppressions().batch().add(...), and so on for domains(), apiKeys(),
webhooks(), templates(), contactProperties(), broadcasts(), topics()).
Notes:
- No audiences: contacts are team-global, so there is no
.audiences()resource and noaudienceIdanywhere. The server keeps/audiences/*only as a compatibility shim; it is not part of this SDK..segments()is MillionSend's dynamic-filter feature, not Resend's audience alias. - Request options use the same
RequestOptionsbuilder shape (setIdempotencyKey,add(header, value)), plusbatchValidation(...). - Errors throw
MillionSendException(in place of Resend'sResendException) with the same{ statusCode, name, message }fields. - MillionSend extensions (no Resend counterpart):
segments(),deliverability(),usage(),contacts().batch(),contacts().preferencesLink(),webhooks().rotate(),emails().remove(),emails().getInsights(),Email.getScore(), thedatecontact property type,MillionSendException.getRetryAfter(), and inreceiving()andinboxes()the long-pollwaitSeconds, the reply/forward/remove of received mail, inboxsafety(),codes(),quarantine(), draftreject(), theusernameanddomainIdofinboxes().createand the extension fields.
mvn test # unit tests over a mocked HTTP layer
mvn verify # + build the jarThe integration test in E2ETest runs only when MILLIONSEND_API_KEY is set
(and MILLIONSEND_BASE_URL for a self-hosted instance); it is skipped otherwise.
Release (workflow_dispatch) publishes the current main to Maven Central
through the Central Portal with mvn -P release deploy (sources + javadoc
jars, GPG signatures, auto-publish, waits until the version is live). It
needs four repository secrets:
| Secret | Value |
|---|---|
CENTRAL_TOKEN_USERNAME / CENTRAL_TOKEN_PASSWORD |
A central.sonatype.com user token (Account → Generate User Token) |
MAVEN_GPG_PRIVATE_KEY |
ASCII-armored private signing key (gpg --armor --export-secret-keys <id>) |
MAVEN_GPG_PASSPHRASE |
Its passphrase |
The public half of the signing key must be on a keyserver Central checks:
gpg --keyserver keyserver.ubuntu.com --send-keys <id>.
MIT