Email

Newsletters (Preview)

Preview · Available to selected organizations. Create a newsletter, write and send Markdown issues, and publish a public archive with your own domain.

A newsletter owns your Markdown issues and public archive. Its topic owns consent, subscriptions, and sending defaults. Each topic belongs to at most one newsletter.

An issue is a Markdown document in a newsletter's issue project. Its frontmatter supplies the title and optional email preheader; its layout turns the body into email or a web preview. Save the issue, then give a campaign its path and saved commit so later edits cannot change the content that run sends.

Create a newsletter

Use newsletters.create({ name: "Weekly digest" }) to create a newsletter and its opt-in topic. To use an existing topic, pass topicId instead of name. The newsletter inherits that topic's namespace. Manage subscribers and sending defaults through topics using the returned topicId.

Create an issue

Use newsletters.issues on the Promise client or NewsletterIssues from samva/effect/newsletter-issues. List issues returns each issue's path, its draft title, and its latest campaign send (latestSend, null until a campaign schedules it), plus the current revision. A topic with no issue project returns projectId: null, revision: null, and an empty list.

Create an issue creates the topic's project and layout on the first request. Omit expectedRevision for that request and keep the same Idempotency-Key header and content when retrying it. After the project exists, new creates require its current revision.

create-issue.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.create({ name: "Weekly digest" });
const path = "issues/weekly-digest.md";
const content = `---
title: This week at Example
slug: weekly-digest
topic: ${newsletter.topicId}
preheader: New features and a guide to getting started
---

## What shipped

We added a faster way to review your messages.

[Read the guide](https://example.com/guide).
`;

const draft = await samva.newsletters.issues.create(
  { id: newsletter.id, path, content },
  { idempotencyKey: "weekly-digest-create-1" },
);

console.log(draft.workspace.revision);

An issue path names a Markdown file under issues/. The required frontmatter keys are title, slug, and topic. preheader supplies the email preview text, and date (YYYY-MM-DD) is what a layout's <IssueDate /> prints. The frontmatter topic is the newsletter's topic ID, matching the campaign audience. A mismatch blocks validation and Save. The optional gate.leadIn is described under subscribe wall.

Creating or editing an issue changes the shared draft. It does not create a saved commit or send email.

Check and preview

Check the issue at the revision returned by the create or update call. Read diagnostics and incompatibilities, then choose a fixture from declaration.fixtures. The initial layout declares default.

preview-issue.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.get({ id: "nl_1234567890abcdef" });
const { workspace } = await samva.newsletters.issues.open({ id: newsletter.id });
const path = "issues/weekly-digest.md";

const checked = await samva.newsletters.issues.check({
  id: newsletter.id,
  path,
  expectedRevision: workspace.revision,
});
console.log(checked.diagnostics, checked.incompatibilities);

const preview = await samva.newsletters.issues.render({
  id: newsletter.id,
  path,
  expectedRevision: workspace.revision,
  fixture: "default",
  target: "email",
});
console.log(preview.rendered?.html);

Render a fixture accepts target: "email" or target: "web". Email output is in rendered; web output is in page. Inspect findings when the requested output is null. Web output is the page readers get, in the topic's brand, with a Wall starts here marker where a frontmatter gate begins. It is a preview, not a public issue URL.

In the dashboard, open Issues from a topic's actions, or select the topic's name. The page lists every issue with its status (draft, scheduled, sending or sent), its date and its recipients. Select an issue to open it in the editor, or choose Write issue to start a new one.

A new topic's first issue also creates its layout: a masthead with your brand logo and the topic's name, a type scale for the issue's headings, paragraphs, lists and quotes, and your brand's footer. The layout takes your default brand's colors and fonts, and every issue in the topic shares it.

The editor lets you switch between Email and Web previews. To edit Email prose, select a paragraph, open Design, and change its Text field. In Web, double-click a paragraph to edit it directly. To edit the whole issue, frontmatter included, add Source from the + menu. Every change is saved to the issue's draft; the topic's layout can't be edited from an issue.

Edit and save

Open the workspace to read its files and current revision. Read an issue with that revision, then update it by sending the complete Markdown content and expectedRevision. Each successful edit returns the new revision.

Remove an issue also requires the current revision and an idempotency key. The project's default issue must remain. Removal changes the draft; saved commits remain available.

Save validates the project and returns the commit that a campaign can pin:

save-issue.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.get({ id: "nl_1234567890abcdef" });
const { workspace } = await samva.newsletters.issues.open({ id: newsletter.id });

const saved = await samva.newsletters.issues.save(
  {
    id: newsletter.id,
    expectedRevision: workspace.revision,
    expectedHead: workspace.remoteHead,
    message: "Save weekly digest",
  },
  { idempotencyKey: "weekly-digest-save-1" },
);
console.log(saved.commit);

expectedRevision protects the draft you inspected. expectedHead protects the saved branch head. If either has changed, the request fails with ConflictError. Read the current workspace, reconcile your changes, and submit a new edit or Save request with the current values. Preserve the original request and idempotency key when retrying after a lost response.

Use Head to read the current revision and saved head, and History to list saved commits.

Pin the issue to a campaign

Set content.issue to the issue path and the commit returned by Save. audience.topicId selects the topic that owns the issue. The saved issue supplies the subject and preheader; do not combine an issue pin with inline subject, HTML, text, or a template selection.

If your layout needs recipient data, use email.templateData for fallback values when a recipient has no matching field. Email delivery options and fallback data are preserved when you copy a campaign in the dashboard.

issue-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 { workspace } = await samva.newsletters.issues.open({ id: newsletter.id });
const path = "issues/weekly-digest.md";
if (!workspace.remoteHead) throw new Error("Save the issue before creating a campaign.");

const campaign = await samva.campaigns.create({
  name: "Weekly digest",
  channel: "email",
  audience: { topicId: newsletter.topicId },
  content: {
    channel: "email",
    issue: { path, commit: workspace.remoteHead },
    from: { email: "newsletter@your-domain.com", name: "Example" },
    email: {},
  },
});
console.log(campaign.id);

Use a verified sender and review the audience before scheduling the run. The run keeps its issue path and commit. A later draft edit or Save does not change that run's content. Subscription preferences, suppression, and campaign audience filters still determine who receives it.

In the dashboard, Edit copies a campaign's saved issue and topic into the form. Save as new creates another campaign with that same saved issue.

Publish a public archive

An archive requires an opt_in topic, archiveEnabled: true, and a publicationSlug. Enable it through Update a newsletter:

enable-archive.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.get({ id: "nl_1234567890abcdef" });

await samva.newsletters.update({
  id: newsletter.id,
  archiveEnabled: true,
  publicationSlug: "example-weekly",
});

The hosted publication is at https://example-weekly.samva.app, following https://<publicationSlug>.samva.app. Its home page features the latest sent issue, /archive lists every issue by year, and sent issues appear at /p/<issue-slug>, using the slug in their frontmatter. Pages use the brand of the topic's default sender domain, or your default brand when the topic has no sender, and show "Powered by Samva" unless your plan removes it. A brand change reaches the site with the next published issue. Saving or previewing a draft does not publish it. A campaign must send the saved issue before it appears in the archive. Public pages show issue content without recipient personalization. Publish only content you intend anyone to read.

Set archiveEnabled: false to stop serving the public archive. An opt_out topic cannot enable a public archive. If Samva cannot confirm the withdrawal, the request returns an error with the settings already saved. Samva retries the withdrawal automatically; read the newsletter to check its current settings.

Gate an issue behind a subscribe wall

Add gate.leadIn to an issue's frontmatter to show only its opening on the public archive page:

issues/weekly-digest.md
---
title: This week at Example
slug: weekly-digest
topic: topic_123
gate:
  leadIn: 2
---

leadIn is the number of leading top-level blocks, such as paragraphs and headings, that readers see in full. The rest of the issue stays in the page, marked for search engines as not free (isAccessibleForFree: false), and fades behind an inline subscribe wall. A leadIn that is greater than or equal to the issue's block count gates nothing. The gate applies to the web page only: the email always carries the full issue.

The wall asks for an email address. Submitting it sends the topic's double opt-in confirmation email and unlocks the page right away in that browser on that publication host, for a 30-day period. The topic subscription starts only after the reader confirms. It is recorded with source wall, and its contact.subscribed event carries the issue URL as page. Wall submissions use the same rate limits and honeypot as public topic forms, and need public topic signup enabled for the organization.

Subscribe from the publication site

The publication site has a subscribe button in its header, a form on its home page, and a form after each open issue. They send the same double opt-in confirmation email as the wall, and a submission unlocks gated issues in that browser on that publication host for the same 30-day period. A confirmed signup from these forms is recorded with source publication and consentSource: "publication". Its contact.subscribed event has no formKey, and Samva sets page to the home page URL or the issue URL. Every publication form, the wall included, uses the same rate limits and honeypot.

Setting archiveEnabled: false also ends every reader's unlocked access.

Add a custom publication domain

Use samva.newsletters.domains in Promise applications, or import NewsletterDomains with import * as NewsletterDomains from "samva/effect/newsletter-domains" in Effect applications. The newsletter must have an enabled, published public archive before you add a domain.

List its domains to discover their latest recorded verification status. Results are ordered by hostname and omit removed domains. Pass nextCursor back as cursor for the next page; a null cursor marks the last page.

list-publication-domains.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.get({ id: "nl_1234567890abcdef" });

const page = await samva.newsletters.domains.list({ id: newsletter.id, limit: 20 });
console.log(page.items, page.nextCursor);

The CLI exposes the same list with samva newsletters domains list <newsletter-id> --limit 20 --json.

publication-domain.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.get({ id: "nl_1234567890abcdef" });

const hostname = "news.example.com";
const addKey = crypto.randomUUID();
const domain = await samva.newsletters.domains.add(
  { id: newsletter.id, hostname },
  { idempotencyKey: addKey },
);
console.log(domain.cname, domain.verificationRecords);

Add the returned cname and verificationRecords to your DNS configuration, then get the domain to recheck its status:

verify-publication-domain.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.get({ id: "nl_1234567890abcdef" });
const hostname = "news.example.com";

const status = await samva.newsletters.domains.get({ id: newsletter.id, hostname });
console.log(status.active, status.hostnameStatus, status.sslStatus, status.verificationErrors);

The hostname serves the publication only when ownership verification and certificate activation are complete and active is true. Preserve the original Idempotency-Key header, hostname, and newsletter when retrying an add request after a lost response.

Remove the domain with a new UUID for that removal, preserving it across retries:

remove-publication-domain.ts
import { createClient } from "samva";

const samva = createClient({ apiKey: process.env.SAMVA_API_KEY! });
const newsletter = await samva.newsletters.get({ id: "nl_1234567890abcdef" });
const hostname = "news.example.com";

const removeKey = crypto.randomUUID();
const removed = await samva.newsletters.domains.remove(
  { id: newsletter.id, hostname },
  { idempotencyKey: removeKey },
);
console.log(removed.phase);

Removal stops publication access on the hostname before domain cleanup finishes. removing means cleanup is pending. You can remove a domain after disabling its newsletter's archive. Remove custom domains before deleting the newsletter.

An email's view-in-browser link is signed for that delivery. It opens the delivery's frozen content and is private, excluded from search indexing, and served without shared caching. Treat the URL as sensitive: anyone who has it can open the content while the link is valid. A private browser link does not publish the message in the newsletter's public archive.

Content must support web rendering for a browser link to be created. A send that requires a browser link fails validation when its template cannot produce web output.

CLI

Use samva newsletters issues --help for issue authoring commands and required revision arguments. Campaign creation selects saved content with --issue-path and --issue-commit together:

samva campaigns create --name "Weekly digest" --topic-id unsub_1234567890abcdef \
  --issue-path issues/weekly-digest.md \
  --issue-commit 1111111111111111111111111111111111111111

Use the commit returned by Save and set a default sender on the topic. An issue pin supplies its subject and body, so omit inline content and template options.

Related documentation

On this page