Email

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:

check-email-review.ts
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 --json

Hosted 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.

  1. Install the SDK.

    npm install samva
  2. 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.

send-template.ts
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.

send-localized.ts
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.

send-attachment.ts
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.pdf

An 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.

upload-attachment.ts
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:

send-to-contacts.ts
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.

send-with-header.ts
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.

Next steps

Related documentation

On this page