Send a batch of transactional emails

POST https://emails.routeon.io/api/v1/transactional/send-batch

Send 1 to 100 transactional emails in one call, one item per recipient. Once the batch has passed the batch-level gates (sending domain, template, content moderation and credits — the 402, 403, 409 and 422 responses below), it always answers 202, even when every item fails: a per-item outcome (including a daily-limit overrun) is never reflected in the response status, only in that item's entry in results. Each results entry is matched back to its input item by index, not by to: the same recipient address may legally appear more than once in a batch, so to cannot be used for correlation. Replaying the request with the same Idempotency-Key and the same items in the same order returns the stored response unchanged; the same key reused with different items — or with the same items reordered, since results is indexed by position — is 409 idempotency_key_conflict. That replay is exact, which matters when some items failed: retrying the same key returns the same per-item failures for the full 24 h replay window, it does not re-attempt them. Recipients whose item came back failed must therefore be retried under a NEW Idempotency-Key — a batch containing only those recipients — because under the original key they can never be sent. The template is always content-verified once for the whole batch: a rejected template is 422 content_moderation_rejected, and a verdict that is not ready yet, or a moderation service that is busy or unreachable, is 409 content_moderation_pending and the request may be retried.

Parameters

Idempotency-Key string header

Replays with the same key return the original result. **Key shape.** A non-empty string of at most 255 characters after trimming surrounding whitespace. An empty or oversized key is rejected with 422 `VALIDATION_ERROR` (`idempotency_key_empty` / `idempotency_key_too_long`) rather than silently running the request unprotected. Leading/trailing whitespace is trimmed, so `" k "` and `"k"` are the same key. **Detecting a replay.** A response served from the idempotency cache rather than by re-executing the operation carries the response header `Idempotency-Replayed: true`; a freshly executed response omits the header entirely. **Replay window.** A record is retained for 24 h by default, after which the same key is free again and the operation re-executes. Only a 2xx outcome is recorded — a request that failed can be retried with the same key. **Conflicts.** Reusing a key with a different request is `409 idempotency_key_conflict`. The request identity is the method, path, query string and body, except on `POST /transactional/send`, where only `domain_id` + `to` (case-insensitive) + `template_id` + `variables` + `send_at` are compared (the parsed instant normalised to ISO-8601, and omitted entirely rather than sent as `null` when the request carried no `send_at`); a replay of that operation does **not** carry `Idempotency-Replayed`. **Concurrency.** A key is claimed before the operation executes, so two requests carrying the same key can never both run. The second one is rejected with `409 idempotency_in_progress` while the first is still in flight. This is a *transient* conflict — distinct from `idempotency_key_conflict` above, which is permanent — so retry after a short delay: once the first request completes, the same key replays its result. A claim is released as soon as the first request fails or returns a non-2xx, and expires after 5 min if the process handling it dies, so a key can never be locked indefinitely. **Rate limiting.** A replay short-circuits before the rate limiter, so it consumes no quota and returns no `X-RateLimit-*` headers. This is deliberate: a client retrying after a timeout must not be penalised for work that was already done. **Scope.** Keys are scoped per tenant.

Request body

{
    "domain_id": "00000000-0000-0000-0000-000000000000",
    "template_id": "00000000-0000-0000-0000-000000000000",
    "from": "string",
    "reply_to": "user@example.com",
    "items": [
        {
            "to": "user@example.com",
            "variables": []
        }
    ],
    "idempotency_key": "string",
    "tags": [
        "string"
    ],
    "metadata": []
}

Responses

202 Accepted
402 Not enough credits to send, for one of two reasons distinguished by meta.reason: INSUFFICIENT_CREDITS (the tenant's balance is empty) or CREDIT_PROTECTION (the send would cross the tenant's own protected minimum, even though the raw balance would otherwise cover it). meta.details carries code=insufficient_credits or code=credit_protection plus needed, balance and available (integer credits) so the client can state the shortfall.
403 Authenticated but not permitted (scope/suppressed/unverified domain). Where the cause is actionable, meta.details.code carries it — domain_not_verified, recipient_suppressed, contact_not_in_tenant, provider_not_allowed (the domain's mail provider is not in the account's allowlist). Guard-level refusals (missing role or scope) carry no code.
409 Uniqueness/state conflict. Where the cause is actionable, meta.details.code carries it — e.g. invalid_state, concurrent_update, suppression_exists, last_owner_protected. Send-path codes: otp_resend_cooldown (POST /otp/request inside the resend cooldown window of the last still-valid code for the same identifier+purpose), content_moderation_pending (fail-closed block while an AI verdict is unavailable — retryable; POST /transactional/send and /transactional/send-batch also raise it when the moderation check is busy or unreachable), idempotency_key_conflict (same Idempotency-Key replayed with a different request body — permanent, pick a new key), idempotency_in_progress (an earlier request holding the same Idempotency-Key is still executing — transient, retry shortly and the same key will replay its result), otp_reserve_not_applied (the credit reserve for POST /otp/request was declined for a reason other than an empty balance, most often because the ledger already held an entry under the same idempotency key — nothing was charged and nothing was sent; retry), and message_not_scheduled (DELETE /transactional/{message_id} on a message that is not `queued` with a future `send_at` — most commonly, the scheduler already picked it up). Sender profiles add sender_already_exists (POST /senders with a local_part the tenant already uses), sender_in_use (DELETE /senders/{id} while a route or campaign still points at the profile) and default_sender_undeletable (DELETE /senders/{id} on the tenant's default profile — promote another profile with is_default first). POST /domains/{id}/provider adds provider_change_in_progress: another change for this same domain is already committing, most commonly a double-submit — retry once the first one settles.
422 Body/query/params failed validation. meta.reason=VALIDATION_ERROR, meta.validation_error[] carries {field, message} per failure ('' field = show the message as-is). Always 422 — a request that parsed but broke a rule is never 400. Sender resolution reports through meta.details.code: invalid_from_address (the `from` string is not `local@domain` or `Name <local@domain>`), from_domain_mismatch (its domain is not a verified domain of the tenant), domain_required (neither `domain_id` nor `from` was given), sender_not_configured (no route and no default profile resolves for this send) and sender_not_found (the referenced profile id is unknown to the tenant). content_moderation_rejected: POST /transactional/send and /transactional/send-batch verify the template's active version before accepting the send, and a rejected verdict fails the request. The check cannot be switched off.

Example response · 202

{
    "status": "success",
    "http_code": 0,
    "payload": {
        "results": [
            {
                "index": 0,
                "status": "queued",
                "message_id": "00000000-0000-0000-0000-000000000000"
            }
        ]
    },
    "meta": []
}

Example response · 402

{
    "status": "error",
    "http_code": 0,
    "payload": [],
    "meta": {
        "reason": "VALIDATION_ERROR",
        "validation_error": [
            {
                "field": "string",
                "message": "string"
            }
        ],
        "details": []
    }
}

Example response · 403

{
    "status": "error",
    "http_code": 0,
    "payload": [],
    "meta": {
        "reason": "VALIDATION_ERROR",
        "validation_error": [
            {
                "field": "string",
                "message": "string"
            }
        ],
        "details": []
    }
}

Example response · 409

{
    "status": "error",
    "http_code": 0,
    "payload": [],
    "meta": {
        "reason": "VALIDATION_ERROR",
        "validation_error": [
            {
                "field": "string",
                "message": "string"
            }
        ],
        "details": []
    }
}

Example response · 422

{
    "status": "error",
    "http_code": 0,
    "payload": [],
    "meta": {
        "reason": "VALIDATION_ERROR",
        "validation_error": [
            {
                "field": "string",
                "message": "string"
            }
        ],
        "details": []
    }
}