Skip to content

Admin REST API ​

Mounted under /api inside createAdminRouter(mailer). With the default mount path /admin/mailer, endpoints live at /admin/mailer/api/*. Gated by your host's auth middleware.

All requests/responses are JSON. Mutating endpoints write to mailer_audit_log automatically with actor resolved via getActor (default human:${req.user?.email ?? 'anonymous'}).

Identity ​

GET /api/me ​

ts
→ { actor: string, permissions: { canPublish: boolean, canSendBroadcasts: boolean, canManageSuppressions: boolean } }

Called by the SPA on boot. The permissions object hides UI buttons the user can't activate.

Dashboard ​

GET /api/dashboard ​

Aggregated KPIs + recent activity.

ts
→ {
  kpis: {
    sends: { value: number, delta: number | null }
    deliveredRate: { value: number, delta: number | null, bounced: number }
    openRate: { value: number, delta: number | null, exclBots: boolean }
    clickRate: { value: number, delta: number | null }
  }
  health: { status: 'healthy' | 'degraded' | 'tripped', rates: Record<string, number> }
  queue: { inFlight: number, delayed: number, providerOk: boolean, providerName: string }
  recentFlows: FlowDoc[]
  recentSends: SendDoc[]
  recentAudit: AuditLogDoc[]
}

Events registry ​

GET /api/events ​

The full event registry — declared events with their dedupe policies, the event names seen in mailer_events that nobody registered, and per-name volume so a registered trigger can be checked against what the host really fires. A name in registered with no stats entry (or none in last30d) is a trigger nobody is reaching.

ts
→ {
  registered: { name: string; dedupePolicy: 'once-per-contact' | 'once-per-day' | 'every-time' }[]
  seen: string[]                       // names in mailer_events that are not registered
  stats: Record<string, {              // keyed by event name, registered or not
    total: number                      // rows ever
    last7d: number
    last30d: number
    firstAt: string                    // ISO
    lastAt: string
  }>
}

Flows ​

GET /api/flows ​

List all flows, newest-updated first.

GET /api/flows/:slug ​

Single flow's full definition.

POST /api/flows/:slug/pause ​

Set enabled: false. In-flight runs continue.

POST /api/flows/:slug/resume ​

Set enabled: true.

Categories ​

GET /api/categories ​

The categories declared in MailerConfig.categories, as { id, label, description?, defaultOptIn? }[]. Empty when none are declared. Read-only: config is the source of truth.

Programs ​

Programs (guide) are managed here. The agent API serves the same routes at /programs. All routes answer 404 { error: 'not_found' } for an unknown program and 404 { error: 'run_not_found' } for a subject with no run. Every mutating route is audited (program.save_draft, program.publish, program.enable, program.disable, program.enter, program.force_tick, program.abort) with this surface's actor (program.abort included: the route passes it to mailer.abortProgram).

GET /api/programs ​

[{ slug, name, version, enabled, draft, category, publishedAt, runs: { active, completed, exited, sunset, total } }]. draft is true when an unpublished draft exists.

POST /api/programs ​

Save a draft from a ProgramDefinition (the body itself, or { definition, notes? }). Creates the program disabled at version 0 when the slug is new (201 { ok, slug, version, created: true }); on an existing slug it replaces the draft (200, created: false). 400 validation_failed on a structural error (zod message). Semantic checks run at publish.

PATCH /api/programs/:slug ​

Replace the draft of an existing program. A slug in the body must match the path (it defaults to it).

POST /api/programs/:slug/publish ​

Validates the draft (categories, facts, templates, requires cycles) and publishes it. 200 { ok: true, version }, or 422 { error: 'validation_failed', issues: [{ path, message }] } with every issue, nothing published.

POST /api/programs/:slug/enable · POST /api/programs/:slug/disable ​

Enable requires a published definition (409 not_published) and MailerConfig.factsAdapter (409 facts_adapter_required). Enabling moves the entry watermark to now: entry events from before are not replayed, so enter existing accounts with /enter.

GET /api/programs/:slug ​

{ slug, version, enabled, published, publishedAt, publishedBy, draft, facts, templates, versions: [{ version, publishedAt, publishedBy }] }; published and draft are definitions (or null). facts is the facts adapter's declaration (or null); templates lists every existing template either definition sends: [{ slug, name, subject, kind, category, published }].

POST /api/programs/:slug/simulate ​

{ source?, subjectId?, facts?, now?, horizonDays? }. A read-only dry run of one tick plus the projected send sequence (assuming facts stay as they are and the subject never engages). With subjectId it uses that account's run and resolved facts (facts are merged over them); without, it simulates a brand-new subject from facts. Returns { source, version, now, subjectId, facts, run, arm, candidates, next, sequence, sequenceEnd }; next.reason is send | holdout | min-gap | delivery-window | session-suppressed | in-flight | no-recipients | none-eligible | completed | exited | sunset. Not audited. 400 validation_failed, 404 not_found, 409 no_definition | no_facts_adapter.

GET /api/programs/:slug/lint?source= ​

{ source, issues } for the draft (default when one exists) or the published definition. Each issue is { severity, code, path, message, actionId?, attempt? }: errors (invalid) are the publish-validation issues; warnings (template-unpublished, template-reused, priority-tie, no-cta, sunset-early) do not block publish. 400 for a bad source, 404 not_found, 409 no_definition.

GET /api/programs/:slug/runs?status=&arm=&limit=&skip= ​

{ runs, total, limit, skip }, newest entry first. status is active | completed | exited | sunset, arm is treatment | holdout; limit 1–200 (default 50).

GET /api/programs/:slug/runs/:subjectId?limit=&skip= ​

{ run, decisions, total, limit, skip } with decisions newest first. Each decision lists every candidate with blockedBy, the chosen action, reason, outcome and trigger: the answer to "why did or didn't this send".

GET /api/programs/:slug/state?subject=<id> ​

The checklist (mailer.getProgramState): [{ actionId, title, cta, status, isNext, attempts, completedAt }]. 400 without subject.

GET /api/programs/:slug/stats ​

Per action × arm funnel and per-arm run counts:

json
{ "actions": [{ "actionId": "connect-shopify", "title": "Connect Shopify",
  "treatment": { "evaluated": 120, "chosen": 90, "sent": 88, "satisfied": 31 },
  "holdout":   { "evaluated": 14,  "chosen": 10, "sent": 10, "satisfied": 3 } }],
  "runs": { "treatment": { "total": 120, "byStatus": { "active": 80, "completed": 31, "exited": 9, "sunset": 0 }, "completed": 31, "completionRate": 0.258 },
            "holdout": { "total": 14, "byStatus": {}, "completed": 3, "completionRate": 0.214 } } }

evaluated counts decisions that listed the action as an unblocked candidate. chosen counts decisions that picked it and went on to send (a silent tick such as min-gap keeps the chosen action on its row but is not counted). sent is treatment sends with status sent or delivered, and, for holdout, the simulated send rows. satisfied is runs where the action has a completedAt. completionRate is completed runs over runs, null with none.

POST /api/programs/:slug/runs/:subjectId/tick ​

Force one tick now (trigger: forced). It obeys every rule a scheduled tick does (gap, policy, holdout); { ok, result } with the engine's tick result.

POST /api/programs/:slug/runs/:subjectId/abort ​

{ reason? }. Exits the run and cancels its queued sends: { ok, aborted, cancelledSends }.

POST /api/programs/:slug/enter ​

{ subjectId }. Creates the subject's run now, without an entry event (backfills, previews). 201 { created: true }, or 200 { created: false } when it already has a run.

Templates ​

GET /api/templates ​

List all templates, newest-updated first. Each carries its category when it has one.

GET /api/templates/:slug ​

Single template's full definition.

PATCH /api/templates/:slug/draft ​

Stage changes on the template's unpublished draft. Every field is optional; only the ones present are written, so the editor can save one field without round-tripping the rest. A template with no draft yet gets one seeded from its published values first.

Exactly one of html, mjml or editorJson describes the body, and publish resolves them in that authoring precedence (editorJson → mjml → raw html). Sending mjml: '' or editorJson: null clears that source, which is how a template changes authoring mode.

fromEmail and kind are validated against the senderDomains registry as a pair, whether one or both are supplied.

ts
body: {
  subject?: string
  preheader?: string
  html?: string
  mjml?: string
  editorJson?: Record<string, unknown> | null
  notes?: string
  name?: string
  fromName?: string
  fromEmail?: string
  replyTo?: string | null
  kind?: 'marketing' | 'transactional'
  category?: string | null   // a declared category id; null clears it
  bodyFormat?: 'multipart' | 'text_only'
  trackOpens?: boolean
  trackClicks?: boolean
}
→ { ok: true }
→ 400 { error: 'sender_domain_invalid', code: string, message: string }
→ 400 { error: 'validation_failed', message: string }   // undeclared category, or a category on a transactional template

Broadcasts ​

GET /api/broadcasts ​

List all broadcasts, newest-created first, each with stats computed from its sends (see broadcasts → stats; the four keys delivered, opened, clicked, bounced are always present).

GET /api/broadcasts/:slug ​

Single broadcast with stats.

POST /api/broadcasts ​

ts
{ slug, name, templateSlug, segmentDefinition?, respectRecipientTimezone?, recipientCap?, order?, stopRules? }
→ { ok: true, slug }
→ 400 { error: 'validation_failed' | 'invalid_segment' | 'invalid_recipient_cap' | 'invalid_order' | 'invalid_stop_rules' }
→ 409 { error: 'slug_taken' | 'template_not_marketing' }
→ 422 { error: 'adapter_cannot_sort' }

The segment is validated leniently here (types only), so the composer can save a half-filled row; scheduling validates strictly.

PATCH /api/broadcasts/:slug ​

Same fields as create except slug. Draft only. → { ok: true }, 409 not_draft.

POST /api/broadcasts/:slug/segment/count ​

ts
{ segmentDefinition }       // the segment being edited, not necessarily saved
→ { upperBound, approximate: false, computedMs, hostMatched, eligible, alreadySent, recipientCount }

Since 0.18 upperBound is the exact count dispatch would send (host filter, post-filters, suppression, cap) — it keeps its name because the composer reads it. It was the host filter alone.

POST /api/broadcasts/:slug/schedule ​

ts
{ scheduledAt, confirmedCount, respectRecipientTimezone? }
→ { ok: true }
→ 400 { error: 'scheduledAt_required' | 'bad_scheduledAt' | 'confirmedCount_required' | 'invalid_segment' }
→ 409 { error: 'not_draft' | 'template_not_found' | 'template_not_marketing' | 'template_not_published' }

The admin path does not compare confirmedCount with the count (the composer's typed-count gate does); the agent path does.

POST /api/broadcasts/:slug/cancel ​

→ { ok: true }. Cancels the broadcast's queued and held sends as well; 409 already_finished for a sent or failed broadcast with nothing left to send.

Waves, stop-rule overrides, pause, resume and test sends are on the agent API.

Contacts ​

GET /api/contacts ​

Paginated list via the adapter.

?cursor=<string>&limit=<number>     (limit max 200)
→ { contacts: Contact[], nextCursor?: string }

GET /api/contacts/:externalId ​

Contact detail — adapter fields + subscription + recent events + recent sends + active flow runs.

ts
→ {
  contact: Contact
  subscription: SubscriptionDoc | null
  recentEvents: EventDoc[]
  recentSends: SendDoc[]
  activeRuns: FlowRunDoc[]
  preferences?: { marketing: boolean; categories: Record<string, boolean> }   // only when categories are declared
}

Sends ​

GET /api/sends ​

?status=<SendStatus>&limit=<number>  (limit max 500)
→ SendDoc[]

GET /api/sends/:id ​

ts
→ { send: SendDoc, webhookEvents: WebhookEventDoc[] }

Suppressions ​

GET /api/suppressions ​

List, newest-added first (limit 500).

POST /api/suppressions ​

ts
body: {
  email: string
  scope: 'all' | 'marketing' | 'transactional'
  reason: 'unsubscribed' | 'hard_bounce' | 'complaint' | 'manual' | 'list_cleaning' | 'gdpr_forget'
  source?: string
  notes?: string
}
→ { ok: true }

Audit log ​

GET /api/audit ​

List, newest-first (limit 200).

Vars schema ​

GET /api/vars-schema ​

The host's varsAdapter schema as JSON Schema (drives editor autocomplete and the unknown_variable lint rule), plus the built-in render-context keys.

ts
→ { schema: JSONSchema | null, builtins: string[] }

Templates — preview + test send ​

POST /api/templates/:slug/preview ​

Render the draft (or published body with useDraft: false). Pass contactId to render as a real contact — the contact is loaded through the adapter and the varsAdapter resolver runs (reason: 'preview'). Without it, a sample contact and empty host vars are used.

Pass html, mjml or editorJson to render that content instead of the stored draft, resolved in the usual authoring precedence. This is how the editor previews unsaved edits: a preview is a read, and making one should not write a draft — which is an audit-logged mutation — just to see the result.

Pass program: { slug, source?, actionId, attempt, facts?, subjectId? } to render as that Program's send would: {{program}}, {{action}}, {{attempt}} and {{facts}} are filled from the chosen definition (source defaults to the draft when one exists), and win over host vars. facts default to the resolved facts of subjectId, else empty. 400 validation_failed for a malformed option, 404 program_not_found when the program or that definition is missing.

Pass eventProperties to simulate a trigger event — both {{event.*}} and the resolver's info.eventProperties receive it.

ts
body: {
  useDraft?: boolean
  contactId?: string
  sampleContact?: Contact
  vars?: Record<string, unknown>
  eventProperties?: Record<string, unknown>
  html?: string
  mjml?: string
  editorJson?: Record<string, unknown> | null
}
→ { subject, preheader, html, plainText, contact: { externalId, email } }
→ 404 { error: 'contact_not_found', contactId }
→ 409 { error: 'not_published', message }
→ 422 { error: 'compile_failed', message }
→ 502 { error: 'vars_resolve_failed', message }

POST /api/templates/:slug/send-test ​

Send the published template to an operator-typed address. With contactId, the render uses that contact's data + resolved host vars (reason: 'test') while still delivering to to.

ts
body: { to: string; contactId?: string; sampleData?: { contact?: Contact; vars?: Record<string, unknown> }; eventProperties?: Record<string, unknown> }
→ { ok: true, providerId: string }

Templates — content linter + Mail-Tester ​

POST /api/templates/:slug/lint ​

Run the content linter against a draft. Body fields are all optional — missing fields fall back to the saved draft, then to the last published values. When the effective source resolves to raw HTML (see Templates → Hand-written HTML), the response's errors / warnings arrays also carry markup issues (unclosed tags, a <script> tag, a missing <html>/<body> wrapper, and the like) alongside the usual content rules.

ts
body: {
  subject?: string
  preheader?: string
  mjml?: string
  html?: string
  editorJson?: Record<string, unknown> | null
  fromEmail?: string
  kind?: 'marketing' | 'transactional'
}
→ {
  errors:   { rule: string; severity: 'error';   message: string; hint?: string }[]
  warnings: { rule: string; severity: 'warning'; message: string; hint?: string }[]
  infos:    { rule: string; severity: 'info';    message: string; hint?: string }[]
  compileFailed: boolean
}

POST /api/templates/:slug/publish ​

Publish the saved draft — its content may live in html, mjml or editorJson, resolved in that authoring precedence (editorJson → mjml → raw html); an HTML-only draft is stored verbatim with plain text derived. Rejects with 422 if the linter has errors. Rejects with 422 mail_tester_blocked when a cached Mail-Tester score is below minScore (code: 'low_score'), or — with mailTester.requireScore enabled — when the content has no score at all (code: 'no_score', score: null). Pass { bypassMailTester: true } to override the Mail-Tester gate (still subject to the lint gate).

ts
body?: { bypassMailTester?: boolean; category?: string | null }
→ 400 { error: 'validation_failed', message }   // the resulting category is undeclared or on a transactional template
→ 200 { ok: true, version: number, warnings: CompilerWarning[], lint: LintResult }
//   CompilerWarning ≈ { line?: number; message: string; tagName?: string; formattedMessage?: string }
//   These come from the MJML / Maily compiler — formatting warnings that did
//   not block compile (e.g. unknown attributes). Distinct from lint issues.
→ 422 { error: 'lint_failed', message: string, lint: LintResult }
→ 422 { error: 'mail_tester_blocked', message: string, score: MailTesterScoreDoc, hint: string }

GET /api/templates/:slug/mail-tester ​

Mail-Tester status + the cached score for the current content fingerprint, if one exists.

ts
→ {
  configured: boolean
  minScore: number
  cacheHours: number
  score: MailTesterScoreDoc | null
}

POST /api/templates/:slug/mail-tester-check ​

Provision a Mail-Tester check, send the rendered draft to the test address via the default provider, return either a cached score or a pending checkId to poll.

ts
→ {
  cached: boolean
  status: 'pending' | 'ready'
  checkId?: string
  contentKey?: string
  score?: MailTesterScoreDoc
  message?: string
}

Returns 400 { error: 'not_configured' } when MailerConfig.mailTester is absent.

GET /api/templates/:slug/mail-tester-result?checkId=...&contentKey=... ​

Poll the in-progress check. Returns { status: 'pending', score: null } until Mail-Tester is ready, then the persisted score.

Health ​

GET /api/health ​

Per-(sender domain × kind) buckets plus the aggregate roll-up plus the configured trip thresholds. The top-level status is the worst bucket status (or null when no tick has run).

ts
→ {
  status: 'healthy' | 'degraded' | 'tripped' | null
  rates: { bounceRate, hardBounceRate, complaintRate, failureRate } | null
  counters: HealthCounters | null
  aggregate: HealthDoc | null
  buckets: HealthDoc[]
  thresholds: {
    hardBounceRatePctTrip: number
    complaintRatePctTrip: number
    combinedBounceRatePctTrip: number
    failedToSendRatePctDegrade: number
  }
}

POST /api/health/resume ​

Manual reset. Pass { senderDomain, kind } to resume one bucket. Empty body resumes every tripped bucket.

ts
body?: { senderDomain?: string; kind?: 'marketing' | 'transactional' }
→ { ok: true, resumed: number }

Audit-logged.

GET /api/health/trips ​

The last 50 health.trip / health.resume audit-log rows.

DNSBL ​

GET /api/dnsbl ​

Latest scan results across all configured targets and lists.

ts
→ {
  checks: DnsblCheckDoc[]
  latestRunAt: string | null
  intervalHours: number
}

POST /api/dnsbl/recheck ​

Force an immediate scan (ignores throttle). Audit-logged.

ts
→ { ran: boolean; reason?: string; totalChecks?: number; listedCount?: number }

Google Postmaster Tools ​

GET /api/postmaster ​

ts
→ {
  configured: boolean
  intervalHours: number
  domains: Array<{
    domain: string
    latest: PostmasterSnapshotDoc | null
    history: PostmasterSnapshotDoc[]   // up to 30
  }>
}

POST /api/postmaster/refresh ​

Force an immediate pull (ignores throttle). Audit-logged.

ts
→ { ran: boolean; reason?: string; fetched?: number; trippedDomains?: string[] }

Microsoft SNDS ​

GET /api/snds ​

ts
→ {
  configured: boolean
  intervalHours: number
  ips: Array<{
    ip: string
    latest: SndsSnapshotDoc | null
    history: SndsSnapshotDoc[]   // up to 30
  }>
}

POST /api/snds/refresh ​

Force an immediate pull (ignores throttle). Audit-logged.

ts
→ { ran: boolean; reason?: string; rowsParsed?: number; rowsPersisted?: number }

DMARC ​

GET /api/dmarc ​

ts
→ {
  domains: Array<{
    domain: string
    passCount: number
    failCount: number
    totalMessages: number
    reportCount: number
    latestRangeEnd: string | null
    alignmentRate: number | null
    currentPolicy: 'none' | 'quarantine' | 'reject' | null
    currentPct: number | null
    progression: { policy, pct, reason, current: { policy, pct } } | null
    series: Array<{ day: string; alignmentRate: number | null }>   // last 14 days
  }>
  sources: Array<{
    sourceIp: string
    domain: string
    totalMessages: number
    daysSeen: number
    lastSeen: string
    dkimResult: string
    spfResult: string
    dispositionApplied: string
    label: string | null
    ignored: boolean
    tagSource: 'config' | 'db' | null
  }>
  recentReports: DmarcReportDoc[]   // up to 30
  retentionDays: number
}

POST /api/dmarc/upload ​

Upload one .zip / .gz / .xml aggregate report as multipart/form-data with a file field. Audit-logged.

ts
→ {
  ok: true
  reportId: string
  domain: string
  rangeStart: string
  rangeEnd: string
  totalMessages: number
  passCount: number
  failCount: number
  duplicate: boolean
}

Returns 400 for no_file / ingest_failed (with the parse error in message).

PUT /api/dmarc/sources/:ip ​

Tag a DMARC source IP as known.

ts
body: { label: string; ignored?: boolean }
→ { ok: true }

DELETE /api/dmarc/sources/:ip ​

Remove a DB-set tag (config-set tags can't be deleted via the API — edit MailerConfig.dmarc.knownSources).

ts
→ { ok: true; deleted: number }

List hygiene ​

GET /api/hygiene ​

ts
→ {
  totalSubscribers: number
  totalWithSends: number
  buckets: Array<{
    label: string                       // "Engaged (last 30 days)", etc
    minDays?: number | null
    maxDays?: number | null
    neverEngaged?: boolean
    count: number
    pctOfTotal: number
  }>
  sunsetCandidate: {
    count: number
    pctOfTotal: number
    cohortLifetime: {
      totalSends: number
      bouncedRate: number
      hardBouncedRate: number
      complaintRate: number
    } | null
    projectedImpact: {
      recentTotalSends: number
      recentBounced: number
      recentComplained: number
      cohortRecentSends: number
      cohortRecentBounced: number
      cohortRecentComplained: number
      currentOverallBounceRate: number
      projectedBounceRate: number
      currentOverallComplaintRate: number
      projectedComplaintRate: number
    } | null
  }
  computedAt: string
}

Error shape ​

All errors return:

ts
{ error: string, message?: string }

Common error codes:

CodeStatusMeaning
not_found404Resource doesn't exist
bad_id400Invalid ObjectId / slug format
validation_failed400Request body failed Zod parse
forbidden403Permission denied (when resolvePermissions is set)

Pagination ​

Endpoints that paginate use opaque cursor strings. Pass ?cursor=<nextCursor> to fetch the next page. nextCursor is omitted on the last page.

Limits are bounded per endpoint (100-500 typical max).

Versioning ​

The admin API is internal — coupled to the SPA shipped in the same mailery package version. Endpoints may change between mailery releases without major version bumps. Don't build external integrations against /admin/mailer/api/* — use the Mailer class methods instead.

Released under the MIT License.