Newsletter API: from contact to segment, draft, test and send, in runnable code
Published Updated 14 min readBy the Rasket team
Written for AI automation builders

A newsletter API lets your code send one campaign to everyone who opted in. Any of them takes the same five steps: record a contact and their consent, define the audience, create the campaign as a draft, send yourself a test, then send it. The scripts below run those steps against Rasket’s API, and the last sections show an AI agent doing the same with a person approving the send.
The case this post builds for: your product ships every month, the changelog lives in your repository, and a scheduled job (or, in the last section, an agent) turns it into a campaign, mails you a test, and waits for a person to approve before the list gets it.
Three objects, and the default that reads backwards
A topic holds what someone agreed to receive (“Product updates”) and each contact’s yes or no. A segment is who one campaign goes to: a filter over contacts, worked out again at send time. A broadcast is the campaign. A topic’s default and a segment’s filter can never be edited; a wrong one is replaced by creating a new one.
Set a newsletter topic’s default_subscription to opt_out. The name reads backwards: it means “not subscribed until they say yes”, so a customer who never ticked the box gets nothing. opt_in would mail everyone who has not left.
A broadcast with no topic ignores topic choices
With a topic_id, a campaign reaches only segment members whose answer on that topic is yes, or whose default is yes if they never chose. Without one, it reaches every segment member, whatever they chose. Always pass the topic.
Three limits that shape the design
- A campaign that runs out of quota stops partway. Each recipient is one email from the plan’s single allowance, the same one your receipts use. When it runs out, those already sent stay sent, the campaign is marked
failed, and it cannot be resumed. On Free that line is 100 a day (3,000 a month, 900 contacts; 300 a month until the account has a card on file), so a Free campaign to more people stops on the first day, and your receipts, drawn from the same allowance, are refused until the next; the pricing page has the paid allowances. The send script below checks headroom first. - Nothing tells you a campaign finished. There is no campaign-level webhook. Poll the broadcast’s
status, or listen to the per-email webhook events (email.delivered,email.complained), which fire for campaign mail as for any other; the webhooks post shows how to verify their signatures. - One-click unsubscribe leaves every campaign, not one topic. Every campaign carries the RFC 8058 headers (the one-click guide shows them), and a click there unsubscribes the person from all campaigns; receipts still reach them. To let people leave one topic instead, link
{{{PREFERENCES_URL}}}in the body and add your postal address there yourself, because the footer that carries the address is only appended to a body with no unsubscribe link of its own. The link lives on our share host and cannot move to your domain; the same link already sits in every campaign’sList-Unsubscribeheader, so the body link adds no new domain to your mail.
Two smaller gaps: a draft can store subject_variants but nothing sends an A/B test yet, and segment filters cannot test opens or clicks, so engagement has to be written to a contact property from webhook events first. If your newsletter depends on subject-line tests or segments built on who opened, Rasket is not the right tool for it yet.
The flow in three scripts
The scripts need Node 22 or later and no packages, and each output below is from running them on 2026-10-17. Each reads the key from RASKET_API_KEY and the base URL from RASKET_API_URL (default https://api.rasket.com). The helper retries only what is safe to repeat: a rate-limit refusal, which carries retry-after, and a failed GET; the rate-limits post explains the limit.
// rasket.mjs: one helper the other scripts import. Node 22+, no packages.
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const KEY = process.env.RASKET_API_KEY;
const sleep = (seconds) => new Promise((r) => setTimeout(r, seconds * 1000));
export async function api(method, path, body, headers = {}) {
for (let attempt = 1; ; attempt++) {
const last = attempt === 4;
let res;
try {
res = await fetch(API + path, {
method,
headers: {
Authorization: `Bearer ${KEY}`,
"Content-Type": "application/json",
"User-Agent": "acme-newsletter/1.0",
...headers,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch (error) {
// A network error on a GET is safe to repeat. A POST may have landed.
if (method === "GET" && !last) {
await sleep(attempt);
continue;
}
throw error;
}
// The rate limit's 429 did nothing and says when to come back. A quota 429
// has no retry-after, because waiting a second will not fix it.
// Rasket sends whole seconds; a date or anything else is not retried.
const retryAfter = Number(res.headers.get("retry-after") ?? NaN);
if (res.status === 429 && Number.isFinite(retryAfter) && !last) {
await sleep(retryAfter);
continue;
}
if (res.status >= 500 && method === "GET" && !last) {
await sleep(attempt);
continue;
}
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { message: text.slice(0, 200) };
}
if (!res.ok) {
const message = `${method} ${path} → ${res.status} ${data.name ?? "error"}: ${data.message}`;
throw Object.assign(new Error(message), { status: res.status });
}
return data;
}
}Once per team: a sender name and postal address
// postal.mjs: once per team. Every campaign footer prints the name and address.
import { api } from "./rasket.mjs";
const team = await api("PATCH", "/team", {
sender_name: "Acme",
postal_address: {
line1: "1 Example Street",
city: "Springfield",
postal_code: "12345",
country: "US",
},
});
console.log(team.sender_identity);The send gate refuses every campaign until the team has a postal address; CAN-SPAM requires one in every commercial message. A team with neither field set must send both, or the call answers 422 missing_required_field naming sender_name.
draft.mjs: contact to test
// draft.mjs: contact → segment → campaign draft → test. Safe to run twice.
import { api } from "./rasket.mjs";
const FROM = process.env.RASKET_FROM ?? "Acme <digest@news.acme.example>";
const TEST_TO = process.env.RASKET_TEST_TO ?? "you@acme.example";
const month = new Date().toISOString().slice(0, 7); // e.g. "2026-10"
// 1. The topic people subscribe to. Created once, then found by name.
const topics = await api("GET", "/topics?limit=100");
const topic =
topics.data.find((t) => t.name === "Product updates") ??
(await api("POST", "/topics", {
name: "Product updates",
description: "What shipped, once a month.",
default_subscription: "opt_out", // nobody gets it until they say yes
visibility: "public",
}));
// 2. A contact and their opt-in, sent only when the contact is new: a topics entry
// overwrites their answer, so repeating it would re-subscribe someone who left.
const email = "sam.reader@example.com";
const known = await api("GET", `/contacts/${encodeURIComponent(email)}`).catch((error) => {
if (error.status === 404) return null;
throw error;
});
const contact =
known ??
(await api("POST", "/contacts", {
email,
first_name: "Sam",
topics: [{ id: topic.id, subscription: "opt_in" }],
}));
// 3. The audience: everyone opted in to the topic. Also found by name.
const segments = await api("GET", "/segments?limit=100");
const segment =
segments.data.find((s) => s.name === "Product updates: opted in") ??
(await api("POST", "/segments", {
name: "Product updates: opted in",
filter: { all: [{ topic: topic.id, subscription: "opt_in" }] },
}));
// 4. This month's campaign, as a draft. Found by name in any status, so a rerun
// reuses the draft, and stops once this month's campaign has gone out.
const name = `Product update ${month}`;
const found = await api("GET", `/broadcasts?search=${encodeURIComponent(name)}`);
const existing = found.data.find((b) => b.name === name);
if (existing !== undefined && existing.status !== "draft") {
console.log({ broadcast: existing.id, status: existing.status });
process.exit(0);
}
const broadcast =
existing ??
(await api("POST", "/broadcasts", {
name,
segment_id: segment.id,
topic_id: topic.id,
from: FROM,
subject: "What shipped this month",
preview_text: "Three changes, two minutes to read.",
// No unsubscribe link of our own: the footer adds one, with the postal address.
html: `<p>Hi {{{FIRST_NAME|there}}},</p>
<p>Three things shipped this month.</p>`,
}));
// 5. A test to one inbox. The same key within 24 hours means it is not sent twice.
const test = await api(
"POST",
`/broadcasts/${broadcast.id}/test`,
{ to: [TEST_TO] },
{ "Idempotency-Key": `test-${broadcast.id}` },
);
console.log({ contact: contact.id, segment: segment.id, broadcast: broadcast.id, test: test.data });$ node draft.mjs
{
contact: '01a14b8c-f8bb-790b-aa22-ef60825a753c',
segment: '01a14b8c-f925-7e70-8755-0a5ae9e14ff7',
broadcast: '01a14be7-346d-7cd6-a9b5-104b24f2057f',
test: [
{
id: '01a14be7-349a-7b31-b775-19ddc736f927',
to: 'you@acme.example'
}
]
}A second run the same day changes nothing. The topic, segment and this month’s draft are found by name, the contact is found by email and its opt-in is not sent again, and the test reuses its idempotency key, which holds for 24 hours, so a rerun on a later day mails a fresh test of the draft as it is then. Once the month’s campaign has gone out, a rerun prints its status and stops. The test carries [Test] in the subject and counts as a send. The content checks, which read the body as you wrote it, warn that it has no unsubscribe link of its own; the footer adds one at send time, and a warning never blocks. Each lookup reads one page of up to 100; past that, page with after while has_more is true. In production the contact step lives in your sign-up handler, and thehtml is your changelog rendered to HTML; to change a saved draft, send PATCH /broadcasts/{id}.
Send the topics entry only at sign-up
A topics entry on POST /contacts overwrites the contact’s current answer. A sync job that posts opt_in for everyone on every run re-subscribes people who opted out of the topic, which is why draft.mjs looks the contact up first. A repeat create never undoes an unsubscribe from everything.
The approval step
Between the scripts sits a person. Run send.mjs without --send and show them what it prints (sender, subject, audience size, any failed check) beside the test in their inbox. In n8n, the Slack node’s Send and Wait for Response operation with Response Type set to Approval pauses the workflow and resumes with approved: true when someone clicks (n8n’s approval docs, read 2026-10-17); branch on that, and on a yes run send.mjs again with --send, or have an HTTP Request node call POST /broadcasts/{id}/send with the id draft.mjs printed. Keep the API key in n8n’s stored credentials, never in a prompt or a message.
send.mjs: audience, gates, headroom, send
// send.mjs <broadcast id> [scheduled_at] [--send]: check audience, gates and headroom.
// Without --send it stops after the checks, so what it prints is what the approver reads.
import { api } from "./rasket.mjs";
const args = process.argv.slice(2);
const go = args.includes("--send");
const [id, scheduledAt] = args.filter((arg) => arg !== "--send");
const draft = await api("GET", `/broadcasts/${id}`);
const audience = await api("GET", `/segments/${draft.segment_id}/metrics`);
console.log({ from: draft.from, subject: draft.subject, contacts: audience.subscribed });
// The compliance gate: domain, postal address, segment, account state, body.
const gate = await api("GET", `/broadcasts/${id}/checklist`);
const problems = gate.conditions.filter((c) => !c.passed);
// The content checks: links, merge variables, subject, alt text, unsubscribe link.
const checks = await api("POST", `/broadcasts/${id}/checks`);
problems.push(...checks.checks.filter((c) => c.status === "fail"));
// Headroom: a campaign that runs out of quota halfway stops halfway.
const { usage } = await api("GET", "/billing");
const left = Math.min(
(usage.daily_limit ?? Infinity) - usage.emails_today,
(usage.monthly_limit ?? Infinity) - usage.emails,
);
if (audience.subscribed > left) {
problems.push({ condition: "quota", message: `${audience.subscribed} contacts, ${left} emails left` });
}
if (problems.length > 0) {
console.error(problems);
process.exit(1);
}
if (!go) {
console.log("All checks pass. Run again with --send to send.");
process.exit(0);
}
await api(
"POST",
`/broadcasts/${id}/send`,
scheduledAt === undefined ? undefined : { scheduled_at: scheduledAt },
);
const { status } = await api("GET", `/broadcasts/${id}`);
console.log({ id, status });$ node send.mjs 01a14be7-346d-7cd6-a9b5-104b24f2057f
{
from: 'Acme <digest@news.acme.example>',
subject: 'What shipped this month',
contacts: 1
}
All checks pass. Run again with --send to send.
$ node send.mjs 01a14be7-346d-7cd6-a9b5-104b24f2057f --send
{
from: 'Acme <digest@news.acme.example>',
subject: 'What shipped this month',
contacts: 1
}
{ id: '01a14be7-346d-7cd6-a9b5-104b24f2057f', status: 'queued' }The send takes no idempotency key and needs none: it only moves a draft, so a second call on the same id answers 400 validation_error: Broadcast is not a draft. and sends nothing. A second argument schedules instead, as an ISO 8601 time or a phrase such as in 2 hours (an ISO time carries its offset; a phrase is read as UTC), between 1 minute and 30 days out; POST /broadcasts/{id}/cancel stops a scheduled or sending campaign. The audience is the segment’s subscribed count; suppressed addresses and topic opt-outs drop out at send time, so fewer can leave, never more.
report.mjs: wait, then read who left
// report.mjs <broadcast id>: wait until the campaign ends, then list who complained or left.
import { api } from "./rasket.mjs";
const [id] = process.argv.slice(2);
let { status } = await api("GET", `/broadcasts/${id}`);
while (!["sent", "failed", "canceled"].includes(status)) {
await new Promise((r) => setTimeout(r, 60_000)); // once a minute
({ status } = await api("GET", `/broadcasts/${id}`));
}
console.log({ status });
for (const type of ["complained", "unsubscribed"]) {
const emails = [];
let cursor = "";
for (;;) {
const page = await api("GET", `/broadcasts/${id}/recipients?type=${type}&limit=100${cursor}`);
emails.push(...page.data.map((r) => r.email));
if (!page.has_more) break;
cursor = `&after=${encodeURIComponent(page.data.at(-1).id)}`;
}
console.log(type, emails);
}One request a minute is nothing against the rate limit. Read the complained list after every send, and again a few days later, because complaints keep arriving after the status says sent. Each one suppresses that address for all your mail, as the suppression list post explains. Gmail reports no complaints per address, only a rate in Postmaster Tools, so the list holds the other mailbox providers’ reports.
When an AI agent writes and sends it
An agent connected to Rasket’s MCP server signs in with OAuth, so no API key sits in its context; the MCP post covers the connection. Three tools make a campaign: create_broadcast saves the draft, preview_broadcast returns what would go out plus a confirm_token, and send_broadcast refuses without that token. From a real run on another draft:
preview_broadcast { "broadcast_id": "01a148db-d264-7b0d-a107-ebd539218a8e" }
→ {
"broadcast": { "from": "Acme <digest@news.acme.example>",
"subject": "What shipped this month", "status": "draft" },
"segment": { "name": "Product updates: opted in", "subscribed": 2 },
"recipients": 2,
"sendable": true,
"confirm_token": "1792227338.CjjPg80uPZBKe3Q8LC1eP_322ANQ1DGCrhcn9RbPics",
"confirm_expires_at": "2026-10-17T08:55:38.000Z"
}
send_broadcast { "broadcast_id": "01a148db-d264-7b0d-a107-ebd539218a8e" }
→ invalid_parameter: "send_broadcast needs a confirm_token. Call preview_broadcast
for this broadcast, show the person the segment, recipients, subject and sender,
and send with the token it returns once they confirm."
send_broadcast { "broadcast_id": "01a148db-…", "confirm_token": "1792227338.CjjP…" }
→ { "id": "01a148db-d264-7b0d-a107-ebd539218a8e" }All three tools need the broadcasts:write scope, which does not include reading: get_broadcast, list_broadcasts and get_broadcast_report need broadcasts:read, and finding a segment with list_segments needs segments:read. A connection made with the plain server URL asks for every tool’s scope, so it holds all of them. No scope lets an app draft a campaign but not send it. Each connected app may send three campaigns per rolling hour (the MCP docs list every tool, scope and limit).
What the confirm_token proves, and what it does not
The token is signed over your team, the connected app’s grant, an expiry one hour out, and a hash of the broadcast’s id, segment, From, subject, reply-to, preview text, HTML, text and topic. So it proves this app fetched the preview for exactly the campaign about to go out. Edit any of those fields and the send is refused until a new preview.
It does not prove a person saw the preview. The server has no way to ask anyone, so an agent can call preview_broadcast and send_broadcast back to back. What stands between them is the client. send_broadcast is marked destructive, and the MCP spec says clients should ask before a sensitive call, but it does not require them to, and Claude’s connector settings let a person set a tool to always allow. The changelog the model reads is untrusted text too, so an instruction hidden in it reaches the same agent that holds the send.
To tie the approval to a person, take the send away from the agent. Tell it to stop after preview_broadcast and post the preview where you work. A signed-in member then opens the draft under Campaigns in the dashboard and presses Send. That runs the same gate and checks, and the team’s audit log records which member sent it.
When the model writes the HTML from the changelog, give it the rules in the prompt: plain HTML, one link per item, only {{{FIRST_NAME|there}}}-style variables with a default, and no unsubscribe link. Then check its work before a person sees it. Save the draft, call POST /broadcasts/{id}/checks, hand each fail message back to the model, and repeat until none remain. The messages are written to act on; this is what a send of a bad draft got back:
422 validation_error
"details": { "checks": [
{ "code": "unresolved_variable", "status": "fail",
"message": "{{{COMPANY}}} is not a contact field or property, so it would be sent
empty. Check the spelling, or add a default: {{{COMPANY|friend}}}." },
{ "code": "link_scheme", "status": "fail",
"message": "1 link runs code instead of opening a page: “javascript:alert(1)”.
Mail clients remove these." }
] }Never let the agent set acknowledge_checks, the flag that sends despite a failing check. It exists for a person who has read the failures.
Which subdomain should campaigns send from?
If you split streams, send campaigns from news.acme.example and receipts from the root or mail.acme.example; whether the split is worth it at your volume is the subdomain guide’s question. The separation is partial: Google counts a subdomain’s mail toward its parent domain’s bulk-sender total (Google’s sender FAQ, read 2026-10-17), so splitting does not take campaigns out from under the rules your receipts follow. Pick a name that cannot clash with your return path, the subdomain that receives your bounces. A domain added now puts its return path at rasket.<domain>; older domains may use send., so avoid that name for a sending subdomain.
Unless you publish a DMARC record at _dmarc.news.acme.example, receivers apply the root record’s sp= tag, or its p= when there is none (RFC 9989, read 2026-10-17). A new subdomain also has no reputation yet: send the first campaigns to your most recent sign-ups and grow from there, as the warm-up guide lays out, and watch complaints against the thresholds in the complaint rate guide.
Start with postal.mjs once, then put draft.mjs on a monthly schedule with RASKET_TEST_TO set to your own address. Every other field is in the broadcasts reference.
Frequently asked questions
How do I show when and how someone agreed to the newsletter?
GET /contacts/{contact_id}/consent returns every subscription change for the contact, newest first: the action, the topic, where it came from (api, one_click, preference_page) and when. It records what your code reported, so keep your own copy of the form and the wording the person saw.
How do I load an existing list instead of creating contacts one at a time?
Upload a CSV of up to 50 MB to POST /contacts/imports as multipart/form-data, with topics as a JSON-encoded string, then read the import back for its status and counts. Rows past the plan's contact limit fail with contact_limit_reached. Import only people who agreed to hear from you.
Can a campaign use a saved template instead of inline HTML?
Yes. Pass template: { id } naming a published template in place of html and text when you create the broadcast. Its HTML and text, and its reply_to when you send none, are copied into the campaign once, so later edits to the template do not change a campaign you already drafted.
Where do replies to a campaign go?
Mail apps reply to the Reply-To address when a message has one and to the From address otherwise. A news. subdomain often has no inbox behind it, so set reply_to on the broadcast to an address someone reads, such as hello@acme.example.
Can a campaign go out at each recipient's local time?
No. scheduled_at is one instant, read as UTC, for the whole campaign. To get close, store each contact's time zone as a string property, make one segment per zone, and schedule one campaign per segment at the local hour you want.
Sources
- Email sender guidelines FAQ — Google Workspace Admin Help, read 2026-10-17
- RFC 8058: Signaling One-Click Functionality for List Email Headers — RFC Editor, read 2026-10-17
- RFC 9989: Domain-Based Message Authentication, Reporting, and Conformance (DMARC) — RFC Editor, read 2026-10-17
- CAN-SPAM Act: A Compliance Guide for Business — Federal Trade Commission, read 2026-10-17
- Slack node: approvals — n8n Docs, read 2026-10-17
Related
- Sending newsletters — Use a campaign, or include an unsubscribe link.
- MCP server — Connect Claude, ChatGPT or any MCP client: your scopes, no key.
- Should you use an email subdomain for marketing? — When a marketing subdomain like news.example.com is worth it, by volume: what it separates from your domain, what it shares, and the DMARC and DNS records.
- How to warm up a new domain or IP address — How to warm up a new domain or IP: what Gmail, Microsoft, Yahoo and M3AAWG publish, schedules from 300 contacts to 50,000 a day, and when to slow down.
- How to get out of the Gmail Promotions tab — Why Gmail files your email under Promotions, what it costs you, and the steps that move mail toward Primary, with an example welcome-email line to copy.
- Email suppression list: what it is, how to use it — What goes on an email suppression list, which entries block receipts and password resets, what a suppressed send returns, and how to manage it by API.