Sending
One email at a time goes through mailroom.send(). Bulk goes through
campaigns or sequences — never a
loop over send().
import { createClient } from "@mailroom/sdk";
const mailroom = createClient({
apiKey: process.env.MAILROOM_API_KEY!,
baseUrl: process.env.MAILROOM_URL!,
});
const { id } = await mailroom.send({
to: "ada@example.com",
template: "welcome",
data: { name: "Ada" },
});
console.log(`queued send ${id}`);
Address the recipient by to (an email) or contactId. One of the two is
required; contactId wins when both are given, and the contact's own email is
used.
Transactional vs marketing
type defaults to the template's declared type. The difference is not cosmetic:
transactional | marketing | |
|---|---|---|
| Requires a contact | No — an ad-hoc to address is fine | Yes; one is created if the address is new |
| Unsubscribe link | Not injected | unsubscribeUrl injected, List-Unsubscribe headers set |
| Suppression list | Enforced | Enforced |
Marketing sends anchor an unsubscribe link to a contact id, so Mailroom upserts the address into the contact list when it has to. Sending marketing mail to people who never opted in is a compliance problem, not a Mailroom feature — check consent before you call.
Subject and sender resolution
Subject: send({ subject }) → the project's defaultSubject for that template →
the template's built-in default.
Sender: send({ from }) → the template-level from → the brand default. Any
explicit from address must sit on a verified sending domain for the project,
or the send fails.
Brands
Templates, content, and senders are brand-scoped. Resolution order:
send({ brand }) → the contact's brand → the project's default brand. A project
with no brand at all fails with no_brand.
Set defaultBrand once on the client instead of threading it through every call:
const mailroom = createClient({ apiKey, baseUrl, defaultBrand: "acme" });
Idempotency
The SDK attaches a generated Idempotency-Key to every mutation, so its own
retries cannot double-send. To make your retries safe, pass a stable key
derived from the thing you are emailing about:
await mailroom.send(
{ to: "ada@example.com", template: "receipt", data: { orderId: "ord_123" } },
{ idempotencyKey: "receipt:ord_123" },
);
The key is forwarded to the delivery provider, which drops the duplicate, and the send log's unique index keeps a second row from appearing. Without a key, two calls are two emails.
What can fail
All of these arrive as MailroomValidationError (422) — branch on err.code:
code | Meaning |
|---|---|
invalid_request | Payload failed validation (bad email, missing template, neither to nor contactId) |
unknown_template | No such template key for this project/brand |
template_not_enabled | The template exists but is not enabled — call templates.setEnabled |
no_brand | The project has no brand to send under |
invalid_content | The published content does not satisfy the template's schema |
send_failed | The provider rejected it, rendering failed, or the recipient is suppressed |
404 not_found means the contactId does not exist in this project.
A suppressed address (bounce, complaint, or manual entry) never reaches the
provider — the call fails with send_failed and a logged row explaining why. Check
suppressions before assuming a delivery problem.
Confirming a send
send() resolves to { id }, the provider's message id. Engagement state lands
later, via webhooks, on the send-log row:
const [latest] = await mailroom.sends.list({ template: "welcome", limit: 1 });
console.log(latest.status); // "sent" | "failed"
console.log(latest.delivery_status); // "queued" | "delivered" | "bounced" | "complained" | null
console.log(latest.open_count, latest.click_count);
Reading the log needs the sends:read scope. For a live feed instead of polling,
subscribe to webhooks.