Send a batch of transactional emails
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
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
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": []
}
}