Send a transactional email
Admits a transactional email for delivery. Never calls the provider before answering: every address is claimed or suppressed on the spot.
Every call needs a bearer token from an active API key. See Authentication. Create or manage keys from the API keys panel.
Path parameters
None.
Query parameters
None.
Headers
AuthorizationstringRequiredBearer ek_live_<key>.
Request attributes
idempotencyKeystringRequiredThe same value with the same body answers what was already resolved, without resending; with a different body it answers conflict.
tostring[]OptionalUp to 50 email addresses. At least one address has to appear across to, cc and bcc.
ccstring[]OptionalUp to 50 email addresses, the same rule as to.
bccstring[]OptionalUp to 50 email addresses, the same rule as to.
fromstringRequiredAn email address of a sender already registered in this organization — its own name is what leaves in the From header, never a per-call override.
replyTostringOptionalAn email address. Supplied, it wins; left out, it is filled with the sender's own real address whenever the send leaves substituted, and stays empty otherwise.
contentobjectRequiredThe message body. Either sent raw or compiled from a saved template, and type is what tells the two apart — never both at once.
Show 2 variantsHide 2 variants
type: "raw"subjectstringRequiredThe subject line. It cannot be empty.
htmlstringRequiredThe HTML body, already composed. Nothing is substituted into it.
textstringRequiredThe plain-text alternative, for a client that renders no HTML.
type: "template"templateIdstringRequiredThe id of an active template this organization owns. Copy it from the template's header or its row menu — see Send with a template, further down this page.
variablesmap from strings to stringsRequiredYour own catalog for this call — there is no list behind a transactional send, so the keys you put here are the only ones the template has to resolve its merge tags against.
Response attributes
resultsobject[]RequiredOne entry per addressee — to, cc and bcc alike.
Show 3 propertiesHide 3 properties
addressstringRequiredThat addressee's own address.
messageIdstringRequiredThe id GET /v1/email/:messageId also accepts.
statusstringRequiredclaimed or suppressed only: the shared enum publishes five values, but the provider has not been called yet when this response is built.
Send with a template
Instead of { type: "raw", subject, html, text }, content can carry a template already saved
in Templates, with type: "template":
curl -X POST https://api.e-comms.dev/v1/email \
-H "Authorization: Bearer ek_live_<your-key>" \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey": "welcome-template-ana-2026-09-19",
"to": ["ana@example.com"],
"from": "hello@yourcompany.com",
"content": {
"type": "template",
"templateId": "01a0ba31-6b2c-7a9f-8c3d-2e5f7a1b9c60",
"variables": { "firstName": "Ana", "planName": "Pro" }
}
}'const response = await fetch("https://api.e-comms.dev/v1/email", {
method: "POST",
headers: {
Authorization: "Bearer ek_live_<your-key>",
"Content-Type": "application/json",
},
body: JSON.stringify({
idempotencyKey: "welcome-template-ana-2026-09-19",
to: ["ana@example.com"],
from: "hello@yourcompany.com",
content: {
type: "template",
templateId: "01a0ba31-6b2c-7a9f-8c3d-2e5f7a1b9c60",
variables: { firstName: "Ana", planName: "Pro" },
},
}),
})
const body = await response.json()import requests
response = requests.post(
"https://api.e-comms.dev/v1/email",
headers={"Authorization": "Bearer ek_live_<your-key>"},
json={
"idempotencyKey": "welcome-template-ana-2026-09-19",
"to": ["ana@example.com"],
"from": "hello@yourcompany.com",
"content": {
"type": "template",
"templateId": "01a0ba31-6b2c-7a9f-8c3d-2e5f7a1b9c60",
"variables": {"firstName": "Ana", "planName": "Pro"},
},
},
)
body = response.json()The response has the same shape as any other send’s.
Where the templateId comes from
Copy it from the template itself. Open the template in Templates → edit and use the copy button beside the id under its name, or open the actions menu of its row in the templates list and choose Copy ID. Any role that can see templates can copy it.
Only an active template can be used to send. An inactive one answers conflict — the same
code a repeated idempotencyKey with a different body answers.
What a missing variable answers
If the template’s document uses a merge tag that is not among variables’s own keys, the call
answers bad_request, naming the tag in details:
{
"code": "bad_request",
"message": "Unknown merge tag key: planName",
"details": {
"keys": ["planName"]
},
"requestId": "01a0ba31-3fe4-7cc4-a1c6-64bdaaa5d99c"
}There is no partial substitution and no silent default: the template compiles whole, or nothing is sent at all.
Notes
fromhas to match a sender exactly, not just a domain: register one in the app’s own Sending settings before calling this endpoint. A sender proved with its own one-time code sends as written; one whose domain is not yet authenticated there still sends, with the local part kept and the domain substituted,replyTocarrying the real address unless the call already supplied one.- Rate limit: 100 requests per second per key, across all of
/v1. Over that it answers429 rate_limited, with noRetry-Afterheader and no quota header at all — the limit is never announced ahead of time. /v1answers no CORS header at all. This surface is not callable from a browser, only server to server.- The body is strict: a field outside this list answers
validation_failed, never silently ignored. idempotencyKeyis a body attribute, not a header. The same key with the same body returns exactly the response already resolved, without re-queuing the send. The same key with a different body answers409 conflict. Two concurrent requests with the same key: the one that loses the race also answers409, never a duplicate send.
The error reference carries the rest of the codes this surface can answer with, including the ones no single route declares on its own.
Errors
| Status | Code | Cause |
|---|---|---|
| 403 | forbidden | The key was not issued messages:send. |
| 404 | not_found | templateId names no template this organization owns, or from names no sender of this organization. |
| 409 | conflict | The idempotencyKey was already used with a different body, the referenced template is not active, or the sender named by from has not proved that address yet. |
| 400 | bad_request | No address across to, cc and bcc, or the template's document uses a merge tag that is not among variables's own keys. |
| 402 | payment_required | The organization is over its plan's sending limit. |