Skip to content
couryo

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.

View as Markdown

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.

Response 202
{ "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, cc and bcc. For more, use a batch.
  • The request body can be up to 30 MB, counting Base64 attachments.
  • Always send text along with html. Providers trust emails with both parts more, and plain-text readers will thank you.
Body with recipients as a string and as a list
{
  "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:

Header
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: true header, 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_progress with Retry-After: 1.
  • 5xx, 409 and 429 responses 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
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:

Response 200
{
  "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_reached or sending_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.

Body
{
  "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#

  • tags let 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.
  • metadata is 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.

Attachment
{ "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.

Response 200
{ "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