Email

Send an email campaign

Create an email campaign for an audience, schedule a run, and inspect or control delivery progress.

Use a campaign for a named broadcast to the contacts of a topic who match a tag, contact ID, or contact group selection. Creating a campaign sends nothing. A run snapshots the definition and starts immediately or at a future time.

Campaigns need a paid plan. On Free, creating a campaign, scheduling a run, and resuming a paused run return 402 PaymentRequiredError with resource campaigns; reading existing campaigns still works. A run scheduled before a move to Free fails when it starts instead of sending.

Define the campaign

Send campaigns as email. A run supports at most 25,000 resolved recipients. Choose exactly one content source:

  • Inline email requires subject and at least one of html or text.
  • Template email references exactly one published template by templateSlug or templateId, with optional templateData. Do not mix it with inline subject, html, or text. Add locale and timeZone to format the template's dates, numbers, and money for the audience; see Format dates and money for the recipient.

A headers map on that email content is copied onto each recipient's email. See Set custom headers. samva campaigns create and the hosted MCP campaign tool do not take headers.

The audience names one topicId and a source: includeTags, contactIds, or groupIds. A recipient must be eligible for the topic and match the source; excludeTags removes matches. Contacts resolve when the run starts. After the first run is created, the campaign definition is frozen and updates return a conflict.

Personalize and test

A topic can supply a default sender, reply mailbox, and template. Set content.from to choose a verified sender explicitly, or content.replyMailboxId to route replies to a mailbox. Set that mailbox field to null to disable mailbox inheritance. An explicit email.replyTo also overrides the inherited mailbox. The sender resolves from the request, topic, template, organization default, then the sole verified sender. email.preheader sets the inbox preview text.

For a published template, each root input field receives the matching contact value: profile fields such as firstName, custom fields, or identity fields such as email. Values in email.templateData are explicit fallbacks for missing contact values and literals for other template inputs. A recipient missing a required field with no fallback is skipped with missing_merge_field; the recipient's failureReason names the missing fields. Invalid values and render failures remain failures.

Use campaigns.preview (Preview a campaign email) with { content, audience, contactId } to render the draft as a contact, without creating a message or run. Omit contactId to check fallback data alone. campaigns.sendTest (Send a campaign test) takes the same input and sends one email to your authenticated email address; contactId chooses personalization only. A test creates no campaign run and does not require the actor to subscribe to the audience topic. Normal sending permissions, billing, and delivery policies apply.

Replies matched to a campaign message carry campaign: { campaignId, runId } on the received message, mailbox thread reads, and message.received and mailbox.message.received webhooks. Configure the topic reply mailbox to receive them in that mailbox.

Every campaign email ends with a brand footer: your company name, postal address, and an unsubscribe link. Samva appends the footer of the sending domain's brand unless the template renders <BrandFooter /> itself. Add a postal address to that brand before scheduling a run, and keep the footer on unless the template renders <BrandFooter />; Brands lists the errors each rule returns.

Preview · Available to selected organizations.

Send a saved newsletter issue

Save an issue to obtain its path and commit. The campaign's audience uses the newsletter's topicId; its content pins the saved issue. Set sending defaults on that topic before scheduling.

newsletter-campaign.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.get({ id: "nl_1234567890abcdef" });
const campaign = await samva.campaigns.create({
  name: "Weekly digest",
  channel: "email",
  audience: { topicId: newsletter.topicId },
  content: {
    channel: "email",
    issue: { path: "issues/weekly.md", commit: "1111111111111111111111111111111111111111" },
    email: {},
  },
});
console.log(campaign.id);

Later draft edits cannot change the pinned content a campaign sends.

Choose the topic

Every campaign names its topic in audience.topicId. Every email in the run shows the unsubscribe link in its footer and carries one-click List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers for that topic. Contacts who are not eligible for the topic are skipped with reason unsubscribed, and the run's audienceSnapshot shows the topic it used. Scheduling fails if the topic no longer exists, and a delivery that resolved a topic without its unsubscribe URL fails instead of sending without an opt-out.

A one-click unsubscribe changes the contact's subscription to the topic in the link only. The hosted preference page lets the contact toggle each topic or unsubscribe from all of them. An audience preview counts the same eligible contacts the run sends to.

Topics explains defaults, eligibility, and subscription management. To bring subscribers from another platform with their unsubscribes intact, see Import a reader list. Deleting a topic that a scheduled, resolving, dispatching, or paused run uses returns a 409 ConflictError.

Use the Promise SDK

send-campaign.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const campaign = await samva.campaigns.create({
  name: "August newsletter",
  channel: "email",
  content: {
    channel: "email",
    email: { subject: "What's new", html: "<h1>August updates</h1>" },
  },
  audience: {
    topicId: "unsub_1234567890abcdef",
    includeTags: ["newsletter"],
    excludeTags: ["bounced"],
  },
});

const run = await samva.campaigns.scheduleRun({
  id: campaign.id,
  scheduledFor: "2026-08-01T09:00:00Z",
}, { idempotencyKey: "august-newsletter-v1" });

console.log("Campaign run:", run.id);

Use the Effect SDK

send-campaign-effect.ts
import { Effect } from "effect";
import * as Campaigns from "samva/effect/campaigns";
import * as Client from "samva/effect/client";

const program = Effect.gen(function* () {
  const campaign = yield* Campaigns.create({
    name: "August newsletter",
    channel: "email",
    content: {
      channel: "email",
      email: { subject: "What's new", html: "<h1>August updates</h1>" },
    },
    audience: { topicId: "unsub_1234567890abcdef", includeTags: ["newsletter"] },
  });

  return yield* Campaigns.scheduleRun({
    id: campaign.id,
    scheduledFor: new Date("2026-08-01T09:00:00Z"),
  }, { idempotencyKey: "august-newsletter-v1" });
}).pipe(Effect.provide(Client.layerFetch({ apiKey: process.env.SAMVA_API_KEY! })));

await Effect.runPromise(program);

Use the REST API

curl -X POST https://api.samva.dev/v1/campaigns \
  -H "X-API-Key: $SAMVA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "August newsletter",
    "channel": "email",
    "content": {
      "channel": "email",
      "email": { "subject": "What is new", "html": "<h1>August updates</h1>" }
    },
    "audience": { "topicId": "unsub_1234567890abcdef", "includeTags": ["newsletter"] }
  }'

curl -X POST https://api.samva.dev/v1/campaigns/<campaign-id>/runs \
  -H "X-API-Key: $SAMVA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: august-newsletter-v1" \
  -d '{
    "scheduledFor": "2026-08-01T09:00:00Z"
  }'

Omit scheduledFor to start immediately. When present, it must be an absolute ISO-8601 instant with Z or a numeric offset. Campaign runs do not accept timezone metadata. See the generated campaigns reference for every operation. After a run, Get a campaign run report returns delivery outcomes, topic unsubscribes, replies, and skip reasons.

Use the CLI

samva campaigns create --name "August newsletter" \
  --topic-id unsub_1234567890abcdef \
  --subject "What's new" --html "<h1>August updates</h1>" \
  --include-tags newsletter
samva campaigns runs create <campaign-id> --at 2026-08-01T09:00:00Z \
  --idempotency-key august-newsletter-v1
samva campaigns runs get <campaign-id> <run-id>
samva campaigns runs recipients <campaign-id> <run-id> --status failed

Use samva campaigns create --dry-run to validate the definition without creating it. Use samva campaigns runs pause, resume, or cancel with the campaign ID and run ID to control a run.

Use MCP

Call campaigns_create, then campaigns_schedule_run. Track progress with campaigns_get_run and inspect outcomes with campaigns_list_recipients. The campaigns_control_run tool accepts pause, resume, or cancel.

Track progress and completion

A run counts its recipients while it sends. dispatchedCount, failedCount, and skippedCount rise as recipients settle, and until every contact has resolved totalRecipients is at least the audience the run started with. While a run is resolving or sending, estimatedCompletionAt says when it should finish, from the recipients still waiting and the sending capacity available when the run started. pendingCount is the number of recipients not yet sent, failed, or skipped. A run that is paused without a person's pause has holdReason set, and holdUntil says when it resumes on its own, if it does. A capacityWarning says a run's audience is larger than its sending capacity for one window, and is advisory. estimatedCompletionAt is null for a run with no forecast, such as a paused or finished run.

A larger audience takes longer, and the estimate moves as the run progresses. The campaign's run page in the dashboard shows the same progress and estimate.

Idempotency, controls, errors, and usage

Pass an idempotencyKey when scheduling a run. It is scoped to the organization, and replaying the same key returns the original run. Pausing stops new sends, and resuming picks up the recipients that have not been sent yet. Cancelling a future scheduled run is terminal. Cancellation after sending begins is best effort, so emails already on their way may still be delivered; recipients still waiting to send fail with CAMPAIGN_CANCELLED. Each recipient must start sending within the campaign send window, which runs from the run's start and includes time spent paused.

If email review is no longer clear just before a recipient's email goes out, the run pauses with an onboarding_review:<reason> hold in holdReason, and that recipient is not sent. The run stays paused after approval until you resume it with the dashboard, CLI, API, or campaigns_control_run. Resuming checks review again for each remaining recipient; Samva never resumes the run on its own.

Each recipient follows the normal email send and usage path when dispatched. Run totals distinguish dispatched, failed, and skipped recipients; recipient records carry the outcome. Invalid state transitions and edits after the first run return conflicts. See the error reference for shared API error handling.

Related documentation

On this page