Ringmaster ingest API
Your app sends Ringmaster the people it knows about, what they do, and who must not be mailed. Flows and campaigns act on that data. The machine-readable contract is /v1/openapi.json.
Authentication
An owner makes an API key under Settings, API keys in the Ringmaster console. Send it as Authorization: Bearer rm_live_…. A key belongs to one organization and works only on /v1. It cannot read data or reach the console or MCP.
Bodies are JSON, at most 256 KB. POST /v1/events takes one event or up to 100 as { events: [...] }.
Idempotency
Every request needs an Idempotency-Key header of up to 255 characters. Keep it the same for every attempt at one piece of work. If your app sends from an outbox table, use the outbox row's id, so every retry of that row sends the same key.
- The same key with the same request gets the first answer again, with
Idempotent-Replayed: true, and stores nothing new. Key order inside the JSON does not matter. - The same key with a different body or endpoint gets 409.
- Ringmaster keeps each key for 7 days. A retry after that runs again.
Retries
Retry a network error, a timeout, 408, 425, 429 or any 5xx with exponential backoff, always with the same key. A request that already succeeded answers from the record and is not applied twice. Finish retrying within 7 days. Do not retry other 4xx answers, because the same request fails the same way.
Errors
Every error is { error: { code, message } }.
| Status | Code | What to do |
|---|
| 400 | missing_idempotency_key, invalid_idempotency_key, invalid_json | Fix the request. Do not retry it as it is. |
| 401 | unauthorized | The API key is missing, unknown or revoked. |
| 409 | idempotency_key_reused | The key was used for a different request. Do not retry; new work needs a new key. |
| 413 | payload_too_large | The body is over 256 KB. Split the batch. |
| 422 | invalid_request | error.issues lists each { path, message }. Fix the request. |
| 422 | unknown_topic, wrong_topic_kind | The topic key is not one of this organization's topics, or is the wrong kind. Fix the request. |
| 422 | unknown_flow, flow_not_active | The flow key is not one of this organization's flows, or the flow is not active. Fix the request, or activate the flow. |
| 5xx | internal_error | Retry with the same key. |
Topics and consent
Every email belongs to one of your organization's topics, which you manage in the console or through MCP. Each has a key, such as product-news, and a kind:
opt_out mails everyone until they unsubscribe.opt_in mails only people you subscribed with POST /v1/subscriptions, with the consent evidence you collected. A new subscription enrolls the profiles with that address in any flow that starts on subscribing to the topic, unless its consentedAt is more than a day old, which marks a backfill.service mails everyone, such as account notices. No one can unsubscribe; only a bounce or a spam complaint stops one.
POST /v1/suppressions stops one topic, or with no topic every opt-out and opt-in topic. Every email carries a link to the person's preference page, where they turn topics off and back on.
Consent webhooks
An owner adds a webhook URL under Settings, Webhooks, and copies its signing secret (whsec_…), which is shown once. Ringmaster then posts every consent change as JSON:
| type | When |
|---|
profile.unsubscribed | The person turned a topic off, or your app withdrew an opt-in subscription. |
profile.resubscribed | A suppression was lifted: by the person on their preference page, by your app, or in the console. |
profile.subscribed | Your app recorded opt-in consent. |
profile.suppressed | Your app or someone in the console suppressed the address, or it hard-bounced or drew a spam complaint. |
{
"id": "0f8a2c7e-5b1d-4e3a-9c6f-2d4b8e1a7c90",
"type": "profile.unsubscribed",
"occurredAt": "2026-10-29T12:00:00.000Z",
"org": "6b1e3f0a-9d2c-4b7e-8a5f-1c3d5e7f9a2b",
"email": "jane@example.com",
"topic": "product-news",
"reason": "unsubscribe_link"
}topic is the topic's key, or null for every topic. reason says who made the change: unsubscribe_link the person, through an email link or their preference page; api your app, through this API; admin someone in the console; bounce and complaint the person's mail provider. Skip api events if your app already knows about them.
Each request carries Ringmaster-Event-Id, the same as the body's id, and Ringmaster-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>, signed over <t>.<raw body> with the webhook's secret. Verify it against the raw body before you parse it, and refuse a timestamp more than 5 minutes old:
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is the request body exactly as received, before any JSON parsing.
function verifyRingmasterSignature(secret, rawBody, header, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}Answer any 2xx within 10 seconds. Anything else, a timeout or a redirect is retried after 30 seconds, then after a wait that doubles up to an hour, until 24 hours after the event; then it is marked failed in the console. A delivery can arrive more than once, so skip a Ringmaster-Event-Id you have handled.
Sends and engagement
Ringmaster sends from your organization's verified domains, through the Resend account ZNZ runs for every organization, in the layout ZNZ designs with you. Every email prints your postal address and a link to unsubscribe from its topic, or for a service topic a link to the preference page. Opt-out and opt-in emails also carry the one-click List-Unsubscribe header. Properties your app sends appear in emails as merge fields, such as {{ profile.first_name }}, and every value is HTML-escaped.
Each email Ringmaster sends or holds back is a send. Read them on the Sending page, or through MCP with list_sends. A send's status is one of these:
| status | Meaning |
|---|
sent | Resend accepted it. The engagement times fill in as Resend reports them. |
blocked | Consent stopped it: the person unsubscribed from the topic, has no opt-in subscription, bounced or complained. |
capped | Your organization had reached its monthly cap. |
paused | Sending was paused, by ZNZ or by the bounce and complaint limits, or your organization had no Ringmaster plan in good standing. error says which. |
failed | Resend refused it, or the From address was not on a verified domain. error says which. |
sending | On its way. |
firstOpenedAt and firstClickedAt keep the earliest open and click. A click counts as an open too, and a click on the unsubscribe link counts only as an open. Opens are seen only when the mail client loads images, so read them as a lower bound. A hard bounce or a spam complaint sets bouncedAt or complainedAt, suppresses the address in every topic with reason bounce or complaint, and posts profile.suppressed to your consent webhooks. A temporary bounce does neither.
Your organization may send up to its monthly cap, which ZNZ sets. Once it has sent 200 emails in 7 days, sending pauses on its own if 4% of them bounced or 0.08% drew a complaint, because one organization's bad list would put every organization's mail at risk. Only ZNZ resumes it. The Sending page shows the cap, this month's sends and both rates.
Flows and direct enrollment
A flow is a series of emails that runs from an event: its trigger names an event your app sends, with filters on the event's properties and the profile, and each step waits its delay, then goes out inside 08:00 to 20:00 in the recipient's time zone. Flows are designed in the console or through MCP, so the events you send are what start them. Send the event, and the flow decides who enters; you do not need to enroll anyone yourself.
POST /v1/enrollments is the exception, for a one-off or a backfill: it enrolls one profile in an active flow now, under the flow's reentry rule, its entry conditions and the person's consent, and answers whether it did and, if not, why (already_active, reentry, no_key, entry, exclusive, consent_blocked or no_email). It records a ringmaster.enroll event on the profile whose properties the flow's emails read as event.properties.
Segments and campaigns
A segment is a group of your profiles defined by their properties and by the events they sent, such as everyone who sent item_saved at least three times in the last 30 days, or who never sent charge-created. Ringmaster works out who is in it each time it is used, from the profiles and events you have sent so far, so keep sending them and a segment stays current on its own. Property names and event names in a segment are the ones your app sends.
A campaign is one email to a segment, in one topic, designed in the console or through MCP. Scheduling one first shows a dry run: who would get it, who consent leaves out, and when the first and last emails go. When it is scheduled, each person's email goes out inside 08:00 to 20:00 in their time zone, at most 50 every five minutes, and consent is checked again just before each one. Merge fields read profile.*, so a profile property your app sends, such as first_name, is {{ profile.first_name }} in a campaign.
Conversions
A flow can name a conversion goal: an event your app sends, such as purchase, and how many days after an email it still counts. Send that event to POST /v1/events when it happens, with occurredAt set to when it happened. To sum a value, such as revenue, give the event a numeric property and name it in the goal, such as event.properties.amount. A string such as "19.99" adds nothing.
Ringmaster credits each conversion event to the last flow email the profile got within the goal's days before it, and also counts it on every email in that window. Each flow's performance page shows both per step, beside sends, opens, clicks, unsubscribes, bounces, complaints and skipped steps. Agents read the same numbers through MCP with get_flow_performance, get_performance and get_overview.
Endpoints
POST /v1/events
Track events. Records one event, or up to 100 as { events: [...] }. Each event creates or updates its profile first, merging properties key by key.
Answers 202. Stored.
const response = await fetch(`${RINGMASTER_URL}/v1/events`, {
method: "POST",
headers: {
Authorization: `Bearer ${RINGMASTER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": outboxRow.id,
},
body: JSON.stringify({
"event": "register",
"profile": {
"id": "user_8f2c",
"email": "jane@example.com",
"properties": {
"first_name": "Jane",
"wedding_date": "2027-06-12"
},
"country": "US",
"timezone": "America/New_York"
},
"properties": {
"source": "signup_form"
},
"occurredAt": "2026-10-29T12:00:00Z"
}),
});PUT /v1/profiles/{id}
Create or update a profile. Creates the profile with your id, or updates it. Fields you leave out keep their stored value.
Answers 200. Stored.
const response = await fetch(`${RINGMASTER_URL}/v1/profiles/${encodeURIComponent("user_8f2c")}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${RINGMASTER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": outboxRow.id,
},
body: JSON.stringify({
"email": "jane@example.com",
"properties": {
"plan": "free"
}
}),
});DELETE /v1/profiles/{id}
Erase a profile. Deletes the profile and everything Ringmaster holds about it, for a GDPR erasure request. The audit log records the erasure without the email. Suppressions stay, because they are keyed by email.
Answers 200. Erased, or there was nothing to erase.
const response = await fetch(`${RINGMASTER_URL}/v1/profiles/${encodeURIComponent("user_8f2c")}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${RINGMASTER_API_KEY}`,
"Idempotency-Key": outboxRow.id,
},
});POST /v1/suppressions
Suppress an email address. Stops Ringmaster mailing the address in one topic or, with no topic, in every opt-out and opt-in topic. Use it when the person unsubscribes elsewhere. A service topic cannot be suppressed: only a bounce or a complaint stops one.
Answers 200. Suppressed. created is false when this suppression was already there.
const response = await fetch(`${RINGMASTER_URL}/v1/suppressions`, {
method: "POST",
headers: {
Authorization: `Bearer ${RINGMASTER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": outboxRow.id,
},
body: JSON.stringify({
"email": "jane@example.com"
}),
});DELETE /v1/suppressions/{email}
Lift a suppression. Removes the unsubscribes your app or the person made for the address and topic, or the every-topic ones with no topic. Suppressions made in the console, bounces and complaints stay. URL-encode the address.
Answers 200. Lifted, or there was nothing to lift.
const response = await fetch(`${RINGMASTER_URL}/v1/suppressions/${encodeURIComponent("jane@example.com")}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${RINGMASTER_API_KEY}`,
"Idempotency-Key": outboxRow.id,
},
});POST /v1/subscriptions
Record opt-in consent. Records that the person agreed to an opt-in topic, with the evidence you collected. An opt-in topic mails only subscribers. A suppression still stops the topic; lift it as well when the new consent should override it. A repeat keeps the first record. A new subscription whose consentedAt is within the last day enrolls each profile with the address in the flows that start on subscribing to the topic, so send the profile first; an older one is treated as a backfill and enrolls no one.
Answers 200. Recorded. created is false when the person was already subscribed.
const response = await fetch(`${RINGMASTER_URL}/v1/subscriptions`, {
method: "POST",
headers: {
Authorization: `Bearer ${RINGMASTER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": outboxRow.id,
},
body: JSON.stringify({
"email": "jane@example.com",
"topic": "newsletter",
"source": "signup_form",
"consentedAt": "2026-10-29T12:00:00Z",
"ip": "203.0.113.7",
"userAgent": "Mozilla/5.0"
}),
});DELETE /v1/subscriptions/{email}
Withdraw opt-in consent. Removes the person's subscription to an opt-in topic. URL-encode the address.
Answers 200. Removed, or there was nothing to remove.
const response = await fetch(`${RINGMASTER_URL}/v1/subscriptions/${encodeURIComponent("jane@example.com")}?topic=newsletter`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${RINGMASTER_API_KEY}`,
"Idempotency-Key": outboxRow.id,
},
});POST /v1/enrollments
Enroll a profile in a flow. Enrolls the profile in an active flow now, without waiting for its trigger event, for a one-off or a backfill. The profile is created or updated first. The flow's reentry rule and the person's consent still apply, so the answer says whether the profile was enrolled and, if not, why: already_active, reentry (the rule refused it), no_key (the flow enters per event property and properties lacks it), entry (the profile does not meet the flow's entry conditions), exclusive (the flow runs one marketing flow at a time and the profile is in another), consent_blocked or no_email. The properties stand in for the trigger event's in the flow's emails.
Answers 200. Decided.
const response = await fetch(`${RINGMASTER_URL}/v1/enrollments`, {
method: "POST",
headers: {
Authorization: `Bearer ${RINGMASTER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": outboxRow.id,
},
body: JSON.stringify({
"flow": "welcome",
"profile": {
"id": "user_8f2c",
"email": "jane@example.com",
"properties": {
"first_name": "Jane",
"wedding_date": "2027-06-12"
},
"country": "US",
"timezone": "America/New_York"
},
"properties": {
"source": "backfill"
}
}),
});