Email

Send an email

Send a transactional email with the Samva TypeScript SDK, the REST API, or curl.

This guide shows the fastest way to send an email with Samva, from a single line of TypeScript to a raw REST call in the language of your choice. Each recipe is self-contained; pick the one that matches your stack.

You need a Samva API key. Grab one from your dashboard. Brand-new organizations must complete email review before production sending. You can validate content without sending while review is in progress. Verify a sending domain before you send from your own address or move real traffic into production. See Verify your domain when you're ready to set that up.

Check email review status

Every production send requires both organization approval and clearance for the selected sending identity. Read the same decision, stage, stable reason code, and next action from the dashboard, SDK, CLI, or hosted MCP:

check-email-review.ts
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. The information-request response flow is handled with Samva support.

An uncleared send returns 403 Forbidden before idempotency, quota, billing, persistence, queueing, or provider work. The same idempotency key remains unused and can be submitted after approval. Validation is read-only and does not create review state or any send side effect. Direct sends that lose clearance after queueing fail terminally before provider submission.

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.

    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 replyTo, text for a plaintext fallback, plus optional fields like attachments and metadata. 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.

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.

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.

await samva.email.send({
  from: "hello@yourdomain.com",
  to: "user@example.com",
  templateId: "tmpl_...",
  inputContractId: "tcon_...",
  templateData: { name: "Ada" },
});

Naming both publicationId and inputContractId is rejected.

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

For a file you already have, create organization-owned media, upload the bytes using the returned instruction, mark the upload complete, and attach its mediaId. The upload must be complete before you send the message.

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");

const media = await samva.media.create({
  filename: "receipt.pdf",
  contentType: "application/pdf",
  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({ mediaId: media.id });

await samva.email.send({
  to: "ada@example.com",
  subject: "Your receipt",
  text: "Your receipt is attached.",
  attachments: [
    {
      filename: "receipt.pdf",
      mediaId: media.id,
      contentType: "application/pdf",
      size: bytes.byteLength,
    },
  ],
});

An attachment must contain exactly one source: a ready mediaId, or base64-encoded inline content. The declared contentType and size must match the uploaded media. Each attachment is limited to 25 MiB. Remote URL attachments are not accepted.

Send to an existing contact or multiple recipients

email.send() is a thin facade over the messages.send() API. Reach for messages.send() directly when you need to address existing contacts by contactId, send to multiple recipients, or thread into an existing conversation. Here to is always an array and the email content is nested under email.

await samva.messages.send({
  to: [
    { contactId: "contact_k9mvt4znw8rh7q2x" },
    { email: "grace@example.com" },
  ],
  cc: [{ email: "ops@example.com" }],
  channel: "email",
  email: {
    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_sk_live_your_api_key" \
  -H "Content-Type: application/json" \
  -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_sk_live_your_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.

Next steps

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. Provider acceptance is reported separately as sent, followed by delivery events.

Each email delivery has a hard ten-minute window to begin sending. The window starts at acceptance for an immediate email, the requested time for a scheduled email, or the campaign's intended start. Retries, temporary holds, and campaign expansion do not extend it. Email already accepted by the provider continues through delivery after this window.

Read deliveries on the email or message response, or open the message in Developers → Logs. Each delivery's scheduling contains dueAt, expiresAt, and waitReason. A null wait reason means there is no recorded hold. Holds include rate, daily-quota, domain-health, tenant-health, provider-unavailable, retry, and campaign-paused.

A delivery that expires before submission has status failed and error code EMAIL_SEND_EXPIRED. It emits a message.failed webhook with that code. An uncertain submission keeps status processing and code PROVIDER_OUTCOME_UNKNOWN while Samva reconciles provider evidence. Do not submit a replacement solely because that outcome is unresolved: the provider may already have accepted it.

Related documentation

On this page