Send an email
Send a transactional email with the Samva TypeScript SDK, the REST API, or curl.
This guide sends an email with the Samva TypeScript SDK, then shows the same request as a REST call
with curl.
Before your first send
You need a Samva API key from your dashboard, and the From address must belong to a verified sending domain or be a sender address you have verified. Samva does not provide a shared sending domain.
Most new organizations can send as soon as their domain is verified. They start on probation, with recipient limits beside their plan's and a content check before each distinct email goes out; see Probation for new organizations. Some are reviewed before their first send, and a rejected review, or a rejected domain or address, stops sending from it.
You can validate an email while review is in progress. Validation sends nothing and creates no review state.
Check email review status
Read the review decision, stage, stable reason code, and next action from the dashboard, SDK, CLI, or hosted MCP:
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const review = await samva.email.getOnboardingReview();
console.log(review.decision, review.stage, review.code, review.nextAction);samva email review status --jsonHosted MCP exposes email_review_get_status. If a review is rejected, an organization owner or admin
can use the dashboard, samva email review reapply, or email_review_reapply. Reapplication starts
a new review and never assigns approval. To answer an information request, reply to Samva support.
A send that review blocks returns
OnboardingReviewRequiredError (403).
Its code, stage, decision, and nextAction identify the restriction and what to do next.
The refused send has no other effect: nothing is queued or
counted toward usage, and an idempotency key sent with it stays unused, so you can submit the same
request with the same key after approval. If review blocks an email that was already accepted, the
email fails instead of going out.
Handle send refusals
Branch on the error's _tag. A
RateLimitedError (429) carries
retryAfterSeconds, repeated in the Retry-After header; wait that long before another attempt.
A SenderNotReadyError (503) names the
sender whose sending identity is still being prepared or verified. Wait for readiness and honor
retryAfterSeconds when present; its absence supplies no retry interval. These errors do not
arrange an automatic retry of the refused request.
Send with the TypeScript SDK
The email.send() facade is the canonical path: pass the recipient, subject, and HTML body
directly and you're done.
-
Install the SDK.
npm install samva -
Create a client and send the email.
send.ts import { createClient } from "samva"; const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! }); const message = await samva.email.send({ to: "ada@example.com", cc: "grace@example.com", replyTo: "support@example.com", subject: "Welcome to Samva", html: "<h1>Welcome!</h1><p>Thanks for joining.</p>", }); console.log("Email accepted:", message.id);
The to, cc, and bcc fields accept a single value or an array, and each entry can be a plain email
string, { email }, or { contactId }. You can also pass from as { email, name }, replyTo,
text for a plaintext fallback, plus optional fields like attachments, metadata, and
headers. Inline
content and a template are exclusive: pass subject with html or text, or pass
templateId/templateSlug with templateData and omit inline subject and body. See the
TypeScript SDK reference for the full surface.
When you omit from, Samva picks the first of these that applies: the template's fromDefault (set in the editor under Sending defaults), your
organization's default sender, then your only verified sender. Set the organization default on
Channels → Senders or with Set the organization default sender.
With no default and not exactly one verified sender, the send fails with FromAddressRequiredError (422), which lists your verified domains in verifiedDomains. Any address on a verified domain works as from without a sender row.
When you pass from without a name, Samva uses the matching verified sender's saved name.
An explicit name takes precedence. An address covered only by a verified domain has no saved sender
name, so it sends without a display name.
To make a retry safe, send an Idempotency-Key header. In the SDK, pass it as an option:
samva.email.send(input, { idempotencyKey: "welcome-ada" }). Repeating a send with
the same key and the same body returns the original message; reusing the key with a different body
is a conflict.
Choose which publication renders
A template send renders the template's current publication unless you say otherwise, and the exact
publication it resolved is recorded on the message. The two options below apply to a send that
names its template by templateId. Name at most one of them.
Add publicationId to pin one exact publication forever. Nothing you publish afterwards changes
what that send renders.
Add inputContractId instead to follow new publications while the input shape stays put. An input
contract is one immutable validation identity: republishing with reworded field descriptions keeps
the contract, so the send picks the new publication up, while a publication that changes what input
is valid carries the next contract and is never picked up until you name it. List a template's
contracts with GET /v1/templates/{id}/input-contracts.
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
await samva.email.send({
from: { email: "hello@yourdomain.com" },
to: "user@example.com",
templateId: "tmpl_...",
inputContractId: "tcon_...",
templateData: { name: "Ada" },
});Format dates and money for the recipient
A template send formats the dates, numbers, and money a template prints with the template's own
locale. Add locale (a BCP 47 language tag such as de-DE) to render for a different reader, and
timeZone (an IANA name such as Europe/Berlin) to print times in their clock. timeZone defaults
to UTC. An unknown locale or time zone fails the request with a validation error, and neither field
is part of the template's templateData. Scheduled messages and campaigns take the same two fields
on their email content.
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
await samva.email.send({
from: { email: "hello@yourdomain.com" },
to: "user@example.com",
templateSlug: "order-receipt",
templateData: { total: 4200, placedAt: "2026-10-01T09:30:00Z" },
locale: "de-DE",
timeZone: "Europe/Berlin",
});To override open or click tracking for this send, add tracking: { opens, clicks }. Omitted fields
inherit your organization defaults, while a recipient tracking opt-out always takes precedence.
See Control engagement tracking for defaults, opt-outs,
scheduled messages, campaigns, and analytics semantics.
Send an attachment
Pass the file's bytes as base64 content. The SDK and CLI send small files inline and upload larger
ones for you, so one call covers any file within the limits below.
import { readFile } from "node:fs/promises";
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const bytes = await readFile("./receipt.pdf");
await samva.email.send({
to: "ada@example.com",
subject: "Your receipt",
text: "Your receipt is attached.",
attachments: [
{
filename: "receipt.pdf",
content: bytes.toString("base64"),
contentType: "application/pdf",
size: bytes.byteLength,
},
],
});From the CLI, repeat --attach for each file:
samva email send --to ada@example.com --subject "Your receipt" --text "Your receipt is attached." --attach ./receipt.pdfAn attachment carries exactly one source: base64 content, or the mediaId of a ready upload. Its
declared contentType and size must match the bytes. One email carries
at most 10 attachments, each at most 25 MiB, and at most 25 MiB in total.
Inline content totals at most 4 MiB per request, which keeps the request under the
request size; past it the API answers 422 with
fields["email.attachments"] naming the upload path. Remote URL attachments are not accepted.
Upload a large file yourself
Over plain HTTP, upload files that do not fit inline as organization-owned media: create the media,
upload the bytes with the returned instruction, mark the upload complete, and attach its mediaId.
Media accepts any content type.
const media = await samva.media.create({
filename: "report.zip",
contentType: "application/zip",
sizeBytes: bytes.byteLength,
purpose: "attachment",
});
const upload = await fetch(media.upload.url, {
method: media.upload.method,
headers: media.upload.headers,
body: bytes,
});
if (!upload.ok) throw new Error(`Attachment upload failed: ${upload.status}`);
await samva.media.complete({ id: media.id });
// attachments: [{ filename: "report.zip", mediaId: media.id, contentType: "application/zip", size: bytes.byteLength }]A retry that uploads the same file again and repeats the send with the same Idempotency-Key
replays the original send.
An agent on hosted MCP follows the same steps with its upload tools; see Attach a file.
Send to an existing contact or multiple recipients
Use email.send() to address existing contacts by contactId, send to multiple recipients,
or thread into an existing conversation. Its to, cc, and bcc fields accept individual
recipients or arrays, and the email fields stay at the top level:
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
await samva.email.send({
to: [{ contactId: "contact_k9mvt4znw8rh7q2x" }, { email: "grace@example.com" }],
cc: [{ email: "ops@example.com" }],
subject: "Welcome to Samva",
html: "<h1>Welcome!</h1><p>Thanks for joining.</p>",
replyTo: "support@example.com",
});For why the facade and the messages API exist side by side, see Email and the messages API.
Send with the REST API
There is no email.send HTTP endpoint; the SDK facade is sugar over POST /v1/messages.
Every REST call uses that endpoint with the message body shape: a channel of email and the
content nested under email. Authenticate with the X-API-Key header.
curl -X POST https://api.samva.dev/v1/messages \
-H "X-API-Key: $SAMVA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-ada" \
-d '{
"to": [{ "email": "ada@example.com" }],
"cc": [{ "email": "grace@example.com" }],
"channel": "email",
"email": {
"subject": "Welcome to Samva",
"html": "<h1>Welcome!</h1><p>Thanks for joining.</p>",
"text": "Welcome! Thanks for joining.",
"replyTo": ["support@example.com"]
}
}'To address an existing contact, swap email for contactId in the to array; both forms hit the
same endpoint:
curl -X POST https://api.samva.dev/v1/messages \
-H "X-API-Key: $SAMVA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": [{ "contactId": "contact_k9mvt4znw8rh7q2x" }],
"channel": "email",
"email": { "subject": "Welcome to Samva", "html": "<h1>Welcome!</h1>" }
}'A successful call returns the new message with its id and status:
{
"id": "msg_7q2xk9mvt4znw8rh",
"status": "pending"
}For every parameter, status code, and pagination detail, see the REST API reference.
Set custom headers
email.headers is an optional map of header name to string. Samva copies it onto the outgoing
message. On samva.email.send, pass headers beside subject. On messages.send the same map is
email.headers.
import { createClient } from "samva";
const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
await samva.email.send({
to: "ada@example.com",
subject: "Your sign-in code",
text: "Your code is 123456.",
headers: { "OTP-Token": "123456" },
});curl -X POST https://api.samva.dev/v1/messages \
-H "X-API-Key: $SAMVA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": [{ "email": "ada@example.com" }],
"channel": "email",
"email": {
"subject": "Your sign-in code",
"text": "Your code is 123456.",
"headers": { "OTP-Token": "123456" }
}
}'samva email send --header 'Name: Value' sets one entry. Repeat the flag for each header. The name
is the text before the first colon, and the value is the rest. A flag with no colon, no name, or no
value is rejected before the request.
samva email send --to ada@example.com \
--subject "Your sign-in code" \
--text "Your code is 123456." \
--header 'OTP-Token: 123456'One email accepts at most 11 custom headers. Each name is an RFC 5322 field name. Each value is one non-empty line of printable ASCII. A second header with the same name, ignoring case, is rejected. A name longer than 126 characters, a value longer than 995 characters, or a name and value that together exceed 996 characters is rejected too.
Names Samva or the transport own are reserved. Matching ignores case. The reserved names are
From, To, Cc, Bcc, Reply-To, Subject, Date, Message-ID, Sender, Return-Path,
MIME-Version, DKIM-Signature, Received, List-Unsubscribe, List-Unsubscribe-Post,
In-Reply-To, and References, plus any name that starts with Content- or ARC-.
A reserved name, a duplicate name, an empty value, a value that is not one line of printable ASCII,
or a name or value past those limits is a
ValidationError (422). fields names that
header as email.headers.<Name>:
{
"_tag": "ValidationError",
"message": "Invalid request",
"fields": {
"email.headers.From": ["Header \"From\" is reserved"]
}
}A request with more than 11 headers returns the same status, with fields["email.headers"] and the
message At most 11 custom email headers.
Samva still writes headers a send needs, and those names stay reserved. When you reply in a
conversation, Samva sets In-Reply-To and References from the message you reply to. When the send
resolves a topic, Samva sets List-Unsubscribe and List-Unsubscribe-Post. Every
campaign resolves a topic. A direct send carries a topic only when the request or the template
names one.
There is no batch send endpoint. A campaign stores one headers map on its email content and
copies that map onto each recipient's email. The fields key on a campaign request is
content.email.headers.<Name>. A scheduled email takes the same map on send.email.headers, and a
rejection names send.email.headers.<Name>. samva scheduled-messages create and
samva campaigns create do not take --header. Hosted MCP accepts headers on
messages_send_email and scheduled_messages_schedule_email. The hosted MCP campaign tool does
not.
To carry a one-time code in a header for your own tooling, see Send a one-time code in a custom header. That guide also keeps the code in the body a person reads.
Accepted, queued, and expired email
A successful send response gives you a message ID and durable acceptance. Samva queues valid bursts
and waits for sending capacity or a previously verified domain to recover. Handoff to the
recipient's mail server is reported separately as sent, followed by delivery events.
Each email delivery has a hard window to begin sending. An immediate or scheduled email has a 10-minute window from acceptance or from its requested time. A campaign recipient has a 72-hour window from the run's start, so a large campaign keeps sending as capacity allows. Retries, temporary holds, and campaign expansion do not extend a window. An email that has started sending continues through delivery after its window.
Read deliveries on the email or message response, or open the message in Observe → Activity.
Each delivery's scheduling contains dueAt, expiresAt, waitReason, and reviewExpectedAt. A
null wait reason means there is no recorded hold. The wait reasons are rate, daily-quota,
domain-health, sending-paused, upstream-unavailable, retry, campaign-paused,
domain-warming, onboarding-review, content-review, and screening-unavailable. When sending
capacity is saturated, an organization's immediate email waits apart from its own campaign
recipients, so a large campaign
does not hold it back.
The send receipt and full message include skippedRecipients. Each entry names the excluded
address and its suppression reason (bounce, complaint, unsubscribe, or manual).
A topic exclusion uses topic-unsubscribe and includes topicId.
Check this array before treating every requested recipient as accepted. Inspect active
suppressions with samva.email.listSuppressions();
change topic subscriptions only when the recipient has opted in.
The message response also carries waitReason and reviewExpectedAt for the message as a whole:
content-review when any delivery needs review, then screening-unavailable when any delivery
waits for the content check to recover, otherwise the first waiting delivery's reason, and null
when none waits.
An organization on
probation has its email
checked before sending. Email flagged by that check keeps status queued with wait reason
content-review while Samva reviews it, and reviewExpectedAt says when the review is expected to
finish; it is null for every other wait reason. After the review Samva sends the email, or fails it
with error code CONTENT_REVIEW_REJECTED and a message.failed webhook. A held email has no
webhook of its own and no status beyond queued.
When Samva's content check is temporarily unavailable, the accepted email stays queued with
wait reason screening-unavailable and no expected review time. This reason reports a service
outage, not a finding about your content. Samva retries the check automatically when the service
recovers. Email that passes can send; content that needs review changes to content-review.
Keep observing the accepted message rather than sending a replacement. These holds emit no
separate webhook; normal sent, delivered, or failed outcomes use the existing events.
Email for an organization whose sending is paused waits with sending-paused.
A delivery that expires before it starts sending has status failed and error code
EMAIL_SEND_EXPIRED. It emits a message.failed webhook with that code. A delivery whose handoff
outcome is uncertain keeps status processing and code PROVIDER_OUTCOME_UNKNOWN while Samva
confirms whether it went out. Do not submit a replacement solely because that outcome is
unresolved: the email may already have been sent.
A failed delivery carries errorCode and errorMessage, and errorDetails when the failure has
more to report: the provider's code and message, whether it is retryable, and details such
as the bounce type and reporting mail server.