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
| Module | Contents | Phase |
|---|---|---|
common | Id (opaque string), Timestamp (ISO 8601 with offset), Email, Slug, Properties, cursor pagination (PageQuery, page()) | all |
errors | ErrorCode union, ERROR_STATUS (code to HTTP status), the ErrorBody envelope, RETRYABLE_ERRORS | all |
headers | Authorization, Idempotency-Key, the dashboard's subject and workspace headers, request id | all |
workspace | workspaces, members and roles, API keys, the audit log | S0 (foundation) |
templates | template documents and versions, compile request and result, uploaded assets | S1 (editor and templates) |
template-inputs | what a template's sending app declares it provides (templates.declareInputs), the merge names real sends carried, templateInputProblems, templateInputDrift | S1 |
providers, contacts, mailings, webhooks | providers with policies, topics, contacts, suppressions, mailings and their state machine, recipients, the sent archive, webhook endpoints, deliveries and events | S2 (sending) |
platform | tags, segments with a filter tree, signup forms, CSV import jobs, scheduling, A/B tests, analytics | S4 (typed, not served yet) |
billing | plans, limits, subscription, usage, checkout | S5 (typed, not served yet) |
routes | the route table, buildPath, matchRoute, acceptsIdempotencyKey | all |
webhook-signing | signWebhook, verifyWebhook and the header constants | S2 |
unsubscribe | the unsubscribe token layout (types only), reserved merge fields | S2 |
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_cursornull 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:
| Access | API key scope | Member role |
|---|---|---|
read | any | viewer or above |
write | send or full | editor or above |
admin | full | admin or above |
dashboard | refused | dashboard token plus x-mail-subject, no workspace yet (create a workspace, list the person's workspaces) |
public | none | none (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:
| Code | Status | When |
|---|---|---|
validation_failed | 400 | the body or query does not match the schema; details.issues lists the paths |
invalid_api_key, api_key_revoked | 401 | the key is unknown or revoked |
insufficient_role | 403 | the key scope or member role is below the route's access |
mailing_invalid_state | 409 | the action is not allowed in the mailing's status |
idempotency_key_reused | 409 | the same key was sent with a different body |
conflict | 409 | a template save based on an outdated base_version |
missing_unsubscribe_url | 422 | a broadcast whose document lacks {{unsubscribe_url}} |
compile_failed | 422 | sending a document whose compile reported errors |
invalid_mjml | 422 | MJML that cannot be imported; details.reason, line, column |
daily_budget_exhausted | 429 | the 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 inaddRecipients(the same one again isalready_present), a segment as the audience, an A/B test, or clearing the topic of a mailing that holds more than one recipient isvalidation_failedwithdetails.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 ascontact.unsubscribedwithtopic: null. The field is still required: a letter without it does not start (missing_unsubscribe_url). - Its
List-Unsubscribeheaders (the same RFC 8058 one-click headers a broadcast carries, withList-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
bouncedblocks (emittingcontact.resubscribedwithsource: 'bounce_reverted'for each), pauses the mailing with apause_reason, and sets the provider'srejections.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 answerprovider_anomaly(409) until an admin callsproviders.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
eventssays where they arrive (url) and whether the service can verify them (status:activeorneeds_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 eventsemail.bouncedandemail.complained, and store its signing secret withproviders.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 inevents.unmatchedand 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.updatetakestranslationsas a patch: a language set to a version replaces it, set tonullremoves 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 changelocaleto a language that has one, remove that version in the same save. - A
readyversion 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,preheaderand insertions are that version's, and itslocaleandsend_localeare 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 thesubjectgiven (subject_requiredwithout 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 bymailings.languagesor tested bymailings.test(anotherlocaleisnot_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 indetails.languages. send_localestays the version's: a different one, or null (each recipient's own language), is refused on create and onmailings.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.duplicatekeepstemplate_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
| Operation | Route | Access | Answer |
|---|---|---|---|
templates.importPreview | POST /v1/templates/import/preview { mjml } | read | 200 { document, warnings, compiled, remote_images, asset_policy }, nothing saved |
templates.import | POST /v1/templates/import { name, description?, mjml, import_remote_assets? } | write | 201 { template, warnings, imported_assets }, the template at version 1 |
templates.export | GET /v1/templates/:id/export?format=mjml|html&version=n | read | 200, the file |
mailings.export | GET /v1/mailings/:id/export?format=mjml|html | read | 200, 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): itsotherwiseis the fallback, in every language. - Choosing: the value of
byis read like a merge field (the recipient'smergevalues, the contact, the workspace'smerge_defaults). The text is the choice whose key is the value (trimmed, any casing), elsefilledwhen the value is filled and the insertion has one, elseotherwise. A value is filled when it is non-blank text, a finite number ortrue;false, blank and absent are not. Choice keys are lowercase letters, digits,-and_;filledandotherwiseare 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, whetherfilledis used) is the main language's.templates.updatetakesinsertionswhole; 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 withoutinsertionskeeps its stored texts. At most 20 insertions of 30 choices each; a name cannot be a reserved merge field (insertion_name), andbycannot beunsubscribe_urlor another insertion (insertion_by). - Ready means complete: marking a language
readywithout every text isready_needs_insertions(details.issuesnames each). When the main language gains a choice, every ready language without its text is set back todraftin 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, andinsertionsonmessage.sent):{ "opening": { "value": "venue", "chosen": "venue", "matched": true } }. A filled value no choice takes and nofilledtext catches goes out withotherwiseandmatched: false;mailings.languagescounts such values ininsertion_missesbefore 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.testandautomations.testEmailrender 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 onkind):text(free text, a name or a title),url(an address; the example must behttporhttps),boolean(yes or no;falseand absent are both no) andone_of(1 to 30values, 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.examplehas the kind's type and, forone_of, is one of the values.optional: truesays the app may send it empty or not at all. - Source:
form_fieldwith theformandfieldas a person sees them on the site (and an optionalnote), orderivedwith anotesaying how the app works it out. Labels, forms, fields andappare one line of at most 80 characters, notes at most 300. - Names are merge-field names, at most
MAX_TEMPLATE_INPUTS(50).emailandunsubscribe_urlare refused (input_reserved): the service fills them and never reads a client's value.first_nameandlast_namecan be declared, since the recipient's merge values come first. - Refusals: a shape problem is
validation_failedwithdetails.issues; the checks beyond the shape (templateInputProblems) add a stabledetails.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
sendorfullscope: the declaration is the app's statement about its own code. A dashboard member is refused withforbiddenanddetails.reason: "api_key_only"(the route's access level iswrite; the service enforces the rest). - Not an edit: it takes no
base_version, creates no template version, never changesversionorupdated_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 answerschanged: falseand writes nothing. A newapp_versionalone movesdeclared_at(so the editor can show a stale sync) without an audit entry; new inputs, a newappor another key write atemplate.inputs_declaredaudit entry naming the inputs and, when replaced, the previous inputs, app and key. - Read: every read of a template (
templates.get, and the template increate,updateandimport) carriesinputs(the stored declaration withdeclared_byanddeclared_at, or null) andinputs_seen. The list (TemplateSummary) and saved versions carry neither. - What was really sent:
inputs_seenholds each merge name the real sends of the template carried (a recipient'smergekeys, on a mailing, a letter or an automation email made from the template), withcount,first_seen_atandlast_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 mostMAX_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):
| Header | Value |
|---|---|
x-mail-timestamp | Unix time in seconds |
x-mail-signature | v1=<hex HMAC-SHA256 over "${timestamp}.${rawBody}">, several comma-separated during a rotation |
x-mail-event-id | the 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.