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
subjectand at least one ofhtmlortext. - Template email references exactly one published template by
templateSlugortemplateId, with optionaltemplateData. Do not mix it with inlinesubject,html, ortext. AddlocaleandtimeZoneto 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.
Sign off with the brand footer
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.
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
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
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 failedUse 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.