Transactional sends

Send receipts, password resets and other messages people asked for: inline, from a saved definition, or raw MIME.

Embed
Preview of the card
<a href="https://hermeticor.com/guides/transactional"><img src="https://hermeticor.com/guides/_api/public/orgs/5fcd8b3c-a27c-4688-972f-de710125592f/spaces/9a3da731-ded5-4536-b84f-8c9a61420ff4/pages/f6328531-57f9-4a9e-9211-a9a5c59f5da2/social-card.svg?v=3e7ae33eec8b" alt="Transactional sends" width="600" height="315"></a>

Transactional messages are ones a person asked for. They do not need marketing consent and ignore quiet hours. They still stop at hard bounces, complaints, manual and do-not-contact suppressions, and blocked domains. Never send marketing this way.

Transactional is its own stream, kept apart from marketing the way Postmark and Amazon SES keep their message streams apart. Because it still reaches people who unsubscribed from marketing, it refuses marketing content with a 400:

  • a template in a preference topic, or a template a campaign uses (make a separate transactional template);

  • a topicId on POST /v1/messages/send with purpose transactional or operational;

  • copy that prints the marketing unsubscribe link, {{ unsubscribe_url }} (or *|UNSUB|*). {{ preferences_url }} is allowed.

Send those with purpose marketing through POST /v1/messages/send, where consent and unsubscribes apply.

What you need first

1. Send one

curl -s -X POST "$BASE/v1/transactional/send" \
  -H "Authorization: Bearer $HERMETICA_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-usr_ada" \
  -d '{"to":[{"email":"ada@example.com","name":"Ada","vars":{"plan":"Growth"}}],
       "subject":"Welcome, {{ vars.recipient.name }}",
       "html":"<p>Hi {{ vars.recipient.name }}, your {{ vars.plan }} workspace is ready.</p>",
       "from":{"email":"hello@example.com","name":"Acme"},
       "tags":["welcome"],"metadata":{"source":"signup"}}'
# 202 {"results":[{"status":"queued","jobId":"..."}]}

The Idempotency-Key is required. See Idempotency.

Variables

  • vars.<key>: from the call's vars and each recipient's vars.

  • vars.recipient.email, vars.recipient.phone, vars.recipient.name.

  • profile.*: the stored person (firstName, lastName, emailPrimary, ... or a custom attribute).

  • unsubscribe_url is not available here (see above).

Bare names such as {{ name }} are not defined; write {{ vars.recipient.name }}. Before anything is queued, every recipient's content is checked against its vars: a missing vars.* value (or a bare name) comes back for that recipient as "status":"invalid" with the tag in reason, and the others still go out. A missing profile.* attribute depends on the stored person, so that message is blocked later with render_error (add | default: "..."); check with GET /v1/messages/<jobId>.

Options

  • cc and bcc arrays. Suppressed copies are dropped.

  • "scheduledFor":"2026-11-20T15:00:00Z" schedules it. Move it with POST /v1/messages/<id>/reschedule, stop it with POST /v1/messages/<id>/cancel.

2. Use a saved definition

Keep the content in a template and send by name:

curl -s -X POST "$BASE/v1/templates" \
  -H "Authorization: Bearer $HERMETICA_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"Password reset","kind":"html",
       "version":{"subject":"Reset your password","body":{"kind":"html","html":"<a href=\"{{ vars.reset_url }}\">Reset</a>"}}}'

curl -s -X POST "$BASE/v1/transactional/definitions" \
  -H "Authorization: Bearer $HERMETICA_API_KEY" -H "Content-Type: application/json" \
  -d '{"name":"password-reset","templateId":"<templateId>",
       "settings":{"fromEmail":"hello@example.com","fromName":"Acme","tags":["auth"],
                   "tracking":{"opens":false,"clicks":false},"route":{"connectionId":"<connectionId>"}}}'

curl -s -X POST "$BASE/v1/transactional/definitions/<definitionId>/send" \
  -H "Authorization: Bearer $HERMETICA_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: reset-req-8812" \
  -d '{"to":[{"email":"ada@example.com","vars":{"reset_url":"https://example.com/reset/abc"}}]}'

Turn tracking off for security messages. GET /v1/transactional/definitions/<id>/deliveries lists what went out.

3. Send raw MIME

curl -s -X POST "$BASE/v1/transactional/send-raw" \
  -H "Authorization: Bearer $HERMETICA_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: raw-1" \
  -d '{"rawMime":"From: Acme <hello@example.com>\r\nTo: ada@example.com\r\nSubject: Hello\r\n\r\nHi Ada"}'

From must be a verified sender.

4. Follow up

  • GET /v1/messages/<jobId>: status, attempts, events and the full decision log.

  • GET /v1/messages/<jobId>/content: the stored body, kept 30 days.

  • POST /v1/messages/<jobId>/resend: send again (checks consent again).

  • GET /v1/transactional/stats and /summary: totals.

For delivery updates as they happen, subscribe to message.status webhooks.

Try it without sending

Turn on test mode (PUT /v1/workspace/test-mode {"enabled":true}). Sends are accepted and logged with a test_... provider id and never reach your provider.

Updated