Send a transactional email

Goal: prepare a transactional template, send an email immediately or at a scheduled time, cancel a scheduled send, send a batch to many recipients in one call, and track what happened to each message.
Prerequisites: a verified sending domain (see Verify a sending domain); credits on your account (see Billing & credits); an API key with the templates:write, email:send and messages:read scopes; Messages and Templates for the models used below.

Steps

1. Prepare an active transactional template (once)

A transactional send only accepts a template whose message_class is transactional and whose status is active. Create it:

curl -X POST https://emails.routeon.io/api/v1/templates \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Order receipt",
    "format": "html",
    "source": "<html><body><p>Thanks for your order {{order_id}}.</p><p>Total: {{total}}</p></body></html>",
    "subject": "Your receipt for order {{order_id}}",
    "message_class": "transactional"
  }'

Expected response (201 Created):

{
  "status": "success",
  "http_code": 201,
  "payload": {
    "id": "<TEMPLATE_ID>",
    "tenant_id": "<TENANT_ID>",
    "name": "Order receipt",
    "current_version": 1,
    "status": "draft",
    "strict_variables": false,
    "message_class": "transactional",
    "created_at": "2026-09-28T09:00:00Z",
    "updated_at": "2026-09-28T09:00:00Z"
  },
  "meta": {}
}

Verify the version’s content:

curl -X POST https://emails.routeon.io/api/v1/templates/<TEMPLATE_ID>/verify \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{ "version": 1 }'

The call answers with a 2xx status and a payload like { "status": "approved", "reason": null }. pending means there is no verdict yet — call again shortly; rejected lists the flagged categories in reason, and the content must be changed. Then activate the template:

curl -X PATCH https://emails.routeon.io/api/v1/templates/<TEMPLATE_ID> \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{ "status": "active" }'

200 OK returns the template with status: "active". Activation is refused with 422 (template_not_verified) until the current version has an approved verdict. Any later content edit creates a new version and returns the template to draft; sending with it stops until you verify and activate again.

2. Send an email now

curl -X POST https://emails.routeon.io/api/v1/transactional/send \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-48213-receipt" \
  -d '{
    "domain_id": "<DOMAIN_ID>",
    "to": "customer@example.com",
    "template_id": "<TEMPLATE_ID>",
    "variables": { "order_id": "48213", "total": "42.00 EUR" },
    "tags": ["receipt", "shop:eu"],
    "metadata": { "order_id": "48213" }
  }'

Expected response (202 Accepted):

{
  "status": "success",
  "http_code": 202,
  "payload": {
    "message_id": "<MESSAGE_ID>",
    "send_at": null
  },
  "meta": {}
}

send_at: null confirms an immediate send.

  • Idempotency-Key — always send one: a non-empty string of up to 255 characters. Repeating the same key with the same request within 24 hours returns the original result instead of sending a duplicate.
  • variables — the values your template’s placeholders reference ({{order_id}} above). Values are checked against the template’s declared variables; a value that does not fit is refused with 422 before anything is charged.
  • tags — up to 10; each is trimmed and lower-cased and must then match ^[a-z0-9_.:-]{1,64}$. Filter by them later with GET /messages?tag=.
  • metadata — string key/value pairs echoed back verbatim on the message and in webhooks; at most 4096 bytes as JSON.

Every send also runs a content check on what it sends — the template’s active version or ready-made content: a rejected verdict is 422 content_moderation_rejected; a verdict that is not available yet is 409 content_moderation_pending. Its meta.details.reason says what to do: in_progress (the check is still running) carries meta.details.retry_after_seconds and a Retry-After header — retry with backoff starting at 5 seconds; manual_review means a person is reviewing the content, and retrying the same content keeps returning this answer until the review ends.

Ready-made content instead of a template. A send can carry subject with html and/or text in place of template_id — one or the other, never both. Placeholders in it are filled from variables the same way. subject is 1–2000 characters; html plus text is at most 4 MB (larger is 422 VALIDATION_ERROR on the field html), and content within that size but too large for the content check is 422 content_too_large. The variable names unsubscribe_url, this, constructor, prototype and any name starting with @ or __ are reserved (422 invalid_variable).

3. Choose the sender (optional)

Without from, the sender mailbox comes from the sender profile routed to transactional mail, or from your default profile (GET /senders lists them; see Domains & warmup). To override it for one call, pass from — local@domain or Name <local@domain> — and optionally reply_to. The domain of from then selects the sending domain, so domain_id can be left out:

curl -X POST https://emails.routeon.io/api/v1/transactional/send \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-48213-receipt-billing" \
  -d '{
    "from": "Example Shop <billing@mail.example.com>",
    "reply_to": "support@example.com",
    "to": "customer@example.com",
    "template_id": "<TEMPLATE_ID>",
    "variables": { "order_id": "48213", "total": "42.00 EUR" }
  }'

The domain of from must be one of your verified domains (422 from_domain_mismatch), the string must be well formed (422 invalid_from_address), and the part before @ must match one of your sender profiles (422 sender_not_configured); that profile supplies the display name and Reply-To unless from and reply_to override them. With neither domain_id nor from the request is 422 VALIDATION_ERROR with a meta.validation_error entry for the field domain_id.

4. Schedule a send for later

Add send_at with an explicit UTC offset, at most 30 days ahead:

curl -X POST https://emails.routeon.io/api/v1/transactional/send \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-48213-followup" \
  -d '{
    "domain_id": "<DOMAIN_ID>",
    "to": "customer@example.com",
    "template_id": "<TEMPLATE_ID>",
    "variables": { "order_id": "48213", "total": "42.00 EUR" },
    "send_at": "<FUTURE_ISO8601_TIMESTAMP>"
  }'

The response’s payload.send_at echoes the recorded time and the message stays queued until then. A past instant, null, or no send_at sends immediately. Further than 30 days ahead is refused with send_at_too_far, an offset that cannot be represented with send_at_invalid; both arrive as 422 in meta.validation_error[0].message. A scheduled message counts against the daily transactional limit of the UTC day it goes out on.

5. Cancel a scheduled send

Only a message that is still queued with a future send_at can be canceled:

curl -X DELETE https://emails.routeon.io/api/v1/transactional/<MESSAGE_ID> \
  -H "Authorization: Bearer <API_KEY>"

Expected response (200 OK):

{
  "status": "success",
  "http_code": 200,
  "payload": { "message_id": "<MESSAGE_ID>", "status": "canceled" },
  "meta": {}
}

Canceling refunds the message’s credit and frees its slot in the daily limit. Repeating the call on an already canceled message also answers 200, so a timed-out cancel is safe to retry. There is no reschedule endpoint: cancel, then send again with a new Idempotency-Key (the old key would replay the canceled send).

6. Send a batch

Send 1–100 emails with one template in a single call, one item per recipient:

curl -X POST https://emails.routeon.io/api/v1/transactional/send-batch \
  -H "Authorization: Bearer <API_KEY>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: shipping-notices-batch-0001" \
  -d '{
    "domain_id": "<DOMAIN_ID>",
    "template_id": "<TEMPLATE_ID>",
    "tags": ["shipping"],
    "metadata": { "run": "0001" },
    "items": [
      { "to": "alice@example.com", "variables": { "order_id": "48214", "total": "10.00 EUR" } },
      { "to": "bob@example.com", "variables": { "order_id": "48215", "total": "12.50 EUR" } }
    ]
  }'

Expected response (202 Accepted):

{
  "status": "success",
  "http_code": 202,
  "payload": {
    "results": [
      { "index": 0, "status": "queued", "message_id": "<MESSAGE_ID_1>" },
      { "index": 1, "status": "failed", "code": "recipient_suppressed" }
    ]
  },
  "meta": {}
}
  • Checks that concern the whole batch — sending domain and sender, template or ready-made content, content check, credits — fail the whole request with the same errors as a single send. A batch can carry ready-made content instead of template_id, as in step 2; the same content goes to every item, and its size times the number of items is at most 50 MB (field items).
  • Once those pass, the call answers 202 even if every item fails. Match each entry of results to your input by index (the same address may appear twice). A failed item carries code: recipient_suppressed, invalid_variable (with an errors list), content_moderation_rejected (ready-made content whose rendering with this item’s variables failed the content check), transactional_daily_limit_exceeded, new_account_daily_limit_exceeded, or send_enqueue_failed.
  • tags and metadata apply to every message of the batch. A batch has no send_at; all items go out immediately.
  • Replaying the same key with the same items returns the stored result unchanged, including its failures. Retry failed recipients in a new batch under a new Idempotency-Key.

7. Track the result

curl https://emails.routeon.io/api/v1/messages/<MESSAGE_ID> \
  -H "Authorization: Bearer <API_KEY>"

Expected response (200 OK):

{
  "status": "success",
  "http_code": 200,
  "payload": {
    "id": "<MESSAGE_ID>",
    "campaign_id": null,
    "to_email": "customer@example.com",
    "status": "delivered",
    "provider_message_id": "<PROVIDER_MESSAGE_ID>",
    "error_message": null,
    "send_at": null,
    "template_id": "<TEMPLATE_ID>",
    "source_type": "transactional",
    "journey_step_id": null,
    "tags": ["receipt", "shop:eu"],
    "metadata": { "order_id": "48213" },
    "created_at": "2026-09-28T09:00:01Z",
    "events": [
      { "type": "delivery", "at": "2026-09-28T09:00:04Z" }
    ]
  },
  "meta": {}
}

A message moves queued → sending → sent → delivered; deferred means a temporary hold with automatic retries. It ends as delivered, bounced, complained, failed, suppressed (the address was on your suppression list when the message was about to go out, so nothing was sent) or canceled. delivered is not necessarily the last word: a late bounce or a spam complaint can still turn it into bounced or complained. Event types are spelled differently from statuses — delivery, not delivered. For push notifications instead of polling, see Receive webhooks.

Verify

GET /messages/{id} reaches a final status as described above. To review recent sends, use GET /messages?transactional=true, or narrow with ?tag=receipt, ?email=customer@example.com or ?status=failed.

Common failures

Symptom Cause Fix
422 template_not_active The template does not exist, is not active, or was edited and returned to draft Verify and activate it (step 1)
422 template_class_mismatch The template’s message_class is not transactional Use a transactional template
422 naming the offending variables A variables value does not fit the template’s declared variable (invalid_variable in a batch) Fix the value; meta.validation_error names each field
422 content_moderation_rejected The template or ready-made content failed the content check Change the content; for a template, then verify and activate the new version
409 content_moderation_pending No content verdict is available yet For meta.details.reason in_progress, retry after Retry-After with backoff; for manual_review, wait for the review to end
422 content_too_large Ready-made content is too large for the content check Shorten the content
413 Request body too large: over 5 MB, or rejected earlier by the edge proxy with a non-JSON response Send smaller content or fewer items
403 domain_not_verified domain_id is not a verified domain of your account Finish Verify a sending domain
422 VALIDATION_ERROR on the field domain_id / from_domain_mismatch / invalid_from_address No domain_id and no from; from on an unverified domain; malformed from Pass a verified domain_id, or a well-formed from on a verified domain
422 sender_not_configured The part before @ in from matches none of your sender profiles, or, without from, no sender profile resolves for transactional mail Create the sender profile or a default one (POST /senders), or pass a from that matches a profile
403 recipient_suppressed The address unsubscribed, bounced, complained, or was suppressed manually Remove the suppression only if you have a new lawful basis to email it
402 INSUFFICIENT_CREDITS / CREDIT_PROTECTION Balance empty, or the send would cross your protected minimum meta.details shows needed, balance, available; top up
429 transactional_daily_limit_exceeded Your daily transactional limit for that UTC day is used up Wait for the next UTC day, or contact support to raise it
429 new_account_daily_limit_exceeded The reduced daily cap of a new account is used up (meta.details.limit) Wait for the next UTC day
422 send_at_too_far / send_at_invalid send_at more than 30 days ahead, or an impossible offset Choose a time within 30 days with a valid offset
409 message_not_scheduled on cancel The message is not queued with a future send_at — usually it is already going out It can no longer be stopped
409 idempotency_key_conflict The same key was reused with a different request Use a new key for a different request
409 idempotency_in_progress A request with the same key is still running Retry shortly; the key then replays the result
429 RATE_LIMITED Request rate limit exceeded Wait for Retry-After seconds