# Sending email Source: https://docs.koltrix.com/sending-email Koltrix sends mail for your application in two ways, through the same pipeline (one suppression list, one signing setup, one message log, the same webhooks): 1. **The REST API**: `POST /api/v2/emails`. The right choice for new code. 2. **The [SMTP relay](https://docs.koltrix.com/smtp-relay.md)**: for software that already speaks SMTP. This page covers the REST API. ## Before you send - **An API key with the `send` scope.** See [Authentication](https://docs.koltrix.com/authentication.md). - **A registered From address.** The `from` address must be an active address on a domain that is verified in your workspace. Add the domain under **Settings → Domains & addresses**, publish its [DNS records](https://docs.koltrix.com/domains.md), then click **Add address** on the domain and add the exact mailbox you will send from, for example `hello@acme.com`. ## `POST /api/v2/emails` **POST** `https://api.koltrix.com/api/v2/emails` **cURL** ```bash curl https://api.koltrix.com/api/v2/emails \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042-receipt" \ -d '{ "from": "Acme ", "to": ["lead@example.com"], "subject": "Your receipt for order 1042", "body_html": "

Thanks!

Your order is on its way.

", "body_text": "Thanks! Your order is on its way." }' ``` ### Request body | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | `string` | Yes | `hello@acme.com` or `Acme `. The address must be an active address on a verified domain in this workspace. | | `to` | `string[]` | Yes | A JSON **array**, even for one recipient. At least one address. | | `cc` | `string[]` | No | Additional recipients. See the note on recipients below. | | `bcc` | `string[]` | No | Additional recipients. See the note on recipients below. | | `subject` | `string` | Yes | The subject line. Non-ASCII characters are encoded for you. | | `body_html` | `string` | No | The HTML body. A fragment is fine; it is wrapped in a full HTML document. | | `body_text` | `string` | No | The plain-text body. If you send only `body_html`, a text version is generated from it. | Send at least one of `body_html` and `body_text`. Fields that are not in this table are ignored. In particular there is no `reply_to`, `attachments`, `headers`, `template` or scheduled send time on this endpoint today. `to` recipients appear in the `To:` header and `cc` recipients in `Cc:`. `bcc` recipients receive the message but never appear in any header. A message with only `bcc` recipients is sent with `To: undisclosed-recipients:;`. ### Headers | Header | Notes | | --- | --- | | `Authorization` | Required. `Bearer kx_…`, a key with the `send` scope. | | `Content-Type` | `application/json`. | | `Idempotency-Key` | Recommended. Any string that identifies this logical send. See [Retrying safely](#retrying-safely). | ### Response `202 Accepted` once the message is queued: ```json { "id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "status": "queued", "queued": true, "tracking_url": "/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "recipient_count": 1 } ``` | Field | Notes | | --- | --- | | `id` | The message id of the **first** recipient. Koltrix keeps one message record per recipient; list the others with `GET /api/v2/messages?recipient=…`. | | `status` | Always `queued` in this response. | | `queued` | Always `true` in this response. | | `tracking_url` | The path to poll for this message's status. | | `recipient_count` | `to` + `cc` + `bcc`. Every recipient counts against your send quota. | Delivery happens a moment later in the background. Follow it by polling [`GET /api/v2/messages/:id`](#checking-a-message) or, better, with [webhooks](https://docs.koltrix.com/webhooks.md). ### Errors | Status | Body | Meaning | | --- | --- | --- | | `400` | `{"error": "invalid body"}` | The body is not valid JSON, or a field has the wrong type (for example `to` as a string instead of an array). | | `400` | `{"error": "from, to, subject are required"}` | One of the three required fields is missing or empty. | | `400` | `{"error": "from address is not a valid email"}` | `from` has no usable address in it. | | `401` | `{"error": "invalid or revoked api key"}` | See [Authentication](https://docs.koltrix.com/authentication.md#errors). | | `403` | `{"error": "missing permission: send"}` | The key has no `send` scope. | | `403` | `{"error": "from address '…' is not registered for this account. …"}` | The From address isn't an active address in this workspace. The message ends by saying whether the domain is verified, so you know whether to add the address or verify the domain first. | | `409` | `{"error": "a request with this Idempotency-Key is still in progress"}` | A retry arrived while the first request was still running. | | `429` | `{"code": "quota_exceeded", …}` | Your plan's send quota is used up. See [Limits and quotas](https://docs.koltrix.com/limits.md). | | `429` | `{"error": "rate limit exceeded — 60 requests per minute per API key"}` | Too many requests. Wait for `Retry-After` seconds. | | `503` | `{"error": "idempotency store unavailable, please retry"}` | Nothing was sent. Retry with the same key. | Every error is listed with what to do about it in [Errors](https://docs.koltrix.com/errors.md). ## Retrying safely Networks fail after a request has been sent and before the answer arrives. Send an `Idempotency-Key` and a retry can never produce a second email. - Use one key per **logical** send, and reuse it on every retry: an order id plus the message type (`order-1042-receipt`) or a UUID you stored with the job. Never generate a new key inside the retry loop. - Keys are scoped to your workspace and remembered for **24 hours**. - The key is claimed before anything is queued, so even two requests racing each other send one message. | Situation | Response | | --- | --- | | First request with this key | `202` with the queued message | | Retry after the first one finished | `200` with the **original** body and the header `Idempotent-Replayed: true` | | Retry while the first one is still running | `409`: wait a moment and retry with the same key | | Idempotency store unreachable | `503`: nothing was sent, retry with the same key | A request that fails before anything is queued (a validation error, a quota refusal) releases the key, so you can fix the problem and retry with the same key straight away. Koltrix does not compare request bodies. A second request with a used key gets the first request's response, even if its body is different. A replay counts against the request [rate limit](https://docs.koltrix.com/limits.md#rate-limits) but never against your send quota. ## What happens after you send 1. **Suppressed recipients are skipped.** An address on your [suppression list](https://docs.koltrix.com/deliverability.md#the-suppression-list) is not mailed. 2. **The message is signed** with your domain's DKIM key and handed to Koltrix's outbound mail servers. 3. **An open-tracking pixel** is added to the HTML part. 4. **A copy appears in your team inbox**, in the Sent folder, so teammates can see what your application sent. 5. **Replies come back to your From address**, which is a real mailbox in your team inbox, threaded with the message. See [Receiving replies](https://docs.koltrix.com/replies.md). > **Current limitation** > > Links in mail sent through the API are not rewritten > for click tracking, so `click_count` stays at 0 and `message.clicked` does not > fire for these messages. Open tracking works. Merge variables such as `{{first_name}}` are **not** substituted on this endpoint; the body is sent exactly as you wrote it. Build the final text in your application. ## Message status | `status` | Meaning | | --- | --- | | `queued` | Accepted and waiting for the background worker. Usually under a second. | | `sent` | Accepted by Koltrix's outbound mail server for delivery to the recipient's provider. | | `bounced` | Rejected. Either the handoff failed, or the recipient's provider sent back a permanent bounce later. A permanent bounce adds the address to your suppression list. | Opens, clicks and replies don't change the status. They are counted in `open_count`, `click_count` and `reply_count`, with the time of the first one in `opened_at`, `clicked_at` and `replied_at`. ## Checking a message ```bash curl https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20 \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` ```json { "id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "from": "hello@acme.com", "to": "lead@example.com", "subject": "Your receipt for order 1042", "status": "sent", "open_count": 2, "click_count": 0, "reply_count": 1, "engagement_score": 12, "first_open_after_seconds": 423, "message_id": "0b9e6f4c-8a1d-4f2e-b7c3-5d6e7f8a9b0c@acme.com", "error": null, "created_at": "2026-10-02T14:00:00Z", "sent_at": "2026-10-02T14:00:01Z", "opened_at": "2026-10-02T14:07:04Z", "clicked_at": null, "replied_at": "2026-10-02T15:12:40Z", "bounced_at": null } ``` - `message_id` is the `Message-ID` header of the sent mail, without the angle brackets. - `error` holds the reason when `status` is `bounced`. - `engagement_score` adds 1 per open, 5 per click and 10 per reply. - `first_open_after_seconds` is how long after sending the first open came. A few seconds usually means a mail provider fetched the images automatically, not that a person read it. Requires the `read` or `send` scope. A message reads `queued` until it has been handed to the receiving server, usually within a second or two; poll again, or use webhooks instead of polling. For every open, click and reply with its time, IP address and user agent, call `GET /api/v2/messages/:id/events`. Opens that look like automatic image fetching are recorded with `"prefetch": true` and are not counted in `open_count`. Both endpoints are in the [API reference](https://docs.koltrix.com/api-reference.md). ## Code samples **cURL** ```bash curl https://api.koltrix.com/api/v2/emails \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1042-receipt" \ -d '{ "from": "hello@acme.com", "to": ["lead@example.com"], "subject": "Your receipt", "body_html": "

Thanks for your order.

" }' ``` **Node.js** ```ts // Node 18+: fetch is built in. const res = await fetch("https://api.koltrix.com/api/v2/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "order-1042-receipt", }, body: JSON.stringify({ from: "hello@acme.com", to: ["lead@example.com"], subject: "Your receipt", body_html: "

Thanks for your order.

", }), }); if (!res.ok) throw new Error(`Koltrix ${res.status}: ${await res.text()}`); const { id } = await res.json(); ``` **Python** ```python res = requests.post( "https://api.koltrix.com/api/v2/emails", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", "Idempotency-Key": "order-1042-receipt", }, json={ "from": "hello@acme.com", "to": ["lead@example.com"], "subject": "Your receipt", "body_html": "

Thanks for your order.

", }, timeout=10, ) res.raise_for_status() message_id = res.json()["id"] ``` **Go** ```go package main "bytes" "encoding/json" "fmt" "net/http" "os" ) func main() { body, _ := json.Marshal(map[string]any{ "from": "hello@acme.com", "to": []string{"lead@example.com"}, "subject": "Your receipt", "body_html": "

Thanks for your order.

", }) req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/emails", bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", "order-1042-receipt") resp, err := http.DefaultClient.Do(req) if err != nil { panic(err) // safe to retry with the same Idempotency-Key } defer resp.Body.Close() var out struct { ID string `json:"id"` } _ = json.NewDecoder(resp.Body).Decode(&out) fmt.Println(resp.StatusCode, out.ID) } ``` **PHP** ```php true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", "Idempotency-Key: order-1042-receipt", ], CURLOPT_POSTFIELDS => json_encode([ "from" => "hello@acme.com", "to" => ["lead@example.com"], "subject" => "Your receipt", "body_html" => "

Thanks for your order.

", ]), ]); $response = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); ``` There are no official SDKs yet. The API is small enough that a few lines like these are all you need. ## REST or SMTP? | Use the REST API when… | Use the SMTP relay when… | | --- | --- | | You are writing new code | Your software can only send by SMTP | | You want `Idempotency-Key` retries | You can't change the code, only its settings | | You want the message id back straight away | A third-party tool asks for SMTP credentials | | You want JSON errors that say what to fix | | The relay has its own rules and limits; read [SMTP relay](https://docs.koltrix.com/smtp-relay.md) before you use it.