Using Catapulte

June 7, 2026 · View on GitHub

This guide shows how to use a running Catapulte instance: submitting emails, reading their state, and subscribing to delivery events. For how to run and configure a server (storage, SMTP senders, queue, observability), see the readme.

Catapulte accepts an email, returns a tracking id immediately, and owns SMTP delivery, routing, retries, and lifecycle events from there.

  • Base URL in the examples below: http://localhost:3000.
  • All request/response bodies are JSON unless noted (attachment uploads may use multipart/form-data).

Authentication

If the server sets CATAPULTE_HTTP_API_KEY, every endpoint except the health probes requires a bearer token:

curl http://localhost:3000/emails \
  -H "Authorization: Bearer $CATAPULTE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

A missing or wrong token returns 401. When the key is unset, the API is unauthenticated (only safe behind a trusted network boundary).

Submitting an email

POST /emails accepts one email and returns its tracking id:

{ "id": "018f4e3c-2d1a-7b3c-8f00-1234567890ab" }

The id is a UUIDv7. The email is queued; delivery happens asynchronously (watch lifecycle events to observe the outcome).

Required fields

FieldTypeNotes
senderstringa valid email address
recipientsarraynon-empty; each { "kind": "to" | "cc" | "bcc", "address": "<email>" }
bodyobjecta body variant (tagged by kind)

Optional fields

FieldTypeNotes
subjectstring
idempotency_keystringretry-safe submission (see Idempotency)
correlation_idstringechoed back on lifecycle events; use it to correlate without a synchronous id
variablesobjecttemplate variables; defaults to {}
attachmentsarraysee Attachments; defaults to []

Body variants

body is a tagged union on kind:

kindFieldsDescription
plaintext and/or html (at least one)a ready-made plain-text and/or HTML body
mjml_inlinesourceraw MJML source, rendered with variables
mjml_namednamea template pre-registered on the server, rendered with variables
mjml_remoteurlMJML fetched over HTTP (supports mj-include), rendered with variables

Examples

Plain text + HTML:

curl -X POST http://localhost:3000/emails \
  -H "Content-Type: application/json" \
  -d '{
    "sender": "noreply@example.com",
    "recipients": [{ "kind": "to", "address": "alice@example.com" }],
    "subject": "Welcome",
    "body": { "kind": "plain", "text": "Hello Alice", "html": "<p>Hello Alice</p>" }
  }'

Inline MJML with variables:

curl -X POST http://localhost:3000/emails \
  -H "Content-Type: application/json" \
  -d '{
    "sender": "noreply@example.com",
    "recipients": [{ "kind": "to", "address": "alice@example.com" }],
    "subject": "Hi {{ name }}",
    "body": { "kind": "mjml_inline", "source": "<mjml><mj-body><mj-text>Hi {{ name }}</mj-text></mj-body></mjml>" },
    "variables": { "name": "Alice" }
  }'

A pre-registered template (mjml_named) or a remote one (mjml_remote with a url) follow the same shape, swapping the body object.

Attachments

Up to 10 attachments per email. Each is either inline base64 or a remote URL (exactly one):

"attachments": [
  { "filename": "invoice.pdf", "content_type": "application/pdf", "inline_base64": "<base64>" },
  { "filename": "logo.png",    "content_type": "image/png",       "url": "https://cdn.example.com/logo.png" }
]

Remote URLs are fetched server-side subject to the operator's allow-list; an unreachable or disallowed URL fails the submission with 400.

Streaming uploads (multipart)

To avoid base64 overhead for large files, submit multipart/form-data with one envelope JSON part (the email without the attachments field) and one attachment part per file:

curl -X POST http://localhost:3000/emails \
  -F 'envelope={"sender":"noreply@example.com","recipients":[{"kind":"to","address":"alice@example.com"}],"subject":"Report","body":{"kind":"plain","text":"see attached"}};type=application/json' \
  -F 'attachment=@./report.pdf;type=application/pdf'

Each attachment part's filename and content type come from its Content-Disposition/Content-Type. The submit routes are exempt from the HTTP request timeout so large uploads over slow links are not truncated.

Idempotency

Pass an idempotency_key to make retries safe. If a submission reuses a key that already exists, Catapulte returns the existing email's id (200) and does not send a second copy.

Submitting a batch

POST /emails/batch accepts up to 100 emails and reports per-email outcomes (partial acceptance — valid emails are accepted even if others are rejected):

curl -X POST http://localhost:3000/emails/batch \
  -H "Content-Type: application/json" \
  -d '{
    "emails": [
      { "sender": "noreply@example.com", "recipients": [{ "kind": "to", "address": "a@example.com" }], "body": { "kind": "plain", "text": "hi" } },
      { "sender": "noreply@example.com", "recipients": [], "body": { "kind": "plain", "text": "hi" } }
    ]
  }'
{
  "results": [
    { "status": "accepted", "id": "018f4e3c-2d1a-7b3c-8f00-1234567890ab" },
    { "status": "rejected", "error": "recipients must not be empty" }
  ]
}

results is positional (aligned to the input emails). A per-email validation error is reported as rejected; an infrastructure failure aborts the whole batch with 500. Batch items use the inline/remote attachment form (no multipart).

Listing emails

GET /emails returns your submitted emails, newest-first, paginated.

Query paramNotes
statusqueued | sent | failed
recipientfilter by recipient address
templatefilter by named MJML template name; only matches emails submitted with kind: mjml_named
idexact email id (UUID)
after_ms, before_mscreated-at bounds, Unix epoch ms
limitdefault 20, max 100
offsetdefault 0
curl "http://localhost:3000/emails?status=failed&limit=50"
{
  "emails": [
    {
      "id": "018f4e3c-2d1a-7b3c-8f00-1234567890ab",
      "idempotency_key": null,
      "subject": "Welcome",
      "sender": "noreply@example.com",
      "recipients": [{ "kind": "to", "address": "alice@example.com" }],
      "created_at_ms": 1700000000000,
      "status": "sent"
    }
  ],
  "limit": 20,
  "offset": 0
}

Lifecycle events

Every email moves through a sequence of events. You can poll them or subscribe to them in real time.

Reading events

  • GET /events — across all emails. Filters: email_id, event_type, sender_name (the upstream SMTP server), error_class, after_ms, before_ms, limit, offset.
  • GET /emails/{id}/events — events for one email (same filters).

error_class is validated against the vocabulary below — an unknown value returns 400. event_type and sender_name are free-form.

curl "http://localhost:3000/emails/018f4e3c-2d1a-7b3c-8f00-1234567890ab/events"
{
  "events": [
    {
      "id": "018f...",
      "email_id": "018f4e3c-2d1a-7b3c-8f00-1234567890ab",
      "event_type": "delivery.succeeded",
      "payload": { "sender_name": "primary", "correlation_id": "order-12345" },
      "sender_name": "primary",
      "error_class": null,
      "created_at_ms": 1700000000050
    }
  ],
  "limit": 20,
  "offset": 0
}

Subscribing (webhook / NATS)

When the operator configures a webhook URL or a NATS subject, Catapulte pushes each event as JSON:

{
  "event_type": "delivery.succeeded",
  "email_id": "018f4e3c-2d1a-7b3c-8f00-aabbccddeeff",
  "payload": { "sender_name": "primary", "correlation_id": "order-12345" }
}
event_typeMeaningpayload fields
queuedaccepted and enqueuedcorrelation_id
sendinga delivery attempt is startingattempt, correlation_id
delivery.succeededaccepted by the upstream SMTP serversender_name, correlation_id
retryingattempt failed, will retryattempt, reason, error_class, sender_name, correlation_id
delivery.failedretries exhaustedattempt, reason, error_class, sender_name, correlation_id

attempt counts from 1; sender_name/correlation_id may be null. error_class is present on retrying / delivery.failed only, and is one of template_resolve, template_interpolate, template_render, attachment, delivery, routing. (The pushed payload has no timestamp; the stored events from GET /events carry created_at_ms.) Webhooks are retried a few times on a non-2xx response.

Submitting over NATS (fire-and-forget)

If the operator enables the NATS inbound transport, publish the same JSON as POST /emails to the configured subject. NATS submission is fire-and-forget: there is no synchronous tracking id. Supply a correlation_id in the payload and observe the outcome via lifecycle events.

Listing senders

GET /senders reports the configured upstream SMTP senders and their usage within the current quota window:

{
  "senders": [
    { "name": "primary", "sent_in_range": 42, "failed_in_range": 3, "quota": { "count": 1000, "range": "daily" } }
  ]
}

quota is null when none is configured; range is hourly | daily | weekly | monthly.

Health

Always public (never require the API key):

  • GET /health/live200 {"status":"ok"} — process liveness.
  • GET /health/ready200 {"status":"ok"} when storage and the queue are reachable, 503 {"status":"unavailable"} otherwise.

Errors

Errors return a minimal JSON body (details are logged server-side, not returned):

{ "error": "invalid request" }
StatusWhen
400malformed JSON/multipart, validation failure (sender/recipients/body/attachment), bad UUID, unreachable/disallowed remote attachment, batch over 100
401missing/invalid bearer token
500storage / queue / attachment-store failure

Limits

LimitValue
Max request body352 MiB
Max envelope JSON (multipart envelope part)1 MiB
Max size per attachment25 MiB
Max attachments per email10
Max emails per batch100
List page sizedefault 20, max 100