# Couryo > Couryo is a transactional and product email API by Wunka, built for developers and AI agents. Send over the REST API (https://api.couryo.com/v1) or the remote MCP server (https://mcp.couryo.com), with SMTP coming soon. Automatic DNS, every bounce explained in plain words, public trust levels and pauses that are always explained. - Authentication: `Authorization: Bearer ck_live_...` (live) or `ck_test_...` (test: never delivers, status `captured`). - Always send the `Idempotency-Key` header on POST: same key and same body within 24 h returns the same response; a different body returns 409. - Sending: `POST /v1/emails` returns 202 with `{ id, status }` only once the email is queued. Batch: `POST /v1/emails/batch`, up to 100. - Errors: `{ error: { type, code, message, param, doc_url, request_id } }`, with a stable `code`. - Webhooks follow Standard Webhooks (HMAC-SHA256, webhook-id, webhook-timestamp and webhook-signature headers). - Domains: DKIM by CNAME on couryo1._domainkey and couryo2._domainkey, return path by CNAME on bounces. to rp.couryo.com, and DMARC; no SPF on the root domain. - Remote MCP at https://mcp.couryo.com with your API key (`Authorization: Bearer ck_...`): send_email, get_email, domain_health and why_bounced. OAuth coming soon. - Every docs page is available as Markdown: add `.md` to the URL. - Pricing: Free: 3,000 emails per month (up to 100 per day) and 1 domain, no card. Pro: US$ 19 per month with 50,000 emails and US$ 0.35 per extra 1,000. Scale: US$ 59 per month with 200,000 emails and US$ 0.28 per extra 1,000. Enterprise: custom. Companies in Brazil pay in reais: Pro R$ 99 (R$ 1,80 per extra 1,000) and Scale R$ 299 (R$ 1,40 per extra 1,000). --- # Developer guide > Everything you need to send transactional email with Couryo, over the REST API or from your AI agent (MCP). Source: https://couryo.com/en/docs Couryo is a transactional and product email API. You send over the **REST API** (`https://api.couryo.com/v1`) or from your AI agent with the [MCP server](https://couryo.com/en/docs/mcp.md) (`https://mcp.couryo.com`), follow every message on a timeline that includes the receiving server's original response, and get events by webhook. [SMTP](https://couryo.com/en/docs/smtp.md) is coming soon. ## Where to start - [Quickstart](https://couryo.com/en/docs/quickstart.md): your first email in 5 minutes. - [Authentication and keys](https://couryo.com/en/docs/authentication.md): `ck_live_` and `ck_test_` keys, scopes and allowed IPs. - [Sending email](https://couryo.com/en/docs/sending.md): idempotency, batch, scheduling, tags and attachments. - [Domains and DNS](https://couryo.com/en/docs/domains.md): what each record does and how to verify. - [Webhooks](https://couryo.com/en/docs/webhooks.md): events signed with Standard Webhooks. - [Errors](https://couryo.com/en/docs/errors.md): every code, with cause and fix. ## API principles - **REST and JSON**, dates in ISO 8601 UTC (`2026-10-07T18:30:00Z`). - **Prefixed, random IDs**: `msg_` (email), `dom_` (domain), `key_` (key), `whk_` (webhook), `evt_` (event), always 16 characters after the prefix. - **No fake success.** A `202` only comes back once the email is in the queue. If something prevents sending, you get an error with a stable code and an explanation. - **Idempotency on every POST**, with the `Idempotency-Key` header. - **Cursor pagination**: `?limit=25&starting_after=` returns `{ "data": [...], "has_more": true }`. - **Messages in English or Portuguese**, following the `Accept-Language` header (the default is `pt-BR`; send `Accept-Language: en` for English). ## Endpoints | Method and path | What it does | |---|---| | `POST /v1/emails` | sends an email | | `POST /v1/emails/batch` | sends up to 100 emails in one request | | `GET /v1/emails` and `GET /v1/emails/{id}` | lists emails or returns one, with its timeline | | `POST /v1/emails/check` | checks an email before sending, without sending it | | `GET` and `POST /v1/domains` | lists and creates domains | | `GET` and `DELETE /v1/domains/{id}`, `POST /v1/domains/{id}/verify` | reads, deletes and verifies a domain | | `GET` and `POST /v1/webhooks`, `GET`, `PATCH` and `DELETE /v1/webhooks/{id}` | webhooks | | `GET` and `POST /v1/suppressions`, `DELETE /v1/suppressions/{email}` | suppression list | | `GET /v1/events` | events, for when you missed a webhook | | `GET /openapi.json` | the API's OpenAPI 3.1 spec, no key needed | ## For AI agents This whole guide is available as Markdown. Add `.md` to any URL (for example, [/en/docs/quickstart.md](https://couryo.com/en/docs/quickstart.md.md)) or use [llms.txt](https://couryo.com/en/llms.txt) and [llms-full.txt](https://couryo.com/en/llms-full.txt). Pricing is at [/pricing.md](https://couryo.com/pricing.md). --- # Quickstart > Send your first email with Couryo in 5 minutes, from sign-up to the message timeline. Source: https://couryo.com/en/docs/quickstart In 5 minutes you create an account, verify a domain, get a key and send your first email. ## 1. Create your account Sign in at [app.couryo.com](https://app.couryo.com) with your Google account. Every account starts on the Free plan and at **level 0 (sandbox)**: up to 25 emails per day, only to the account's own addresses, which is enough to test your integration (before verifying a domain, use the `teste@sandbox.couryo.com` sender). See [trust levels](https://couryo.com/en/docs/limits.md). ## 2. Add and verify your domain In the dashboard, under **Domains**, add the domain your email will come from (for example, `example.com`). - **Using Cloudflare?** Click **Connect Cloudflare**. Couryo creates the records for you. - **Another DNS provider?** Copy each record shown on screen. The status of each one updates live. Once DKIM, return path and DMARC are correct, the domain becomes `verified` and your account moves to level 1 (100 per day). Your root domain's SPF stays untouched. The Free plan allows 1 domain. Details in [Domains and DNS](https://couryo.com/en/docs/domains.md). ## 3. Create an API key Under **Keys**, create a key with the `send` scope. The full key is shown **only once**: store it in your secrets manager or `.env`. ```bash title="Terminal" export COURYO_API_KEY="ck_live_..." ``` Want to test without delivering anything? Use a `ck_test_` key. See [Test mode](https://couryo.com/en/docs/test-mode.md). ## 4. Send ```bash title="cURL" curl https://api.couryo.com/v1/emails \ -H "Authorization: Bearer $COURYO_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: welcome-usr-123" \ -d '{ "from": "Example ", "to": ["you@example.com"], "subject": "Hello from Couryo", "html": "

It works!

", "text": "It works!" }' ``` ```js title="Node.js" const res = await fetch("https://api.couryo.com/v1/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.COURYO_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "welcome-usr-123", }, body: JSON.stringify({ from: "Example ", to: ["you@example.com"], subject: "Hello from Couryo", html: "

It works!

", text: "It works!", }), }); console.log(res.status, await res.json()); ``` The response is `202 Accepted`: ```json title="Response" { "id": "msg_9w2k7c1x0d4e5f6g", "status": "queued" } ``` ## 5. Follow the delivery A `send` key can only send. To read the timeline, use a key with the `read` scope (or open the email in the dashboard): ```bash title="cURL" curl https://api.couryo.com/v1/emails/msg_9w2k7c1x0d4e5f6g \ -H "Authorization: Bearer $COURYO_READ_KEY" ``` The `events` field lists every step (`accepted`, `queued`, `sent`, `delivered`...) with time, receiving server, SMTP code and a plain-language explanation. See [Events and timeline](https://couryo.com/en/docs/events.md). ## Next steps - Get events in your system with [webhooks](https://couryo.com/en/docs/webhooks.md). - Avoid duplicate sends with [idempotency](https://couryo.com/en/docs/sending.md#idempotency). - Use Couryo from your AI agent with the [MCP server](https://couryo.com/en/docs/mcp.md). - Rather not touch code? [SMTP](https://couryo.com/en/docs/smtp.md) is coming soon. > Official Node and PHP SDKs are on the way. Until then, any HTTP client works, as in the examples above. --- # Authentication and keys > How to authenticate with the Couryo API using ck_live_ and ck_test_ keys, scopes, allowed IPs and good practices. Source: https://couryo.com/en/docs/authentication Every request to `https://api.couryo.com/v1` uses an API key in the `Authorization` header: ```http title="Header" Authorization: Bearer ck_live_... ``` ## Key types | Prefix | Mode | What happens | |---|---|---| | `ck_live_` | live | real delivery | | `ck_test_` | test | never delivers: the email is captured in the dashboard with status `captured` | ## Scopes | Scope | Can | |---|---| | `send` | send and check email (`POST /v1/emails`, `/v1/emails/batch` and `/v1/emails/check`); cannot read | | `read` | read emails, events, domains, webhooks and suppressions; cannot send | | `admin` | everything, including creating and verifying domains, creating and changing webhooks and editing the suppression list | Use the smallest scope you can. Your application server usually needs only `send`; to read an email's timeline, use a `read` key. Keys belong to one project: each project has its own. ## Allowed IPs Each key can have a list of allowed IPs (addresses or CIDR ranges). A request from any other address gets a `403` with code `ip_not_allowed`. ## How we store your key - The full key is shown **only once**, at creation. After that, the dashboard shows only the prefix (for example, `ck_live_4f9a`). - Couryo stores only a hash of the key. If you lose it, create a new one and delete the old one. - A deleted key stops working immediately (`401 invalid_api_key`). ## Good practices - Keep the key in an environment variable or a secrets manager. Never in browser or mobile app code. - One key per application and environment, so you can rotate one without breaking the others. - Protect the Google account you use to sign in to the dashboard with 2-step verification, and set a [spending limit](https://couryo.com/en/docs/limits.md#spending-limit). ## Authentication errors | HTTP | `code` | When | |---|---|---| | 401 | `missing_api_key` | no `Authorization` header | | 401 | `invalid_api_key` | key does not exist, was deleted or was copied incorrectly | | 403 | `insufficient_scope` | the key lacks the required scope | | 403 | `ip_not_allowed` | the request came from an IP outside the list | See every code in [Errors](https://couryo.com/en/docs/errors.md). --- # Test mode > ck_test_ keys capture emails without delivering them and simulate delivery, bounces, complaints and deferrals by recipient address, with real events and webhooks. Source: https://couryo.com/en/docs/test-mode With a `ck_test_` key, Couryo does everything it would do for real (validates the request, applies idempotency, creates events and calls your webhooks), **except deliver**. No email leaves for the internet. ## How to use it 1. Create a test key under **Keys** (mode `test`). 2. Use it instead of the `ck_live_` key, with no other code change. 3. The response has `status: "captured"`: ```json title="Response 202" { "id": "msg_t3st0k9a1b2c3d4e", "status": "captured" } ``` The email's status stays `captured`; what happened to each recipient shows up in the events. ## Simulated addresses The part before the recipient's `@` decides what the test simulates, on any domain: | Recipient | What happens | Events | |---|---|---| | `bounced@...` or `bounce@...` | hard bounce | `sent` and `bounced` with `550 5.1.1` (unknown user) | | `complained@...` or `complaint@...` | delivered, then marked as spam | `sent`, `delivered` and `complained` | | `deferred@...` | deferred, then delivered | `sent`, `deferred` with `421 4.7.0` and `delivered` | | anything else | delivered | `sent` and `delivered` with `250 2.0.0` | Events arrive right after the send, with times (`at`) that mimic the real pace: the complaint 5 seconds after delivery, and the deferred email's delivery 1 minute after the deferral. ```bash title="cURL" curl https://api.couryo.com/v1/emails \ -H "Authorization: Bearer $COURYO_TEST_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "Shop ", "to": ["bounced@example.com", "ana@example.com"], "subject": "Bounce test", "text": "Hello" }' ``` Simulated events go through the same path as real ones: they show up in the timeline (`GET /v1/emails/{id}`), in `GET /v1/events` and in your [webhooks](https://couryo.com/en/docs/webhooks.md), with an SMTP response marked as simulated and a plain-language explanation. It is how you test bounce and complaint handling without hurting anyone's reputation. ## What is different from a live key - **No verified domain needed** in `from`, and no level 0 (sandbox) restriction. - **Does not count** toward the level's daily and monthly limits or the plan quota. The per-second request limit applies as usual. - **Never touches suppression** or the account's bounce and complaint rates: a simulated `bounced@` is not added to your suppression list. - The content check (shortened links and the like) only runs live. To check an email first, use [`POST /v1/emails/check`](https://couryo.com/en/docs/sending.md#pre-send-check). ## In the dashboard The email shows up under **Emails** with the `captured` status and the simulated timeline, and webhook calls appear in the delivery history, so you can check your integration. Viewing the rendered content and sharing a review link with your team: coming soon. ## Good for - Automated tests and CI: no email ever reaches real customers. - Staging environments with data copied from production. - Testing how you handle bounce, complaint and deferral webhooks. - Using the [MCP server](https://couryo.com/en/docs/mcp.md) with your agent at no risk. --- # 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. Source: https://couryo.com/en/docs/sending `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](https://couryo.com/en/docs/errors.md), never a fake success. ```json title="Response 202" { "id": "msg_9w2k7c1x0d4e5f6g", "status": "queued" } ``` With a `ck_test_` key the status is `captured` and nothing is delivered (see [Test mode](https://couryo.com/en/docs/test-mode.md)). ## Fields | Field | Type | Required | Description | |---|---|---|---| | `from` | string | yes | sender on one of the project's verified domains: `"hi@example.com"` or `"Shop "`. 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](#batch-sending). - 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. ```json title="Body with recipients as a string and as a list" { "from": "Example Shop ", "to": "ana@example.com", "cc": ["finance@example.com", "shipping@example.com"], "reply_to": "support@example.com", "subject": "Order 1042 confirmed", "html": "

Your order 1042 is confirmed.

", "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: ```http title="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](https://couryo.com/en/docs/suppressions.md), 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. ```bash title="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 `param`s include the item index: ```json title="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`. ```json title="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. ```json title="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. ```json title="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](https://couryo.com/en/docs/events.md) | --- # SMTP > Coming soon. Couryo's SMTP relay, to send without changing code. Host, ports, username, password and examples for Laravel, Node and Supabase. Source: https://couryo.com/en/docs/smtp > **Coming soon.** The SMTP relay is not live yet: `smtp.couryo.com` does not accept connections for now. Today, send through the [REST API](https://couryo.com/en/docs/sending.md), which already has idempotency, batches and attachments. This page shows how it will work. For apps, frameworks and tools that already send over SMTP, swapping the credentials will be enough. ## Credentials | Field | Value | |---|---| | Host | `smtp.couryo.com` | | Port | `587` (STARTTLS), `465` (implicit TLS) or `2525` (when 587 is blocked) | | Username | `couryo` | | Password | your API key (`ck_live_...` or `ck_test_...`) with the `send` scope | The sender must be on a [verified domain](https://couryo.com/en/docs/domains.md), as with the API. `ck_test_` keys also work over SMTP and capture the email without delivering it. ## Laravel ```ini title=".env" MAIL_MAILER=smtp MAIL_HOST=smtp.couryo.com MAIL_PORT=587 MAIL_USERNAME=couryo MAIL_PASSWORD=ck_live_... MAIL_ENCRYPTION=tls MAIL_FROM_ADDRESS=hi@example.com MAIL_FROM_NAME="Example" ``` ## Node.js (Nodemailer) ```js title="Node.js" import nodemailer from "nodemailer"; const transport = nodemailer.createTransport({ host: "smtp.couryo.com", port: 587, secure: false, // STARTTLS auth: { user: "couryo", pass: process.env.COURYO_API_KEY }, }); await transport.sendMail({ from: "Example ", to: "ana@example.com", subject: "Hello", text: "Sent through Couryo over SMTP.", }); ``` ## Supabase Auth and other tools In tools with a "custom SMTP" setting (Supabase Auth, WordPress, n8n and similar), fill in host, port, username and password from the table above. ## API or SMTP? | | API | SMTP | |---|---|---| | Idempotency | yes, with `Idempotency-Key` | no | | Message `id` in the response | yes | yes, in the `DATA` response | | Batch of up to 100 per request | yes | no | | Code change | small | none | Once SMTP is live, both paths will create the same events, show on the same timeline and call the same webhooks. --- # Domains and DNS > How to verify a domain on Couryo. The DKIM (couryo1 and couryo2), return path and DMARC records the API generates, why there is no SPF on your root domain, and how DMARC moves up to p=reject. Source: https://couryo.com/en/docs/domains To send with your domain in `from`, it has to be verified. This proves to mailbox providers (Gmail, Microsoft, Yahoo and others) that Couryo may send on your behalf, and it is the single biggest factor in reaching the inbox. ## Add a domain Creating a domain needs a key with the `admin` scope (or the dashboard). ```bash title="cURL" curl https://api.couryo.com/v1/domains \ -H "Authorization: Bearer $COURYO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "example.com" }' ``` The response (`201`) lists the records you need to create, each with its own `status`: ```json title="Response 201" { "id": "dom_7h2kq9w4x1abcdef", "name": "example.com", "status": "pending", "dmarc_policy": "missing", "cloudflare_connected": false, "records": [ { "purpose": "dkim", "type": "CNAME", "name": "couryo1._domainkey.example.com", "value": "couryo1.7h2kq9w4x1abcdef.dkim.couryo.com", "status": "pending" }, { "purpose": "dkim", "type": "CNAME", "name": "couryo2._domainkey.example.com", "value": "couryo2.7h2kq9w4x1abcdef.dkim.couryo.com", "status": "pending" }, { "purpose": "return_path", "type": "CNAME", "name": "bounces.example.com", "value": "rp.couryo.com", "status": "pending" }, { "purpose": "dmarc", "type": "TXT", "name": "_dmarc.example.com", "value": "v=DMARC1; p=none; rua=mailto:dmarc@couryo.com; adkim=r; aspf=r", "status": "pending" } ], "created_at": "2026-10-07T18:30:00Z" } ``` Both DKIM targets always follow `..dkim.couryo.com`. The ID above is an example: copy the values shown in the dashboard or in the API response for your domain. > **Domains per plan:** Free 1, Pro 10, Scale 50 and Enterprise unlimited, across all projects of the account. Above that, the API answers `403` with the [`domain_limit_reached`](https://couryo.com/en/docs/errors.md#domain_limit_reached) code; deleting a domain frees the slot. A domain can only be registered once on Couryo (`409 domain_exists`). ## What each record does | Record | Type | Name | What it is for | |---|---|---|---| | **DKIM** | CNAME | `couryo1._domainkey` and `couryo2._domainkey` | Every email is signed with a 2048-bit RSA key of your own domain. Both selectors point to Couryo's zone, where the public keys live: we sign with one while the other one's key is replaced, so keys rotate without you touching DNS. | | **Return path** | CNAME | `bounces` | The technical envelope address, on a subdomain of yours. It carries SPF (aligned with your domain) and routes bounces to Couryo, which turns them into events. | | **DMARC** | TXT | `_dmarc` | Tells providers what to do with email pretending to be from your domain, and where to send reports. `rua=mailto:dmarc@couryo.com` brings those reports to Couryo. It starts at `p=none`. | | **Inbound** | MX | | Optional, to receive email in Couryo (coming soon). | ### Why there is no SPF on your root domain SPF is checked on the envelope domain (the return path), not on the `From` domain. Since the envelope uses `bounces.example.com`, which points to Couryo by CNAME, SPF passes and is aligned with your domain. **Leave the SPF of `example.com` alone**: the one you already have (Google Workspace, Microsoft 365...) stays as it is, and Couryo uses none of the 10 DNS lookups SPF allows. > If the response has a `return_path` record of type `MX` plus an `spf` record (`TXT v=spf1 include:spf.couryo.com ~all`), that is the alternative return path mode. Create both on the `bounces.` subdomain, never on the root domain. ## Verify With Cloudflare connected, Couryo creates the records and verifies them on its own. Anywhere else, create the records and request a check (`admin` scope): ```bash title="cURL" curl -X POST https://api.couryo.com/v1/domains/dom_7h2kq9w4x1abcdef/verify \ -H "Authorization: Bearer $COURYO_API_KEY" ``` The check queries public DNS. Each record comes back with a `status`: - `ok`: found and correct. - `pending`: not found yet. DNS propagation can take from minutes to a few hours. - `wrong`: found with a different value. The `found` field shows what is published, so you can compare. If you already have your own DMARC record, it is respected: the record shows `ok` and `found` holds your value. Two DMARC records on the same name invalidate each other and show as `wrong`. The domain becomes `verified` once DKIM, return path and DMARC are `ok` and Couryo's engine confirms the DKIM signature. Pending domains are checked again automatically every 10 minutes for 72 hours; verified ones, once a day. The first verified domain moves the account from level 0 to level 1 (see [Limits and levels](https://couryo.com/en/docs/limits.md)). ## Connect Cloudflare In the dashboard, under **Domains**, use **Connect Cloudflare** and paste a token from your account with the **Zone > DNS > Edit** permission for the domain. Couryo creates the records in your zone and verifies right away. An existing DMARC record is never overwritten. Possible errors: `invalid_cloudflare_token` (token refused or missing the permission) and `cloudflare_zone_not_found` (the token cannot reach the domain's zone). ## Tips by DNS provider - **Cloudflare (by hand):** keep the CNAMEs DNS-only (grey cloud). - **Other providers:** some append the domain automatically. If a record shows up as `couryo1._domainkey.example.com.example.com`, remove the repeated domain from the name. ## DMARC from p=none to p=reject DMARC starts at `p=none`: providers only observe and send reports. Once reports are clean for a few weeks, it is worth moving to `p=quarantine` (spoofed email goes to spam) and then `p=reject` (spoofed email is refused). Walking DMARC up with your permission is the deliverability agent's next step (coming soon). The domain's `dmarc_policy` field shows the policy published today: `none`, `quarantine`, `reject` or `missing`. ## Other endpoints | Method and path | Scope | What it does | |---|---|---| | `GET /v1/domains` | `read` | lists the project's domains | | `GET /v1/domains/{id}` | `read` | one domain, with its records | | `DELETE /v1/domains/{id}` | `admin` | removes the domain (`204`) and frees the plan slot | --- # Webhooks > Get your email events by webhook, signed with Standard Webhooks and retried for up to 3 days. Verification examples in Node, PHP and Python. Source: https://couryo.com/en/docs/webhooks Webhooks tell your system when something happens to an email: delivered, deferred, bounced, complained, opened, clicked. Every call is signed with the open [Standard Webhooks](https://www.standardwebhooks.com) specification. ## Create a webhook ```bash title="cURL" curl https://api.couryo.com/v1/webhooks \ -H "Authorization: Bearer $COURYO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com/webhooks/couryo", "events": ["delivered", "bounced", "complained"] }' ``` ```json title="Response" { "id": "whk_5n1c8v2z7r3k6m9p", "url": "https://example.com/webhooks/couryo", "events": ["delivered", "bounced", "complained"], "enabled": true, "secret": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw", "created_at": "2026-10-07T18:30:00Z" } ``` The `secret` is shown **only in this response**. Store it with your other credentials. - The URL must start with `https://` and be public: internal network addresses are refused (`invalid_field`). - Creating, changing and deleting webhooks needs the `admin` scope; listing and reading, `read`. - `PATCH /v1/webhooks/{id}` changes `url`, `events` or `enabled` (to pause without deleting). `DELETE /v1/webhooks/{id}` deletes it. ## What you receive A JSON `POST` with three headers: ```http title="Headers" webhook-id: evt_7d3k9s0a2m5n8b1c webhook-timestamp: 1791484264 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= ``` ```json title="Body" { "type": "bounced", "timestamp": "2026-10-07T18:31:04Z", "data": { "email_id": "msg_9w2k7c1x0d4e5f6g", "event_id": "evt_7d3k9s0a2m5n8b1c", "recipient": "maria@company.com", "provider": "other", "mx": "mx.company.com", "smtp_code": "550 5.1.1", "smtp_response": "550 5.1.1 : Recipient address rejected: User unknown", "explanation": "The mailbox does not exist. The address was added to the suppression list.", "tags": { "type": "order" }, "metadata": { "order_id": "1042" } } } ``` `webhook-id` is the event ID and stays the same on retries: use it to ignore duplicates. ## Verify the signature Always verify before trusting the payload. The signature is an HMAC-SHA256 of `webhook-id.webhook-timestamp.body`, keyed with the Base64-decoded secret (without the `whsec_` prefix). Also reject messages older than 5 minutes, to prevent replays. > Use the **raw body**, exactly as received. If your framework already parsed it into JSON, the signature will not match. ```js title="Node.js (Express)" import crypto from "node:crypto"; import express from "express"; function verifyWebhook(rawBody, headers, secret) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) throw new Error("missing headers"); if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("timestamp too old"); const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"); const valid = signatures.split(" ").some((entry) => { const [version, signature] = entry.split(","); return ( version === "v1" && signature?.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) ); }); if (!valid) throw new Error("invalid signature"); return JSON.parse(rawBody); } const app = express(); app.post("/webhooks/couryo", express.raw({ type: "application/json" }), (req, res) => { let event; try { event = verifyWebhook(req.body.toString("utf8"), req.headers, process.env.COURYO_WEBHOOK_SECRET); } catch { return res.sendStatus(400); } res.sendStatus(200); // answer fast; process later (queue, job) console.log(event.type, event.data.email_id); }); ``` ```php title="PHP" function verify_webhook(string $body, array $headers, string $secret): array { $id = $headers['webhook-id'] ?? ''; $timestamp = $headers['webhook-timestamp'] ?? ''; $signatures = $headers['webhook-signature'] ?? ''; if ($id === '' || $timestamp === '' || $signatures === '') { throw new RuntimeException('missing headers'); } if (abs(time() - (int) $timestamp) > 300) { throw new RuntimeException('timestamp too old'); } $key = base64_decode(preg_replace('/^whsec_/', '', $secret)); $expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true)); foreach (explode(' ', $signatures) as $entry) { [$version, $signature] = array_pad(explode(',', $entry, 2), 2, ''); if ($version === 'v1' && hash_equals($expected, $signature)) { return json_decode($body, true); } } throw new RuntimeException('invalid signature'); } $body = file_get_contents('php://input'); $headers = array_change_key_case(getallheaders(), CASE_LOWER); try { $event = verify_webhook($body, $headers, getenv('COURYO_WEBHOOK_SECRET')); } catch (RuntimeException $e) { http_response_code(400); exit; } http_response_code(200); ``` ```python title="Python" import base64 import hashlib import hmac import json import time def verify_webhook(body: bytes, headers, secret: str) -> dict: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not (msg_id and timestamp and signatures): raise ValueError("missing headers") if abs(time.time() - int(timestamp)) > 300: raise ValueError("timestamp too old") key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for entry in signatures.split(" "): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return json.loads(body) raise ValueError("invalid signature") ``` The official Standard Webhooks libraries (for Node, PHP, Python, Go, Ruby and more) also work with Couryo's secret. ## Retries - Reply with any `2xx` status to acknowledge. Reply fast and process later, in a queue. - Without a `2xx` (or with no answer within 15 seconds), Couryo retries after 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 12 h, 16 h and 24 h: 12 attempts over about **3 days**. - The dashboard shows each webhook's last 100 attempts, with your server's status and response, and has a button to resend right away. - Missed events? List everything at [`GET /v1/events`](https://couryo.com/en/docs/events.md#list-events). ## Available events `accepted`, `queued`, `sent`, `delivered`, `deferred`, `bounced`, `complained`, `opened`, `clicked`, `suppressed` and `failed`. What each one means is in [Events and timeline](https://couryo.com/en/docs/events.md). --- # Events and timeline > Every step of an email on Couryo, with time, receiving server, SMTP code, the original response and a plain-language explanation. Source: https://couryo.com/en/docs/events Every email has a timeline. Each event carries the time, the receiving server, the SMTP code, **the server's original response** and a plain-language explanation in your account's language. ## See the timeline ```bash title="cURL" curl https://api.couryo.com/v1/emails/msg_9w2k7c1x0d4e5f6g \ -H "Authorization: Bearer $COURYO_API_KEY" \ -H "Accept-Language: en" ``` ```json title="Response" { "id": "msg_9w2k7c1x0d4e5f6g", "from": "Acme Store ", "to": ["ana@example.com"], "subject": "Your order 1042 is confirmed", "stream": "transactional", "status": "delivered", "tags": { "type": "order" }, "metadata": { "order_id": "1042" }, "created_at": "2026-10-07T18:30:00Z", "events": [ { "id": "evt_1a", "type": "accepted", "at": "2026-10-07T18:30:00Z" }, { "id": "evt_1b", "type": "queued", "at": "2026-10-07T18:30:00Z" }, { "id": "evt_1c", "type": "sent", "at": "2026-10-07T18:30:01Z", "recipient": "ana@example.com" }, { "id": "evt_1d", "type": "delivered", "at": "2026-10-07T18:30:02Z", "recipient": "ana@example.com", "mx": "gmail-smtp-in.l.google.com", "provider": "gmail", "smtp_code": "250 2.0.0", "smtp_response": "250 2.0.0 OK 1791484202 a1b2c3d4e5f6 - gsmtp", "explanation": "Gmail accepted the message." } ] } ``` ## Event types | Event | Meaning | |---|---| | `accepted` | the API received and validated the request | | `queued` | the email is in the queue (or waiting for `scheduled_at`) | | `sent` | it left Couryo for the receiving server | | `delivered` | the receiving server accepted the message | | `deferred` | the receiver asked to try later (`4xx` code); Couryo retries on its own | | `bounced` | the receiver refused it for good (`5xx` code) | | `complained` | the recipient marked it as spam | | `opened` | the email was opened (`marketing` stream only; imprecise, because some mail apps open everything on their own) | | `clicked` | a link was clicked (`marketing` stream only) | | `suppressed` | not sent because the address is on the [suppression list](https://couryo.com/en/docs/suppressions.md) | | `failed` | not sent: the content was stopped by the intake check (phishing, a blocklisted link) or the send failed; the reason is in the event | > "Delivered" means the receiving server accepted it. Whether it landed in the inbox or in spam is not reported per message by any provider. That is why the dashboard shows delivery split by provider; reputation tracking by the deliverability agent is coming soon. ## Email status The `status` field summarizes the timeline: `queued`, `sent`, `delivered`, `deferred`, `bounced`, `complained`, `failed`, `suppressed` or `captured` (test key, not delivered). ## Providers The `provider` field groups the destination: `gmail`, `microsoft`, `yahoo`, `uol`, `bol`, `terra`, `locaweb` or `other`. The dashboard shows delivery split by provider. ## Bounces: permanent, temporary and blocks - **Permanent** (`5.1.x`, user or domain does not exist): the address goes to the suppression list right away. - **Temporary** (`4.x.x`): Couryo retries on its own. If the same address bounces on 3 different days (within 14 days), it is suppressed. - **Policy block** (`5.7.x`, responses mentioning "blocked" or "spam"): not the recipient's fault. The address is not suppressed and the bounce does not count toward your bounce rate. ## List events `GET /v1/events` (`read` scope) lists events for all emails in the project, newest first, each with its `email_id`. Useful if you missed webhooks or prefer to poll. Filters: `type` (for example `bounced`) and `email_id`. ```bash title="cURL" curl "https://api.couryo.com/v1/events?type=bounced&limit=50" \ -H "Authorization: Bearer $COURYO_API_KEY" ``` ```json title="Response" { "data": [{ "id": "evt_7d3k9s0a2m5n8b1c", "email_id": "msg_9w2k7c1x0d4e5f6g", "type": "bounced", "at": "2026-10-07T18:31:04Z", "recipient": "maria@company.com", "smtp_code": "550 5.1.1" }], "has_more": true } ``` For the next page, pass `starting_after` with the `id` of the last item. ## How long it is kept The timeline is available for your plan's log retention: 7 days on Free, 30 days on Pro and 90 days on Scale. --- # Suppression > Couryo's suppression list protects your reputation. Reasons, streams, and how to list, add and remove addresses through the API. Source: https://couryo.com/en/docs/suppressions The suppression list holds addresses Couryo will no longer send to. It protects your reputation: insisting on an address that does not exist, or on someone who complained, drags down delivery of all your email. ## Reasons | `reason` | When it is added | Applies to | |---|---|---| | `hard_bounce` | the address does not exist (`5.1.x`) | all streams | | `complaint` | the recipient marked the email as spam | all streams | | `unsubscribe` | the recipient unsubscribed | only the stream they unsubscribed from (usually `marketing`) | | `manual` | you added it | the stream you choose | A marketing unsubscribe **never** blocks transactional email: someone who left the newsletter still gets the password reset. ## What happens when sending If **some** recipients are suppressed, the email goes to the others and the timeline gets a `suppressed` event for each skipped address. If **every** recipient is suppressed, the send is refused with `422 recipient_suppressed`, and `param` points to the first one (`to[0]`). In a batch, only the affected item is refused. Temporary bounces (`4.x.x`) also lead to suppression when the same address bounces on 3 different days. ## List ```bash title="cURL" curl "https://api.couryo.com/v1/suppressions?limit=25" \ -H "Authorization: Bearer $COURYO_API_KEY" ``` ```json title="Response" { "data": [ { "email": "maria@company.com", "reason": "hard_bounce", "stream": "all", "created_at": "2026-10-07T18:31:04Z" } ], "has_more": false } ``` ## Add ```bash title="cURL" curl https://api.couryo.com/v1/suppressions \ -H "Authorization: Bearer $COURYO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "do-not-send@example.com", "stream": "marketing" }' ``` ## Remove ```bash title="cURL" curl -X DELETE https://api.couryo.com/v1/suppressions/maria@company.com \ -H "Authorization: Bearer $COURYO_API_KEY" ``` The address leaves every stream (`204`). Remove an address only when you are sure the problem is solved (for example, the person confirmed the address works again). ## Scopes and filters - Listing needs the `read` scope; adding and removing, `admin`. - The list accepts `q` (part of the address), `reason` and `limit` (default 100), with `starting_after` for the next page. - Added addresses always get the `manual` reason; `stream` can be `transactional`, `marketing` or `all` (default). An address already on the list returns `409 already_suppressed`. --- # Errors > Couryo API error format, types, HTTP statuses and every code with cause and fix. Source: https://couryo.com/en/docs/errors Every error has the same shape. The `code` is stable: you can rely on it in your code. Each `code` always has the same `type` and the same HTTP status. ```json title="Error" { "error": { "type": "invalid_request", "code": "domain_not_verified", "message": "The domain example.com is not verified in this project yet.", "param": "from", "doc_url": "https://couryo.com/docs/errors#domain_not_verified", "request_id": "req_6f2a9c1e7b0k3m4n" } } ``` | Field | What it is | |---|---| | `type` | the error category (table below) | | `code` | the exact reason, stable | | `message` | a human explanation, in English or Portuguese following `Accept-Language` (Portuguese by default; send `Accept-Language: en`) | | `param` | the offending field, when there is one (for example `to[1]` or `emails[2].from`) | | `doc_url` | a link to the explanation of this code | | `request_id` | the request ID, same as the `Request-Id` header; send it to support if you need help | ## Types | `type` | HTTP | When | |---|---|---| | `invalid_request` | 400 or 422 | the request has a problem you can fix | | `authentication` | 401 | missing or invalid key | | `permission` | 403 | the key or the plan does not allow this | | `not_found` | 404 | the resource or route does not exist | | `conflict` | 409 | a conflict, such as a reused idempotency key | | `rate_limited` | 429 | too many calls in a short time | | `tier_limit` | 429 | a limit of your level, your plan or your spending limit | | `api_error` | 500 or 503 | a problem on our side; you can retry | ## Codes | `code` | `type` | HTTP | Summary | |---|---|---|---| | [`missing_field`](#missing_field) | `invalid_request` | 400 | a required field is missing | | [`invalid_field`](#invalid_field) | `invalid_request` | 400 | a field has an invalid value | | [`invalid_json`](#invalid_json) | `invalid_request` | 400 | the body is not JSON | | [`batch_too_large`](#batch_too_large) | `invalid_request` | 400 | more than 100 emails in a batch | | [`invalid_cloudflare_token`](#invalid_cloudflare_token) | `invalid_request` | 400 | Cloudflare token refused | | [`cloudflare_zone_not_found`](#cloudflare_zone_not_found) | `invalid_request` | 400 | the token cannot reach the domain's zone | | [`domain_not_verified`](#domain_not_verified) | `invalid_request` | 422 | the `from` domain is not verified | | [`recipient_suppressed`](#recipient_suppressed) | `invalid_request` | 422 | every recipient is suppressed | | [`missing_api_key`](#missing_api_key) | `authentication` | 401 | no key | | [`invalid_api_key`](#invalid_api_key) | `authentication` | 401 | invalid key | | [`not_authenticated`](#not_authenticated) | `authentication` | 401 | dashboard call without a session | | [`insufficient_scope`](#insufficient_scope) | `permission` | 403 | the key's scope is not enough | | [`ip_not_allowed`](#ip_not_allowed) | `permission` | 403 | IP outside the key's list | | [`sandbox_recipient`](#sandbox_recipient) | `permission` | 403 | level 0 account sending outside the account | | [`domain_limit_reached`](#domain_limit_reached) | `permission` | 403 | the account already has the plan's domains | | [`forbidden_origin`](#forbidden_origin) | `permission` | 403 | dashboard write from another site | | [`resource_not_found`](#resource_not_found) | `not_found` | 404 | unknown ID | | [`route_not_found`](#route_not_found) | `not_found` | 404 | unknown route | | [`idempotency_conflict`](#idempotency_conflict) | `conflict` | 409 | same idempotency key, different body | | [`idempotency_in_progress`](#idempotency_in_progress) | `conflict` | 409 | the same request is still running | | [`domain_exists`](#domain_exists) | `conflict` | 409 | domain already registered | | [`already_suppressed`](#already_suppressed) | `conflict` | 409 | address already on the list | | [`last_project`](#last_project) | `conflict` | 409 | trying to delete the only project | | [`rate_limited`](#rate_limited) | `rate_limited` | 429 | too many calls per second | | [`daily_limit_reached`](#daily_limit_reached) | `tier_limit` | 429 | daily limit reached | | [`monthly_limit_reached`](#monthly_limit_reached) | `tier_limit` | 429 | monthly quota reached | | [`spend_limit_reached`](#spend_limit_reached) | `tier_limit` | 429 | overage reached your spending limit | | [`sending_paused`](#sending_paused) | `tier_limit` | 429 | sending paused, with the reason | | [`internal_error`](#internal_error) | `api_error` | 500 | an error on our side | | [`billing_unavailable`](#billing_unavailable) | `api_error` | 503 | billing unavailable right now | ### missing_field A required field is missing, named in `param`. To send, `from`, `to`, `subject` and one of `html` or `text` are required (with neither, `param` is `html`). ### invalid_field The field named in `param` has a value we do not accept: a malformed address, more than 50 recipients across `to`, `cc` and `bcc`, a `scheduled_at` in the past or more than 30 days ahead, a webhook URL without `https://`. The `message` says what is wrong. In a batch, `param` includes the index: `emails[2].to[0]`. ### invalid_json The body is not valid JSON. Check `Content-Type: application/json` and your quotes. ### batch_too_large The batch has more than 100 emails. Split it into smaller calls. ### invalid_cloudflare_token Cloudflare did not accept the token. It needs the **Zone > DNS > Edit** permission for the domain. See [Domains and DNS](https://couryo.com/en/docs/domains.md#connect-cloudflare). ### cloudflare_zone_not_found The token is valid but does not give access to the domain's zone on Cloudflare. Create the token including that zone. ### domain_not_verified The `from` domain is not verified in the key's project. Verify it in [Domains and DNS](https://couryo.com/en/docs/domains.md) and try again. `ck_test_` keys do not need a verified domain. ### recipient_suppressed **Every** recipient is on the [suppression list](https://couryo.com/en/docs/suppressions.md), because of a hard bounce, a complaint, an unsubscribe or a manual entry. `param` points to the first one (`to[n]`). If only some recipients are suppressed, the email goes to the others and the timeline gets a `suppressed` event for each skipped address. ### missing_api_key The `Authorization: Bearer ck_...` header is missing. See [Authentication](https://couryo.com/en/docs/authentication.md). ### invalid_api_key The key does not exist, was deleted or was copied only in part. Create a new one in the dashboard if needed. ### not_authenticated A dashboard call (`/api`) without a session. Sign in again at [app.couryo.com](https://app.couryo.com). It never happens on the public API (`/v1`). ### insufficient_scope The key is not allowed to do this. For example, a `send` key trying to read emails, which needs `read`. The `message` says which scope is missing. ### ip_not_allowed The key has an allowed IP list and the call came from another address. Adjust the list in the dashboard. ### sandbox_recipient The account is on level 0 (sandbox), or the sender is `teste@sandbox.couryo.com`, and that path only sends to the account's own addresses. Verify a domain to move to level 1. See [Limits and levels](https://couryo.com/en/docs/limits.md). ### domain_limit_reached The account already has the number of domains its plan allows: Free 1, Pro 10, Scale 50, Enterprise unlimited (across all projects). Delete a domain you do not use or change plans. The `message` gives the current limit and the next plan's. ### forbidden_origin A dashboard write came from another site (CSRF protection). It never happens on the public API. ### resource_not_found The ID does not exist in this project, or belongs to another project. The `message` says which resource was not found. ### route_not_found The path does not exist. Check the method and the URL, for example `POST /v1/emails`. ### idempotency_conflict The same `Idempotency-Key` was used in the last 24 hours with a different body. Use a new key for a new request. See [Idempotency](https://couryo.com/en/docs/sending.md#idempotency). ### idempotency_in_progress A request with the same `Idempotency-Key` is still being processed. Wait for `Retry-After` (1 second) and repeat: you get the first request's response. ### domain_exists This domain is already registered on Couryo, in this or another account. If it is yours and lives in another account, delete it there first. ### already_suppressed The address is already on the suppression list for that stream. ### last_project The account needs at least one project. Create another one before deleting this one. ### rate_limited Too many calls in a short time (the default is 10 per second per key). Wait the seconds in the `Retry-After` header and retry with the same `Idempotency-Key`. The `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` headers show your headroom. ### daily_limit_reached You reached your trust level's daily limit, or the Free plan's (100 per day). The `message` gives the limit, how much was sent and what is missing to move up a level. The counter resets at midnight in your account's time zone. ### monthly_limit_reached You reached the monthly quota: 3,000 on the Free plan and also on level 1 of any plan. Sending resumes next month (on a paid plan, next period). On level 1, the quota goes up when the account reaches level 2; on Free, when you subscribe to Pro or Scale. ### spend_limit_reached This month's overage reached the spending limit you set under **Billing**. **All** sending stops (transactional and marketing) until you raise the limit or the period renews. Nothing is charged above the cap. ### sending_paused Sending is paused. On a regular pause, marketing stops and transactional email keeps going with up to 30 per day; on a full pause, everything stops. The `message` brings the reason, the numbers and how to fix it, and the dashboard has a **Request review** button. See [Limits and levels](https://couryo.com/en/docs/limits.md#pauses). ### internal_error A problem on our side. The request was **not** processed. Retry with the same `Idempotency-Key`: there is no risk of a duplicate. ### billing_unavailable Billing is unavailable right now (checkout or payment portal). Try again in a few minutes; sending email is not affected. --- # Limits and trust levels > Couryo trust levels, daily and monthly limits, each plan's limits (emails and domains), the spending limit, rate limit headers, pauses and how to request a review. Source: https://couryo.com/en/docs/limits Couryo protects every customer's reputation without catching anyone by surprise. Limits are public, show in the dashboard and in the API (in error messages), and every pause comes with a reason and numbers. ## Trust levels | Level | How you get there | Limit | |---|---|---| | 0, sandbox | account created | 25 per day, only to the account's own addresses | | 1, new | a verified domain (DKIM, return path and DMARC) | 100 per day and 3,000 per month | | 2, verified | 7 clean days and a checked company ID (CNPJ) or a payment method on file | 1,000 per day, doubling every clean week up to 10,000 | | 3, trusted | 30 clean days on level 2 and a paid plan | your plan's volume, with the spending limit | - **You move up on your own.** No manual approval, no form. - **The dashboard shows what is missing**, for example: "4 more clean days to reach level 2". - **"Clean days"** are days without a bounce or complaint pause. - Limits count **recipients** (`to` + `cc` + `bcc`), not calls. The day rolls over at midnight in the account's time zone; the month is the billing period or, on Free, the calendar month. - `ck_test_` keys never count toward sending limits. The limit at any moment is the **lower** of the level's and the plan's: on the Free plan, the cap is 100 per day even on level 2. ## Plan limits | Plan | Emails per month | Per day | Domains | Logs | |---|---|---|---|---| | Free | 3,000 | up to 100 | 1 | 7 days | | Pro | 50,000 included, overage per 1,000 | the level's | up to 10 | 30 days | | Scale | 200,000 included, overage per 1,000 | the level's | up to 50 | 90 days | | Enterprise | custom | custom | unlimited | extended | - **Domains** count across all projects of the account. Above the limit, the API answers `403 domain_limit_reached`; deleting a domain frees the slot. - **On Free**, sending stops at the monthly quota (`monthly_limit_reached`) and resumes next month, with no charge. - **On paid plans**, overage is billed per started block of 1,000 emails. See [pricing](https://couryo.com/en/pricing). ## Spending limit Under **Billing**, you set how much overage you accept per month. When overage reaches that amount, **all** sending stops (transactional and marketing) with `spend_limit_reached`, until you raise the limit or the period renews. Nothing is charged above the cap. ## Limits that pause | Metric | Pauses when | |---|---| | Bounce rate | above 3% over the last 24 hours, with at least 50 sent | | Complaint rate (marked as spam) | above 0.05% over the last 7 days, with at least 200 sent and 2 complaints | Rates are per account, measured in real time, and sit well below what major providers tolerate, so problems get fixed early. Policy blocks from the receiving side (`5.7.x`) do not count toward the bounce rate. ## Pauses When a rate goes over the limit, the pause is **gradual**: 1. marketing email stops; 2. critical transactional email (password, login, billing) keeps going, with up to 30 per day; 3. you get an email with the reason, the numbers and what to fix. Above a 10% bounce rate or a 0.5% complaint rate, the pause is **full**, including at the sending engine, and only a person on the team can lift it. A full stop also happens on clear fraud, such as phishing or a purchased list. While paused, the API answers `sending_paused` with the reason, the numbers and how to fix it, and the dashboard shows the same, with a **Request review** button. ## Request a review Disagree with a pause, or already fixed it? Use the **Request review** button in the dashboard and explain your case. - **Right away:** an AI agent reviews the request against your account data and decides clear cases. If approved, the account spends 48 hours under observation (status `limited`), with up to 50 per day, and returns to normal if the rates stay healthy. - **Within 1 business day:** if there is still doubt, a person on the team reviews it and replies with the reason. The full rules are in the [acceptable use and suspension policy](https://couryo.com/en/acceptable-use). ## Rate limits Each key has a 1-second window (10 calls per second by default). Every response carries these headers: | Header | What it says | |---|---| | `RateLimit-Limit` | how many calls fit in the window | | `RateLimit-Remaining` | how many are left | | `RateLimit-Reset` | seconds until the window restarts | | `Retry-After` | on `429 rate_limited`, how many seconds to wait | On a `429`, wait for `Retry-After` and retry with the same `Idempotency-Key`. To send many emails at once, use a [batch](https://couryo.com/en/docs/sending.md#batch-sending): up to 100 per call. ## Sizes | What | Limit | |---|---| | Recipients per email (`to` + `cc` + `bcc`) | 50 | | Emails per batch | 100 | | Attachments per email | 20 | | Request body (with Base64 attachments) | 30 MB | | `html` or `text` | 5 MB each | | Scheduling (`scheduled_at`) | up to 30 days ahead | --- # MCP and AI agents > Couryo's remote MCP server at mcp.couryo.com, the send_email, get_email, domain_health and why_bounced tools, and how to connect from Claude, Cursor and ChatGPT. Source: https://couryo.com/en/docs/mcp Couryo runs a remote MCP server at `https://mcp.couryo.com`. With it, your AI agent sends email, reads a message's timeline, checks a domain's DNS and explains why an email bounced, just by chatting. - **Transport:** Streamable HTTP, stateless. - **Sign-in:** the same API key as the REST API, in the `Authorization: Bearer ck_...` header, with the same scopes and the key's project. - **OAuth:** coming soon. With it, the Claude and ChatGPT connectors will sign in without pasting any key. ## Tools | Tool | Scope | What it does | |---|---|---| | `send_email` | `send` | sends an email from a verified domain: `from`, `to` (string or list), `subject`, `html` and/or `text`, `stream` and an optional `idempotency_key` (24 hours, like the `Idempotency-Key` header) | | `get_email` | `read` | returns an email (`id`, `msg_...`) and its timeline, with SMTP code, raw response and a plain-language explanation | | `domain_health` | `read` | checks a project domain's DKIM, return path and DMARC in real DNS (`domain`) and summarizes what to fix | | `why_bounced` | `read` | explains why an email (`email_id`) bounced, was deferred or got a complaint, and what to do | Responses and error messages follow the `Accept-Language` header (Portuguese by default; send `Accept-Language: en` for English). Errors use the same shape and codes as the [API](https://couryo.com/en/docs/errors.md). ### Which key to use - A `send` key can only use `send_email`; a `read` key only the three read tools. For all four, use an `admin` key. - Start with a `ck_test_` key: nothing is delivered, and the [simulated addresses](https://couryo.com/en/docs/test-mode.md#simulated-addresses) (`bounced@`, `complained@`, `deferred@`) let the agent try `why_bounced` for real. - The key lives in the MCP client's configuration on your machine. Do not paste it into the chat. ## Claude Code ```bash title="Terminal" claude mcp add --transport http couryo https://mcp.couryo.com \ --header "Authorization: Bearer $COURYO_API_KEY" ``` Then just ask: "why did email msg_... bounce?" or "is example.com's DNS right?". ## Claude Desktop Until OAuth lands, Claude Desktop connects through the `mcp-remote` bridge, which forwards the header with your key. Under **Settings > Developer > Edit config**, in `claude_desktop_config.json`: ```json title="claude_desktop_config.json" { "mcpServers": { "couryo": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.couryo.com", "--header", "Authorization:${COURYO_AUTH}"], "env": { "COURYO_AUTH": "Bearer ck_live_..." } } } } ``` Restart Claude Desktop. The connector through **Settings > Connectors** (and on claude.ai) depends on OAuth: coming soon. ## Cursor Under **Settings > MCP**, or in the project's `.cursor/mcp.json` file (`~/.cursor/mcp.json` for every project): ```json title=".cursor/mcp.json" { "mcpServers": { "couryo": { "url": "https://mcp.couryo.com", "headers": { "Authorization": "Bearer ${env:COURYO_API_KEY}" } } } } ``` Do not commit the key: use the environment variable, as above. ## ChatGPT - **ChatGPT app (connectors):** requires OAuth. It arrives with Couryo's OAuth (coming soon). - **OpenAI API:** works today, with the remote MCP tool and the key header. ```python title="Python (OpenAI API)" import os from openai import OpenAI client = OpenAI() resp = client.responses.create( model="gpt-4.1", tools=[{ "type": "mcp", "server_label": "couryo", "server_url": "https://mcp.couryo.com", "headers": {"Authorization": f"Bearer {os.environ['COURYO_API_KEY']}"}, "require_approval": "always", }], input="Why did email msg_9w2k7c1x0d4e5f6g bounce?", ) print(resp.output_text) ``` ## Try it by hand The server answers JSON-RPC over `POST`. To list the tools: ```bash title="cURL" curl https://mcp.couryo.com \ -H "Authorization: Bearer $COURYO_API_KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }' ``` Without a key, or with an invalid one, the answer is `401` with the `missing_api_key` or `invalid_api_key` error. ## Coming soon - OAuth for the Claude and ChatGPT connectors, with no key to paste. - More tools: delivery diagnosis by provider, DNS setup, suppressions, templates and received replies. Every action that changes something will have a test mode and ask for confirmation. ## Docs for agents - [llms.txt](https://couryo.com/en/llms.txt) and [llms-full.txt](https://couryo.com/en/llms-full.txt) in English, with Portuguese versions at [/llms.txt](https://couryo.com/llms.txt). - Every page of this guide as Markdown: add `.md` to the URL, as in [/en/docs/sending.md](https://couryo.com/en/docs/sending.md.md). - Pricing as Markdown at [/pricing.md](https://couryo.com/pricing.md). To have your coding agent write the integration, point it at `llms-full.txt`: ```text title="Prompt" Read https://couryo.com/en/llms-full.txt and integrate order confirmation emails with the Couryo API, using Idempotency-Key and verifying webhook signatures. ``` --- # Couryo pricing > Transactional and product email API. Priced by email volume, no per-seat fees. Monthly prices. Page: https://couryo.com/en/pricing Sign up: https://app.couryo.com ## Plans | Plan | Price in Brazil (BRL) | Price outside Brazil (USD) | Emails per month included | Overage per 1,000 emails | |---|---|---|---|---| | Free | R$ 0 | $0 | 3,000 | none (sending stops at the limit) | | Pro | R$ 99 | $19 | 50,000 | R$ 1,80 / $0.35 | | Scale | R$ 299 | $59 | 200,000 | R$ 1,40 / $0.28 | | Enterprise | custom | custom | custom | custom | ### Free For testing and small projects. Forever. - 3,000 emails per month (up to 100 per day) - 1 domain - 7 days of logs - REST API and webhooks (SMTP coming soon) - MCP for AI agents - Every bounce explained in plain words - Permanent, and written into the terms of use ### Pro For products in production. - 50,000 emails per month included - Up to 10 domains - 30 days of logs - Unlimited users, no per-seat fees - Spending limit for overage - Inbound: receive email by webhook (coming soon) - Human support ### Scale For high volume and larger teams. - 200,000 emails per month included - Everything in Pro - Up to 50 domains - 90 days of logs - Event-based sequences (coming soon) - Priority support ### Enterprise For teams that need a contract and guarantees. - Custom volume - Unlimited domains - Dedicated IP - SSO - Contractual SLA - Extended log retention - Dedicated DPA ## Add-ons - Dedicated IP - Extra log retention ## Pricing commitments - **No per-seat fees.** Invite your whole team. Price depends on email volume only. - **Price locked for 12 months.** If prices change, existing customers keep their current price for 12 months, with 6 months of notice. - **Free forever, in writing.** The 3,000 emails per month of the Free plan are in the terms of use, not in a promotion. - **Cancel in one click.** No lock-in, no retention calls. Annual plans get a prorated refund. - **Spending limit.** You set how much overage you accept. At the cap, sending stops and nothing is charged above it. - **A late payment never stops critical email.** Password, login and billing emails keep going out while an invoice is sorted out. ## Payment methods - International credit card, billed in US dollars - Customers in Brazil pay in reais (card, boleto or Pix) and get a local service invoice (NFS-e) ## How to estimate Pick the plan whose price plus overage is lowest for your monthly volume. Overage is charged per started block of 1,000 emails and respects the account spending limit. Above 1 million emails per month, talk to us.