EEmail Editor

Mail service API contract (v1)

@marlinjai/mail-contract is the single definition of the mail service's v1 Application Programming Interface (API). The service (apps/service), the typed client (@marlinjai/mail-sdk) and the dashboard (apps/dashboard) all import it; none of them keeps its own copy of a request or response shape. A change to the API is a change to this package first.

It depends on zod 3 only and uses no Node built-ins, so it runs in Node, in the browser and on edge runtimes. Consumers must resolve the same zod 3 major: mixing in zod 4 breaks type identity between packages.

What is in it

ModuleContentsPhase
commonId (opaque string), Timestamp (ISO 8601 with offset), Email, Slug, Properties, cursor pagination (PageQuery, page())all
errorsErrorCode union, ERROR_STATUS (code to HTTP status), the ErrorBody envelope, RETRYABLE_ERRORSall
headersAuthorization, Idempotency-Key, the dashboard's subject and workspace headers, request idall
workspaceworkspaces, members and roles, API keys, the audit logS0 (foundation)
templatestemplate documents and versions, compile request and result, uploaded assetsS1 (editor and templates)
template-inputswhat a template's sending app declares it provides (templates.declareInputs), the merge names real sends carried, templateInputProblems, templateInputDriftS1
providers, contacts, mailings, webhooksproviders with policies, topics, contacts, suppressions, mailings and their state machine, recipients, the sent archive, webhook endpoints, deliveries and eventsS2 (sending)
platformtags, segments with a filter tree, signup forms, CSV import jobs, scheduling, A/B tests, analyticsS4 (typed, not served yet)
billingplans, limits, subscription, usage, checkoutS5 (typed, not served yet)
routesthe route table, buildPath, matchRoute, acceptsIdempotencyKeyall
webhook-signingsignWebhook, verifyWebhook and the header constantsS2
unsubscribethe unsubscribe token layout (types only), reserved merge fieldsS2

Conventions

  • JSON on the wire, snake_case fields. Timestamps are strings, never Date.
  • Ids are opaque. Clients never parse or construct them.
  • Request schemas validate, they do not transform (apart from trimming an email and coercing a query-string limit). Lowercasing an email is the service's job.
  • Lists are cursor-paginated: ?cursor=&limit= (1 to 100, default 50) and a { data, next_cursor } response, next_cursor null on the last page.
  • Reads strip unknown fields. A secret posted where a read shape is parsed never survives into the parsed value.

Authentication

A client (ÅŒPUNTIA's admin, any customer's backend) sends Authorization: Bearer <workspace API key>. The key is scoped to one workspace and to full, send or read.

The dashboard calls server-side with its service token in Authorization, plus x-mail-subject (the signed-in person's auth-brain subject) and x-mail-workspace. The service checks that person's membership and role on every call. Members are bound by auth-brain subject: the dashboard resolves the person and posts { subject, email, name, role }, since the service never sees a login. The liveness probe is GET /healthz, outside /v1 and without credentials.

A client whose one key is shared by everybody who uses its application may add x-mail-on-behalf-of to a key call: a name or an email address (trimmed, at most 200 characters, longer is invalid_request) naming the person the application acts for. The service records it on the audit entry's actor as on_behalf_of, where the dashboard shows it as reported by the client's application. It is a label, never a credential: it is not verified and it changes nothing about what the key may do. A client that wants verified per-person attribution creates one key per person instead. The header is ignored on a dashboard call.

Every route declares an access level:

AccessAPI key scopeMember role
readanyviewer or above
writesend or fulleditor or above
adminfulladmin or above
dashboardrefuseddashboard token plus x-mail-subject, no workspace yet (create a workspace, list the person's workspaces)
publicnonenone (signup form submission only)

Errors

Every non-2xx response is { "error": { "code", "message", "details"? } }. Switch on code, never on message. The codes and their statuses are ERROR_STATUS in errors.ts; the ones a sending client meets most:

CodeStatusWhen
validation_failed400the body or query does not match the schema; details.issues lists the paths
invalid_api_key, api_key_revoked401the key is unknown or revoked
insufficient_role403the key scope or member role is below the route's access
mailing_invalid_state409the action is not allowed in the mailing's status
idempotency_key_reused409the same key was sent with a different body
conflict409a template save based on an outdated base_version
missing_unsubscribe_url422a broadcast whose document lacks {{unsubscribe_url}}
compile_failed422sending a document whose compile reported errors
invalid_mjml422MJML that cannot be imported; details.reason, line, column
daily_budget_exhausted429the provider's rolling 24 hour budget is spent

Idempotency

Every mutating call (anything but GET) accepts Idempotency-Key. A replay with the same key and body within 24 hours returns the first response; the same key with a different body is idempotency_key_reused. Every answer below 500 is stored, refusals included, so the same key keeps answering with the same refusal; only a 5xx releases the key for a real retry. The SDK sends a key on every mutating request, so a retry after a network failure never applies twice.

Adding recipients is idempotent on the email address even without a key: a batch reports added, already_present and rejected (by index).

Mailings: states and actions

stateDiagram-v2
[*] --> draft
draft --> scheduled: schedule (S4)
draft --> sending: send
scheduled --> sending: send / time reached
sending --> paused: pause
paused --> sending: resume
sending --> sent: queue drained
sending --> partially_failed: queue drained with failures
partially_failed --> sending: retry-failed
draft --> cancelled: cancel
scheduled --> cancelled: cancel
sending --> cancelled: cancel
paused --> cancelled: cancel

MAILING_TRANSITIONS holds this table as data, and canTransition(status, action) answers from it; the service, the dashboard buttons and the SDK all read the same table. Any other combination is mailing_invalid_state.

Recipients move queued, sending, then sent, failed or skipped. A skipped recipient has a skip_reason: suppressed, not_subscribed, contact_erased, cancelled, or outcome_unknown. The last one marks a recipient the worker was sending to when it crashed, with no archived message to prove whether the provider accepted it. It is never retried automatically, because a duplicate cannot be unsent: retry-failed requeues it only with include_outcome_unknown: true, which is a human's decision.

A mailing's metadata (up to 20 string values) is echoed in every message webhook, so a client can file an event (who sent it, what kind of mailing) without a lookup.

Letters: a mailing with no topic

A mailing created without a topic (or updated to topic: null while it is a draft) is a letter: one-to-one mail from a client's backend, such as the reply to a contact form. It differs from a broadcast in four ways:

  • One recipient at most (MAX_LETTER_RECIPIENTS). A second person in addRecipients (the same one again is already_present), a segment as the audience, an A/B test, or clearing the topic of a mailing that holds more than one recipient is validation_failed with details.reason: "letter_has_one_recipient".
  • Consent: only a block on every topic stops it (an all-topics unsubscribe, a hard bounce, a complaint, a manual all-topics block); a block on one topic does not, and there is no subscription check, since there is nothing to be subscribed to. A contact the letter creates is subscribed to nothing.
  • Its {{unsubscribe_url}} opens the hosted page with every topic listed and "unsubscribe from everything" as the first, primary action; using it blocks the address on every topic, reported as contact.unsubscribed with topic: null. The field is still required: a letter without it does not start (missing_unsubscribe_url).
  • Its List-Unsubscribe headers (the same RFC 8058 one-click headers a broadcast carries, with List-Unsubscribe-Post: List-Unsubscribe=One-Click) point at that all-topics page rather than a topic's, so a mail client's unsubscribe button does what the footer link does: it blocks the address on every topic. Until 2026-09-26 a letter carried none, and ÅŒPUNTIA's confirmations, whose only way out was the footer link, were junked by iCloud.

How to send one from a form, step by step (three calls, idempotency keys from the client's own submission key, no contact upsert): the mail SDK's README, Recipe: a confirmation letter after a form.

Mailing.topic is therefore string | null. A client must understand a null topic (contract 0.4.0 or later) before the first letter exists in its workspace, since an older client's response validation refuses it.

Bounces and complaints

The service blocks an address on every topic when it hard-bounces or its owner marks a message as spam, whatever the client sends. The block is a suppression with reason bounced or complained and source_message_id naming the message, when known, and it emits contact.bounced (reason tells which). suppressions.list filters by reason. Lifting such a block by hand (suppressions.delete, admin) lets the next mailing reach the address again; if it bounces again, it is blocked again.

  • Every provider: a hard bounce the receiving server reports while the message is handed over (SMTP enhanced status 5.1.x or 5.2.1, or a 550, 551 or 553 whose text names the recipient). A rejection that is the sender's problem (5.7.x: authentication, relaying, content, reputation, rate) never blocks the recipient; it is counted on the provider as rejections (count, last_error, last_at). A 5.1.x reply that names the sender or the setup counts as the sender's problem too.
  • The bounce circuit breaker: when, in one run of a mailing, 5 recipients in a row are refused with the same reply, or more than 20 percent of the first 50 are refused, the refusals are more likely the provider's fault than the list's. The service undoes that run's bounced blocks (emitting contact.resubscribed with source: 'bounce_reverted' for each), pauses the mailing with a pause_reason, and sets the provider's rejections.anomaly. Fix the provider, then resume; the resumed run is watched afresh. Across a provider's sends (one-to-one mailings and test sends included), 5 refusals in a row with the same reply within 24 hours halt the provider (anomaly.scope: 'provider', blocking: true): no blocks, and test sends, sends, resumes and retries answer provider_anomaly (409) until an admin calls providers.clearAnomaly (POST /v1/providers/:id/clear-anomaly). A block a bounce hardened (an unsubscribe) is restored, not lifted.
  • Resend: also bounces and spam complaints reported afterwards, through Resend's webhooks. A Resend provider's events says where they arrive (url) and whether the service can verify them (status: active or needs_secret). Creating or verifying the provider registers the endpoint at Resend when the API key allows it; with a sending-only key, add a webhook at Resend for that URL with the events email.bounced and email.complained, and store its signing secret with providers.setEventsSecret (PUT /v1/providers/:id/events-secret, { signing_secret: "whsec_..." }). Only an event for a message the service sent through that provider acts. An event for an unknown email is asked to be redelivered (503) during its first hour, in case the send is not recorded yet; after that it is counted in events.unmatched and never blocks anyone.
  • Not detected: bounces that an SMTP server reports later as an email to the sender's inbox. That is how iCloud+ reports almost all of them, so with an iCloud+ provider, addresses that bounce later have to be blocked by hand.

Compile

POST /v1/templates/:id/compile and POST /v1/compile both return { mjml, html, warnings, errors } with status 200 whenever compilation ran. A non-empty errors means the HTML must not be sent; warnings are shown and do not block. Only an unreadable document is a 4xx. templates.compile takes an optional locale to compile one language version (see below); a language the template has no version in is not_found, never another language.

Languages

A workspace lists the languages it writes in (settings.locales, language tags such as en, fr, pt-BR, stored in canonical casing, none twice) and its main one (settings.default_locale).

A template is written in its main language, locale (the workspace default when it is created, unless given; a later change of the default does not relabel it), with that language's subject, preheader and document. Every other language it has is a version in translations, keyed by tag: { subject, preheader, document, status }, where status is draft or ready. A send gives each recipient the ready version of their language, and the main language to everyone else. So a draft can be previewed and test-sent, but no real recipient gets it until it is marked ready, which needs its own subject.

Writing languages, and what is refused (validation_failed, with details.reason):

  • templates.update takes translations as a patch: a language set to a version replaces it, set to null removes it, left out is untouched. Each save is one version of the template, all languages together, so restoring an old version restores every language as it was.
  • A version being set has to be in one of the workspace's languages (not_a_workspace_language). A version whose language the workspace later drops is kept and can be removed, but not written, and no send uses it.
  • The main language cannot also have a separate version (main_language_version); to change locale to a language that has one, remove that version in the same save.
  • A ready version without a subject is refused (ready_needs_subject).
  • A blank subject or preheader is stored as null.

templates.list names each template's languages and their status (languages: { fr: "ready", de: "draft" }) without the documents. It sorts by sort (updated_at, the default; name, without regard to case; version) in order (asc or desc; by default the name A to Z, the most recent change and the highest version first), ties broken by the id, and a cursor continues the order it came from. templates.export takes locale like compile.

Sending in each recipient's language

A mailing made from a template takes the template's main language, subject and preheader (its locale is the template's). The template's own subject and preheader win: mailings.create needs subject only when the template has none (subject_required), and refuses one that differs from the template's (subject_from_template). A mailing made from a document is in the workspace's default language and needs its subject as before.

When the mailing starts, the template's ready versions in the workspace's languages are compiled beside the main content and fixed for the mailing: a version marked ready after the mailing was created still goes out, and nothing that changes in the template after the start reaches it (a pause and resume keeps what it started with; a duplicate takes the template's versions again). Each ready version is checked like the main content at the start, and a problem in one is refused with details.locale.

Each recipient's language is, in order: the mailing's send_locale when set (one language for everyone, the way a letter sends in the language its reader wrote in), else the recipient's own merge.locale, else their contact's locale. It is matched to a version without regard to casing, then by language (fr-CA gets fr); anyone without a match gets the main language. Every archived message records the language it went out in (locale).

mailings.languages (GET /v1/mailings/:id/languages) says who gets what, exactly as the worker resolves it: each language with its status (main, ready, draft, not_a_workspace_language), the queued recipients planned for it and the messages sent in it; fallback, the recipients who read a language with no ready version and get the main one, grouped by that language; and no_language, those with no language at all.

mailings.test takes locale to send one version, a draft one too; a language the mailing has no version in is not_found, never another language. An A/B test needs a mailing that sends one language (ab_test_single_language): with ready versions in several languages, set send_locale first.

Writing from one language version

A letter written by hand (a reply to a contact request, an outreach letter) starts from the version in its reader's language and goes out as that one letter. mailings.create takes template_locale beside template_id for this: the main language or any language version, a draft one too.

  • The mailing's document, subject, preheader and insertions are that version's, and its locale and send_locale are set to it, so everyone on it reads that language. The subject rule above applies against the version: one without a subject of its own takes the subject given (subject_required without one), one with a subject keeps it (subject_from_template). Nothing here needs a version to have a subject of its own.
  • The mailing records the version as template_locale, and sends that version only: none of the template's other versions, ready or not, is compiled at the start, listed by mailings.languages or tested by mailings.test (another locale is not_found). This holds for the main language too, so a letter written in English is not joined by a ready translation's empty frame.
  • A draft goes out as written, so every insertion needs its text in that language: a missing one is refused (validation_failed, details.reason: "template_locale_incomplete", details.missing).
  • A language the template has no version in is refused (validation_failed, details.reason: "template_locale_missing"), with the template's languages in details.languages.
  • send_locale stays the version's: a different one, or null (each recipient's own language), is refused on create and on mailings.update (send_locale_from_template_locale); leaving it out is fine, and the same one sent back is no change. To write in another language, create another mailing. mailings.duplicate keeps template_locale.

Automations and signup confirmations

An automation's email step from a template works like a mailing: publishing takes the template's main content, its subject and preheader when it has them (an email step needs no subject of its own then), and its ready versions in the workspace's languages, into the published version (locale and languages on the step). Each person gets the step in their contact's language, the main language otherwise. A version marked ready later reaches the automation when it is published again; a version that does not compile blocks publishing with details.locale. automations.testEmail takes locale, a draft version too.

A signup form's custom confirmation template is compiled per language when the form is saved: the main language and each ready version, each needing its {{confirm_url}}. The confirmation goes out in the language the person signed up in when there is a ready version in it, else in the template's main language, and its subject comes from the same version (the template's own, else the built-in subject in that language), so a subject and a body never differ in language. The built-in subject exists in the hosted pages' five languages only, so a template written in another language needs its own subject (confirmation_needs_subject).

MJML import and export

OperationRouteAccessAnswer
templates.importPreviewPOST /v1/templates/import/preview { mjml }read200 { document, warnings, compiled, remote_images, asset_policy }, nothing saved
templates.importPOST /v1/templates/import { name, description?, mjml, import_remote_assets? }write201 { template, warnings, imported_assets }, the template at version 1
templates.exportGET /v1/templates/:id/export?format=mjml|html&version=nread200, the file
mailings.exportGET /v1/mailings/:id/export?format=mjml|htmlread200, the mailing's content snapshot as a file

Import. The service reads the MJML (at most MAX_MJML_IMPORT_BYTES, 512 KB, else payload_too_large) in its compile workers under the compile deadline. Attributes a block has become its fields; the rest are kept and emitted again, and the document keeps its mj-attributes, so the mail compiles as the source did. What the editor cannot hold as a block becomes a Raw HTML block with the compiled output of exactly that part. Nothing is dropped silently: each change is an ImportWarning (severity info or warning, a stable code such as kept_as_html or unknown_component, a path like mj-body > mj-section[2] > mj-column[1] > mj-social[1], the line, and for a fallback the MJML fragment). MJML that cannot be read at all is invalid_mjml (422) with details.reason (MJML_IMPORT_REFUSALS: invalid_xml, not_mjml, include_not_supported, too_deep, too_many_elements, too_complex, invalid_document) and, when it is one place, details.line and details.column. mj-include is always refused: an import has no files.

The preview lists remote_images, the images the document loads from outside the service and outside the workspace's settings.allowed_asset_hosts. Under the service_only asset policy each is also a compile error. With import_remote_assets: true, templates.import copies each (https only, no private addresses, no redirects, images only, at most MAX_IMPORTED_REMOTE_IMAGES, 50) into the workspace's assets exactly as assets.import does and points the document at the copies (imported_assets); one that cannot be copied stays remote and is a remote_image_not_imported warning, never a failed import. templates.import takes an Idempotency-Key like every mutating call: a retry with the same key and body answers with the first template.

Export. The answer is the file itself, not JSON (responseType: 'text' in the route table): text/plain; charset=utf-8 for MJML (it has no registered media type), text/html; charset=utf-8 for HTML, Content-Disposition: attachment with a file name from the template's name (exportFilename), nosniff and a sandboxing content security policy. The service's own asset addresses are absolute. An export is never refused for its content: MJML errors and addresses the workspace's asset policy does not allow are in x-mail-export-warnings (URL-encoded JSON of CompileMessage[], shortened to fit; read it with parseExportWarningsHeader) and counted in x-mail-export-warning-count. Errors are the JSON envelope as everywhere. The SDK's templates.export and mailings.export return { content, contentType, filename, warnings, warningCount }.

Merge fields

{{name}} in a document is filled per recipient, HTML-escaped, from the recipient's merge values, then the contact's properties, then the workspace's settings.merge_defaults. A fallback is written {{first_name|there}} and is used when none of the three has a value; a paragraph made only of fields that all come out empty is left out (see Insertions). Reserved names (RESERVED_MERGE_FIELDS): first_name and last_name (with fallback), email, and unsubscribe_url, which the service fills and which every broadcast must contain. missingRequiredMergeFields(html) is the check the service runs before send.

merge_defaults (MergeDefaults) is for what is the same in every mail of the workspace: the operator's legal name and postal address for the footer, the address of the privacy policy. Keys are merge-field names (MergeFieldName, ^[a-z][a-z0-9_]*$), the reserved four are refused, values are strings, numbers or booleans, at most MAX_MERGE_DEFAULTS (50). Unlike a contact's properties, workspace.update replaces the whole map: send every default, not only the changed one.

Insertions

An insertion is a small set of sentences a template chooses between per recipient, by one merge value, written in every language the template has (docs/plans/2026-09-25-translated-insertions.md). The client sends data (a contact reason, whether a form field was filled, a name), never prose: every sentence a reader sees is in the template, in their language, reviewed with that language.

// templates.insertions (the main language: structure and texts)
{
  "opening": {
    "by": "reason",
    "choices": { "venue": "Thank you for thinking of us as a venue.", "press": "Thank you for your press enquiry." },
    "filled": null,
    "otherwise": "Thank you for writing to us."
  },
  "greeting": { "by": "first_name", "choices": {}, "filled": "Dear {{first_name}},", "otherwise": "" }
}
// templates.translations.de.insertions (texts only, the same keys)
{
  "opening": { "choices": { "venue": "Danke, dass du an uns als Ort denkst.", "press": "Danke für deine Presseanfrage." }, "filled": null, "otherwise": "Danke für deine Nachricht." },
  "greeting": { "choices": {}, "filled": "Hallo {{first_name}},", "otherwise": "" }
}
  • Placing one: {{opening}} in a body, subject or preheader, like a merge field. When a name is both an insertion and a merge value the client sends, the insertion wins. An insertion takes no {{opening|...}} fallback (insertion_with_fallback): its otherwise is the fallback, in every language.
  • Choosing: the value of by is read like a merge field (the recipient's merge values, the contact, the workspace's merge_defaults). The text is the choice whose key is the value (trimmed, any casing), else filled when the value is filled and the insertion has one, else otherwise. A value is filled when it is non-blank text, a finite number or true; false, blank and absent are not. Choice keys are lowercase letters, digits, - and _; filled and otherwise are not keys, since they name the other two texts.
  • Texts are plain text with merge fields and line breaks, at most 2,000 characters, escaped like a merge value; they cannot place another insertion (insertion_text). An empty text shows nothing.
  • Structure (names, by, which choices exist, whether filled is used) is the main language's. templates.update takes insertions whole; what it leaves out is removed from every language in the same save, and a language's text for a name or choice the main language lacks is refused (unknown_translation_insertion). A language version saved without insertions keeps its stored texts. At most 20 insertions of 30 choices each; a name cannot be a reserved merge field (insertion_name), and by cannot be unsubscribe_url or another insertion (insertion_by).
  • Ready means complete: marking a language ready without every text is ready_needs_insertions (details.issues names each). When the main language gains a choice, every ready language without its text is set back to draft in the same save (the audit entry names them), so its readers get the main language whole until the text is written; one sentence of another language never appears in a letter.
  • Sending: a mailing takes the template's insertions with its document; each language it starts with carries its own texts, and a language whose texts do not cover the mailing's insertions is not sent (its readers get the main language). Automation email steps and signup confirmations snapshot them the same way. Every archived message records the choice each insertion made (Message.insertions, and insertions on message.sent): { "opening": { "value": "venue", "chosen": "venue", "matched": true } }. A filled value no choice takes and no filled text catches goes out with otherwise and matched: false; mailings.languages counts such values in insertion_misses before and after the send. A form offering the template's keys as fixed choices never produces one: insertionInputs(template.insertions) lists what each merge value accepts.
  • Empty lines vanish: a paragraph made only of insertions and merge fields that all come out empty is removed, and a text block left with nothing visible is removed with its row and padding. A paragraph with any other words, and an empty paragraph with no field in it, stay.
  • Tests: mailings.test and automations.testEmail render a draft language too; a text not written yet shows as [no de text yet for opening, press].

personalizeHtml and personalizeText in the contract are the rendering the service runs, so a preview with sample values shows what a recipient with those values gets.

Template inputs

A template can carry a declaration of the merge values its sending app provides: for each one a label, where it comes from, the kind of value and an example (docs/plans/2026-09-25-insertions-ux.md, slice X1). The app sends it from its own code on every deploy, so an editor can say where every value comes from ("Contact reason, Contact form, 'Reason' drop-down, 7 values") and name an insertion's rows by the declared values. It changes nothing about how a letter is rendered or sent, and a template without one behaves exactly as before.

// PUT /v1/templates/:id/inputs  (templates.declareInputs)
{
  "app": "ÅŒPUNTIA website",
  "app_version": "4d59e60",
  "inputs": {
    "first_name": {
      "kind": "text",
      "label": "First name",
      "source": { "kind": "form_field", "form": "Contact form", "field": "Name" },
      "optional": true,
      "example": "Anna"
    },
    "reason": {
      "kind": "one_of",
      "label": "Contact reason",
      "source": { "kind": "form_field", "form": "Contact form", "field": "Reason (dropdown)",
                  "note": "Also set by /contact?reason=; anything unknown is sent as general." },
      "values": [
        { "value": "general", "label": "General question" },
        { "value": "venue", "label": "I manage a venue" },
        { "value": "press", "label": "Press & Media" }
      ],
      "example": "venue"
    }
  }
}
// 200
{ "template_id": "...", "changed": true,
  "inputs": { "app": "ÅŒPUNTIA website", "app_version": "4d59e60", "inputs": { ... },
              "declared_by": { "api_key_id": "...", "api_key_name": "ÅŒPUNTIA website (production)" },
              "declared_at": "2026-09-25T10:00:00.000Z" } }
  • Kinds (TemplateInput, discriminated on kind): text (free text, a name or a title), url (an address; the example must be http or https), boolean (yes or no; false and absent are both no) and one_of (1 to 30 values, each the key the app sends, in an insertion choice key's syntax, with the label the site shows). The editor names an insertion's rows by it: Yes and No; Has a value and Is empty; a row per declared value, then Anything else and Not given. example has the kind's type and, for one_of, is one of the values. optional: true says the app may send it empty or not at all.
  • Source: form_field with the form and field as a person sees them on the site (and an optional note), or derived with a note saying how the app works it out. Labels, forms, fields and app are one line of at most 80 characters, notes at most 300.
  • Names are merge-field names, at most MAX_TEMPLATE_INPUTS (50). email and unsubscribe_url are refused (input_reserved): the service fills them and never reads a client's value. first_name and last_name can be declared, since the recipient's merge values come first.
  • Refusals: a shape problem is validation_failed with details.issues; the checks beyond the shape (templateInputProblems) add a stable details.reason: input_reserved, input_values (a value listed twice), input_example (an example that is not a value). A refusal writes nothing.
  • API keys only, with send or full scope: the declaration is the app's statement about its own code. A dashboard member is refused with forbidden and details.reason: "api_key_only" (the route's access level is write; the service enforces the rest).
  • Not an edit: it takes no base_version, creates no template version, never changes version or updated_at, and never conflicts with an edit in progress. It replaces the stored declaration whole; a second app declaring replaces the first.
  • Idempotent twice over: like every mutating route it takes an Idempotency-Key, and the same declaration from the same key again answers changed: false and writes nothing. A new app_version alone moves declared_at (so the editor can show a stale sync) without an audit entry; new inputs, a new app or another key write a template.inputs_declared audit entry naming the inputs and, when replaced, the previous inputs, app and key.
  • Read: every read of a template (templates.get, and the template in create, update and import) carries inputs (the stored declaration with declared_by and declared_at, or null) and inputs_seen. The list (TemplateSummary) and saved versions carry neither.
  • What was really sent: inputs_seen holds each merge name the real sends of the template carried (a recipient's merge keys, on a mailing, a letter or an automation email made from the template), with count, first_seen_at and last_seen_at. Names only, never values. Test sends are not counted, and neither are signup confirmations (their merge values are the service's own). At most MAX_TEMPLATE_INPUTS_SEEN (200) names are kept per template; names already seen keep counting. templateInputDrift(inputs, inputs_seen) lists the inputs declared but never sent and the names sent but never declared.

Webhooks

Events: message.sent, message.failed, contact.unsubscribed, contact.resubscribed, contact.bounced, mailing.finished, plus the platform's (contact.subscribed, import.finished, mailing.scheduled, mailing.started, mailing.schedule_failed, mailing.ab_winner_selected, contact.tagged, contact.updated), the automations' (automation.run_*) and the templates' (template.created, template.updated, template.deleted). The template events exist for a client that embeds the editor beside the hosted dashboard and caches or mirrors templates: each carries the template's id, name, the version after the change and which user interface made it (api, dashboard, mjml_import), never the document, which the service holds. A client that reads templates live never needs them. contact.resubscribed has the shape of contact.unsubscribed with resubscribed_at in place of unsubscribed_at: an unsubscribed block was lifted, by the person on the hosted page (hosted_page), or through suppressions.delete (api, dashboard). Topic null means the block on every topic was lifted. Lifting a bounce, complaint or manual block sends nothing. A resubscribe on the hosted page to one topic also subscribes the contact to it; an API lift leaves subscriptions to your next upsert. A mirror that applies unsubscribes must apply it too, or it keeps excluding someone who asked to receive mail again. After an all-topics unsubscribe, a contact.resubscribed naming a topic lifts that topic only: the service turns the block on everything into blocks on every other topic, so a mirror that models "all topics" as one flag must expand it into per-topic blocks before applying the event. Every body is one envelope, { id, type, created_at, workspace_id, data }, parsed with WebhookEvent (a union discriminated on type). Deliveries may repeat: deduplicate on id.

Signing, per endpoint secret (prefix whsec_, shown once on create and on rotation):

HeaderValue
x-mail-timestampUnix time in seconds
x-mail-signaturev1=<hex HMAC-SHA256 over "${timestamp}.${rawBody}">, several comma-separated during a rotation
x-mail-event-idthe event id

A receiver verifies against the raw body before parsing and rejects anything more than 300 seconds from its clock:

import { verifyWebhook, WebhookEvent, WEBHOOK_SIGNATURE_HEADER, WEBHOOK_TIMESTAMP_HEADER } from '@marlinjai/mail-contract';

export async function POST(req: Request) {
  const rawBody = await req.text();
  const check = await verifyWebhook({
    secret: process.env.MAIL_WEBHOOK_SECRET!,
    rawBody,
    signatureHeader: req.headers.get(WEBHOOK_SIGNATURE_HEADER),
    timestampHeader: req.headers.get(WEBHOOK_TIMESTAMP_HEADER),
  });
  if (!check.ok) return new Response(check.reason, { status: 401 });
  const event = WebhookEvent.parse(JSON.parse(rawBody));
  // handle event.type, idempotently on event.id
  return new Response(null, { status: 204 });
}

HMAC is a hash-based message authentication code: only a holder of the secret can produce a matching signature. The helpers use Web Crypto and compare in constant time. A failed delivery is retried up to 8 times with growing delays (30 seconds up to 6 hours) and is visible with its status under /v1/webhooks/:id/deliveries.

Unsubscribe

The hosted page lives at <service>/u/<token>: GET shows the topics, POST applies, so link scanners never unsubscribe anyone. One-click unsubscribe (RFC 8058, the Request for Comments that defines List-Unsubscribe-Post) posts to the same URL. The token is an HMAC over workspace, contact, mailing and topic (UnsubscribeTokenClaims); only the service holds the key, so the contract documents the layout and implements nothing. Clients never build the link. A letter's token names no topic: its page offers every topic, and its one-click unsubscribe blocks them all.

Which sends carry List-Unsubscribe and List-Unsubscribe-Post: every mailing sent to a contact, so broadcasts, automation emails and letters, each pointing at the same URL as the body's {{unsubscribe_url}}; test sends too, pointing at the preview link, whose one-click answers without writing. Two sends deliberately carry none, because their recipient is on no list and has nothing to leave: an automation's internal notification to a colleague, and a signup form's double opt-in mail, which precedes consent and whose one link is the confirmation.

Route table

routes maps each operation id to its method, path, schemas, success status, access level and phase:

import { routes, buildPath, type RouteBody, type RouteResponse } from '@marlinjai/mail-contract';

const r = routes['mailings.addRecipients'];
const url = buildPath(r.path, { id: mailingId });          // "/v1/mailings/mlg_1/recipients"
const body: RouteBody<'mailings.addRecipients'> = { recipients: [{ external_id: 'p1', email: 'a@b.de' }] };
type Result = RouteResponse<'mailings.addRecipients'>;     // { added, already_present, rejected }

The phases are separate namespaces (foundationRoutes, templateRoutes, sendingRoutes, platformRoutes, billingRoutes) merged into routes. S4 and S5 routes are typed so those phases extend the contract rather than invent it; the service answers them with not_found until they ship.