Developer guideSending
Sending email
POST /v1/emails in detail. Fields, recipients as a string or a list, idempotency, batch sending, scheduling, tags, metadata, attachments and the pre-send check.
POST /v1/emails sends an email (send scope). The 202 Accepted response has the id and the status, and it only comes back after the email is in the queue. If something prevents the send, you get an error, never a fake success.
{ "id": "msg_9w2k7c1x0d4e5f6g", "status": "queued" }With a ck_test_ key the status is captured and nothing is delivered (see Test mode).
Fields#
| Field | Type | Required | Description |
|---|---|---|---|
from |
string | yes | sender on one of the project's verified domains: "hi@example.com" or "Shop <orders@example.com>". A subdomain of a verified domain works too |
to |
string or string[] | yes | one address ("ana@example.com") or a list |
cc, bcc |
string or string[] | no | carbon copy and blind carbon copy, also a string or a list |
reply_to |
string or string[] | no | where replies go |
subject |
string | yes | subject, 1 to 998 characters |
html |
string | one of the two | HTML body (up to 5 MB) |
text |
string | one of the two | plain text body (up to 5 MB) |
attachments |
object[] | no | up to 20 attachments (see below) |
headers |
object | no | extra headers, such as { "X-Order": "1042" } |
tags |
object | no | string key and value pairs to filter and group by |
metadata |
object | no | your own string data, returned in webhooks |
scheduled_at |
ISO 8601 string | no | send later, up to 30 days ahead |
stream |
transactional or marketing |
no | stream; defaults to transactional |
template + variables |
string + object | no | saved templates: coming soon (today the API answers invalid_field; send html or text) |
- At most 50 recipients per email, across
to,ccandbcc. For more, use a batch. - The request body can be up to 30 MB, counting Base64 attachments.
- Always send
textalong withhtml. Providers trust emails with both parts more, and plain-text readers will thank you.
{
"from": "Example Shop <orders@example.com>",
"to": "ana@example.com",
"cc": ["finance@example.com", "shipping@example.com"],
"reply_to": "support@example.com",
"subject": "Order 1042 confirmed",
"html": "<p>Your order 1042 is confirmed.</p>",
"text": "Your order 1042 is confirmed.",
"tags": { "type": "order" },
"metadata": { "order_id": "1042" }
}Idempotency#
Networks drop, functions restart, queues retry. To avoid sending the same email twice, send the Idempotency-Key header (up to 255 characters) with a value unique to the business operation:
Idempotency-Key: order-1042-confirmation- For 24 hours, repeating the call with the same key and the same body returns the same response, with the
Idempotent-Replayed: trueheader, without sending again. - The same key with a different body returns
409 idempotency_conflict. - If the first request is still running, the retry gets
409 idempotency_in_progresswithRetry-After: 1. 5xx,409and429responses are not stored: you can retry with the same key.- It works on every
POST, including batches.
Use something tied to the event, such as order-{id}-confirmation or password-{user}-{timestamp}. A fresh UUID on every attempt does not protect against retries.
Suppressed recipients#
If some recipients are on the suppression list, the email goes to the others and the timeline gets a suppressed event for each skipped address. If every recipient is suppressed, the response is 422 recipient_suppressed, with param pointing to the first one (to[0]).
Batch sending#
POST /v1/emails/batch takes up to 100 emails per call, in the emails field. Each item has the same fields as POST /v1/emails and is validated on its own: one bad item does not take down the others.
curl https://api.couryo.com/v1/emails/batch \
-H "Authorization: Bearer $COURYO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reminders-2026-10-07" \
-d '{
"emails": [
{ "from": "hi@example.com", "to": "ana@example.com", "subject": "Reminder", "text": "Your class starts at 7 pm." },
{ "from": "hi@example.com", "to": ["bruno@example.com"], "subject": "Reminder", "text": "Your class starts at 7 pm." }
]
}'The response is 200 with one result per item, in the same order. Error params include the item index:
{
"data": [
{ "id": "msg_4h8s1m2p0q7r6t5u", "status": "queued" },
{
"error": {
"type": "invalid_request",
"code": "recipient_suppressed",
"message": "The address bruno@example.com is on the suppression list.",
"param": "emails[1].to[0]",
"doc_url": "https://couryo.com/docs/errors#recipient_suppressed",
"request_id": "req_2m9x4k7c1v0b8n3q"
}
}
]
}- More than 100 items:
400 batch_too_large, and nothing is sent. - A limit error (
daily_limit_reached,monthly_limit_reached,spend_limit_reachedorsending_paused) stops the rest of the batch: the item that hit the limit and every item after it come back with the same error.
Scheduling#
Pass scheduled_at as ISO 8601 with a time zone (Z or -03:00), up to 30 days ahead. The email stays queued until then. A time in the past (by more than 1 minute) or beyond 30 days returns invalid_field.
{
"from": "hi@example.com",
"to": "ana@example.com",
"subject": "Your class starts in 1 hour",
"text": "See you soon!",
"scheduled_at": "2026-10-08T21:00:00Z"
}Tags and metadata#
tagslet you filter the list (GET /v1/emails?tag=type:order) and group in the dashboard:{ "type": "order", "campaign": "october" }. Keys use letters, numbers,_and-(up to 64); values up to 256 characters.metadatais yours: it comes back in every webhook, handy to tie the email to a record in your system:{ "order_id": "1042" }. Keys up to 64 characters; values up to 1,024.
Both accept string values only.
Attachments#
Each attachment has filename (up to 255 characters), content (the file in Base64) and, optionally, content_type. Up to 20 per email.
{ "filename": "receipt.pdf", "content": "JVBERi0xLjcK...", "content_type": "application/pdf" }Bills and invoices: send a link, not an attachment. Bill attachments are a classic scam pattern, and spam filters know it. The pre-send check warns about it (
billing_attachment).
Headers#
Headers in headers go out as you sent them, except the ones that belong to the platform or break authentication, which are ignored: From, To, Cc, Bcc, Subject, Message-ID, Date, Return-Path, DKIM-Signature, List-Unsubscribe, List-Unsubscribe-Post, Content-Type, Content-Transfer-Encoding and MIME-Version.
Transactional and marketing#
The stream field separates the streams. A marketing unsubscribe never blocks transactional email (password, login, billing). On marketing, Couryo adds the one-click unsubscribe (List-Unsubscribe and List-Unsubscribe-Post, RFC 8058) that Gmail and Yahoo require, and whoever clicks it is suppressed on the marketing stream. Opens and clicks are tracked on the marketing stream only.
Pre-send check#
POST /v1/emails/check (send scope) takes the same body as a send and returns a score from 0 to 100 and a list of issues, without sending anything. Good for CI.
{ "score": 90, "issues": [{ "code": "missing_text_part", "severity": "warning", "message": "The email has no text part." }] }code |
Severity | What it flags |
|---|---|---|
domain_not_verified |
error | the from domain is not verified: the send would be refused |
link_shortener |
error | a shortened link; use the full link on your own domain |
missing_text_part |
warning | html without text |
insecure_link |
warning | links without HTTPS |
html_too_large |
warning | HTML over 100 KB (Gmail clips the message) |
subject_all_caps |
warning | an all-caps subject |
billing_attachment |
warning | a bill, invoice or tax receipt as an attachment |
image_without_alt |
info | an image without alternative text |
List and fetch#
| Method and path | Scope | What it does |
|---|---|---|
GET /v1/emails |
read |
newest first; filters status, recipient (part of the address), tag (key:value or a free term) and period (24h, 7d, 30d), with limit and starting_after |
GET /v1/emails/{id} |
read |
one email with its timeline |