# Koltrix documentation > Koltrix is email for SaaS teams on your own domain: a transactional email REST API (POST /api/v2/emails), an SMTP relay, signed webhooks, a team inbox for replies, and an MCP server so Claude, ChatGPT and Cursor can read, organise and draft mail. This file is every page of https://docs.koltrix.com, in sidebar order, as Markdown, followed by the API reference for every endpoint. Index: https://docs.koltrix.com/llms.txt. Any single page: add `.md` to its URL. --- # Koltrix developer docs Source: https://docs.koltrix.com **Email for your product, sent from your own domain.** Send transactional email through a REST API or SMTP, get delivery events by webhook, and read the replies in a shared team inbox. **Build with AI.** Copy one prompt into Claude Code, Cursor or ChatGPT. It reads these docs, asks you a few questions and writes the integration. See [Build with AI](https://docs.koltrix.com/ai.md). ## Start here 1. **Add your domain** Five DNS records, about ten minutes of work. [Domains and DNS](https://docs.koltrix.com/domains.md) 2. **Create an API key** Under **Settings → API keys**, shown once. [Authentication](https://docs.koltrix.com/authentication.md) 3. **Send an email** One `POST` to `/api/v2/emails`. [Sending email](https://docs.koltrix.com/sending-email.md) The [Quickstart](https://docs.koltrix.com/quickstart.md) walks through all three, with the commands to copy. ## Explore - [Sending email](https://docs.koltrix.com/sending-email.md): The send API in depth: retries, statuses, errors. - [SMTP relay](https://docs.koltrix.com/smtp-relay.md): Point any SMTP library or app at Koltrix. - [Webhooks](https://docs.koltrix.com/webhooks.md): Sent, bounced, opened and clicked events, signed. - [API reference](https://docs.koltrix.com/api-reference.md): Every endpoint, with a live Try it. - [Replies](https://docs.koltrix.com/replies.md): Answers to your mail land in the team inbox. - [Connect an assistant](https://docs.koltrix.com/mcp.md): Let Claude or ChatGPT read, sort and draft mail. ## What Koltrix doesn't do - **No SMS or push notifications.** Email only. - **Koltrix's own AI never sends, forwards or deletes mail for you.** Its triage, summaries and drafted replies only suggest; every message is sent by a person clicking Send in Koltrix. An assistant you connect over the [AI assistants connection](https://docs.koltrix.com/mcp.md) can read, sort and draft, and can send only if an admin turns that on, you opt in, and you confirm each message in the assistant. It can never forward or delete. - **No sending through other providers.** Koltrix delivers from its own mail servers. - **No compliance certifications yet.** Koltrix is not SOC 2 or HIPAA certified. If you need that, email us before you sign up. ## Help Email **support@koltrix.com**; we answer within one business day. Common problems and their fixes are in [Troubleshooting](https://docs.koltrix.com/troubleshooting.md), and what changed recently is in the [Changelog](https://docs.koltrix.com/changelog.md). --- # Quickstart Source: https://docs.koltrix.com/quickstart This takes you from a new account to an email delivered through the API. Allow about fifteen minutes; most of it is waiting for DNS. 1. Create a workspace. 2. Add your domain and an address on it. 3. Create an API key. 4. Send an email. 1. **Create a workspace** Sign up at [app.koltrix.com](https://app.koltrix.com) and name your workspace. You are its owner; invite teammates later from **Settings → Team**. New workspaces start on a trial with tighter limits. They are listed under [Limits and quotas](https://docs.koltrix.com/limits.md#during-the-trial). 2. **Add your domain and an address** Open **Settings → Domains & addresses** and click **Add domain**. Enter the domain you want to send from, such as `acme.com`. Koltrix shows five DNS records to add at your DNS provider, each with a copy button: | Record | Name | What it does | | --- | --- | --- | | Ownership (TXT) | `_koltrix` | Proves you control the domain. | | MX | `@` | Delivers your domain's mail, including replies, to Koltrix. | | SPF (TXT) | `@` | Lets Koltrix send for the domain. If you already have an SPF record, Koltrix shows a merged one to replace it with. | | DKIM (TXT) | `kx1._domainkey` | Signs your mail so it can't be forged. | | DMARC (TXT) | `_dmarc` | Tells receivers what to do with forgeries. | [Custom domains and DNS](https://docs.koltrix.com/domains.md) explains each record, with notes for Cloudflare, GoDaddy, Namecheap and Route 53. Click **Check again** after you have saved them. DNS usually takes a few minutes and sometimes a few hours. The domain becomes **verified** when all five records check out. Then click **Add address** on the domain and add the mailbox you will send from, such as `hello@acme.com`. The API only sends from addresses added this way; any other From address is refused, even on a verified domain. 3. **Create an API key** Open **Settings → API keys** and click **Create key**. Name it (for example "Local development") and keep the default scopes, `read` and `send`. The key starts with `kx_` and is **shown once**. Copy it now: ```bash export KOLTRIX_API_KEY="kx_..." ``` Check it works: ```bash curl https://api.koltrix.com/api/v2/me -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` 4. **Send an email** ```bash curl https://api.koltrix.com/api/v2/emails \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: quickstart-1" \ -d '{ "from": "hello@acme.com", "to": ["you@your-personal-inbox.com"], "subject": "Hello from Koltrix", "body_html": "

It works

Sent with the Koltrix API.

" }' ``` Koltrix answers `202 Accepted`: ```json { "id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "status": "queued", "queued": true, "tracking_url": "/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "recipient_count": 1 } ``` The email arrives a few seconds later. A copy is in your Koltrix Sent folder, and reply to it from your personal inbox to see the reply arrive in Koltrix. Check its status: ```bash curl https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20 \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` `status` moves from `queued` to `sent`, and `open_count` goes up when you open it. (Right after sending it may still show `queued`; check again in a second.) The same request in Node, Python, Go and PHP is in [Sending email](https://docs.koltrix.com/sending-email.md#code-samples). ## If it didn't arrive | What you see | What to do | | --- | --- | | `403` "from address … is not registered" | Add that exact address to the domain (step 2). The message says whether the domain itself is verified yet. | | `401` | The key is wrong or revoked. Check you copied all of it, including `kx_`. | | `status` is `bounced` | The `error` field has the receiving server's reason. A typo in the address is the usual cause. | | `status` is `sent` but nothing arrived | Look in your spam folder, then check the domain's records in Settings: an unverified DKIM record is the usual cause. | More in [Troubleshooting](https://docs.koltrix.com/troubleshooting.md). ## Next - [Webhooks](https://docs.koltrix.com/webhooks.md): hear about sends, bounces and opens without polling. - [SMTP relay](https://docs.koltrix.com/smtp-relay.md): for software that only speaks SMTP. - [Receiving replies](https://docs.koltrix.com/replies.md): what happens when customers write back. - [AI assistants](https://docs.koltrix.com/mcp.md): connect Claude, ChatGPT or Cursor to your inbox. --- # Authentication Source: https://docs.koltrix.com/authentication Your code authenticates to Koltrix with an **API key**. The same key works for the REST API (`https://api.koltrix.com/api/v2`) and, with the `send` scope, as the password for the [SMTP relay](https://docs.koltrix.com/smtp-relay.md). | Surface | How it authenticates | | --- | --- | | REST API, `/api/v2/*` | `Authorization: Bearer kx_…` | | SMTP relay, port 2525 | `AUTH PLAIN` or `AUTH LOGIN`, the API key as the password | | AI assistants (MCP) | OAuth sign-in with your Koltrix account, never an API key. See [AI assistants](https://docs.koltrix.com/mcp.md). | | The Koltrix app | Your account sign-in, in the browser | An API key belongs to a **workspace**, not to a person. Anyone holding it can act as that workspace within the key's scopes, so keep keys on your servers and never put one in a browser or a mobile app. ## Create a key Owners and admins create keys under **Settings → API keys** (also linked from **Developers**). Give the key a name you'll recognise later, such as "Production web" or "Billing worker", and choose its scopes. - The key looks like `kx_` followed by 48 hexadecimal characters. - **It is shown exactly once.** Copy it into your secret store straight away. Koltrix stores only a hash and cannot show it again. - The list shows each key's first characters, its scopes and when it was last used, so you can tell keys apart and spot unused ones. ```bash export KOLTRIX_API_KEY="kx_..." ``` ## Scopes A key may carry several scopes. Give each key the fewest it needs. New keys start with `read` and `send`. | Scope | What it allows | | --- | --- | | `send` | Send email with `POST /api/v2/emails` and through the SMTP relay. Also reads back your messages. | | `read` | Read your message log and message events, and read lists, subscribers and sequences. | | `newsletter` | Create, change and delete lists, subscribers, broadcasts and sequences. | | `contacts` | Reserved. No `/api/v2` endpoint uses it today. | | `*` | Full access: every scope, now and in future. Use it sparingly. | Scopes are fixed when the key is created. To change them, create a new key and revoke the old one. ### Which scope each endpoint needs | Endpoint | Scope | | --- | --- | | `GET /api/v2/me` | Any valid key | | `POST /api/v2/emails` | `send` | | `GET /api/v2/messages`, `GET /api/v2/messages/:id`, `GET /api/v2/messages/:id/events` | `read` or `send` | | `GET` on lists, subscribers, sequences and sequence steps | `read` or `newsletter` | | Every other list, subscriber, broadcast and sequence endpoint | `newsletter` | | SMTP relay | `send` | ## Make an authenticated request `GET /api/v2/me` tells you which workspace a key belongs to and what it may do. It is a good first call and a good health check. ```bash curl https://api.koltrix.com/api/v2/me \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` ```json { "tenant": "acme", "permissions": ["read", "send"] } ``` `tenant` is your workspace's short name. ## Errors | Status | Body | What to do | | --- | --- | --- | | `401` | `{"error": "missing Bearer token"}` | Send the header as `Authorization: Bearer kx_…`, with the word `Bearer` and a space. | | `401` | `{"error": "invalid api key format"}` | The token doesn't start with `kx_`. Check you copied the whole key. | | `401` | `{"error": "invalid or revoked api key"}` | The key doesn't exist or was revoked. Create a new one. | | `403` | `{"error": "missing permission: send"}` | The key is valid but lacks the scope. The message names it (`send`, `read` or `newsletter`). | Endpoints that accept `read` **or** `newsletter` name only `newsletter` in their `403` message, and endpoints that accept `read` or `send` name `read`. ## Rotate a key Keys don't expire. To rotate one: 1. Create a new key with the same scopes. 2. Deploy it everywhere the old key is used, including SMTP settings. 3. Revoke the old key under **Settings → API keys**. Revoking takes effect on the next request: the old key gets `401` on the API and `535` on the SMTP relay. A request that was already accepted finishes normally. Rotate straight away if a key is ever committed to a repository, pasted in a chat or printed in a log, and otherwise on a schedule that suits you. ## Rate limits Each API key may make **60 requests per minute** to `/api/v2`. Over that, the API answers `429` with a `Retry-After` header. This limit is separate from your plan's send quota; both are explained in [Limits and quotas](https://docs.koltrix.com/limits.md#rate-limits). ## Calling the API from a browser The API answers cross-origin requests from any website (without cookies), so a browser can call it. Don't do that from your own pages: a key in a web page is a key anyone can copy. Call the API from your server. The **Try it** panels in the [API reference](https://docs.koltrix.com/api-reference.md) are the one exception. They run in your browser on docs.koltrix.com and send the key you paste straight to `api.koltrix.com`, nowhere else, and keep it only in that browser tab. Use a dedicated key with the fewest permissions and revoke it afterwards. --- # 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. --- # SMTP relay Source: https://docs.koltrix.com/smtp-relay Point software that already speaks SMTP at Koltrix and authenticate with an API key. Messages accepted by the relay go through the same pipeline as the [REST API](https://docs.koltrix.com/sending-email.md): the same suppression list, signing, message log, send quota and webhooks. If you are writing new code, use the REST API instead. It returns the message id, supports idempotent retries and tells you in JSON what went wrong. ## Connection settings | Setting | Value | | --- | --- | | Host | `smtp.koltrix.com` | | Port | `2525` | | Encryption | `STARTTLS` (TLS 1.2 or later, certificate for `smtp.koltrix.com`). Connect, send `EHLO`, upgrade with `STARTTLS`, then authenticate. Most libraries do this automatically. | | Authentication | `AUTH PLAIN` or `AUTH LOGIN` | | Username | `apikey` (any value is accepted) | | Password | An API key with the `send` scope | | Message size | 25 MB per message | | Recipients | 1,000 per message | | Connection time | A connection is closed after 5 minutes | Ports `587` and `465` are **not** the relay and don't accept API keys. > **Authenticate after STARTTLS** > > The relay still accepts `AUTH` on an unencrypted connection so existing integrations keep working, but that will be switched off. Always upgrade with `STARTTLS` before you log in: your API key is a password. ## What the relay reads from your message The relay is built for transactional mail with a simple body. It takes: | From your SMTP session | Used as | | --- | --- | | `MAIL FROM` (the envelope sender) | The **From address** of the sent message. It must be an active address on a verified domain in **your** workspace, exactly as for the [REST API](https://docs.koltrix.com/sending-email.md#before-you-send); anything else is refused with `553 5.7.1 Sender not authorized`. | | Each `RCPT TO` | A recipient. Recipients named in your message's `To:` or `Cc:` header appear there; any other `RCPT TO` is delivered as a Bcc and never shown in the headers. | | The `Subject:` header | The subject. | | The body of a single-part `text/plain` or `text/html` message | The message body. An HTML body also gets a plain-text version generated from it. | The `To:` and `Cc:` headers only decide how each `RCPT TO` recipient is shown. Everything else in the message is ignored: the `From:`, `Bcc:` and `Reply-To:` headers, display names and any custom headers. Put every recipient in `RCPT TO`. > **Current limitation** > > The relay does not parse multipart messages, so a > message with both a text and an HTML part, or with attachments, arrives > garbled. Send a single-part message: most libraries do that when you give > them only `text` or only `html` and no attachments. > **Current limitation** > > Mail sent through the relay has no open or click > tracking, and the relay doesn't return a message id. Find the message in > **Settings → Logs** or with `GET /api/v2/messages?recipient=…`. ## Examples ### swaks The quickest way to check that a key works: ```bash swaks --server smtp.koltrix.com --port 2525 --tls \ --auth PLAIN --auth-user apikey --auth-password "$KOLTRIX_API_KEY" \ --from hello@acme.com --to lead@example.com \ --header "Subject: Hello via SMTP" \ --body "It works." ``` The last reply should be `250 2.0.0 OK: queued`. ### Node.js (nodemailer) **Node.js** ```ts const transport = nodemailer.createTransport({ host: "smtp.koltrix.com", port: 2525, secure: false, auth: { user: "apikey", pass: process.env.KOLTRIX_API_KEY }, }); await transport.sendMail({ from: "hello@acme.com", to: "lead@example.com", subject: "Hello via SMTP", html: "

It works.

", // one body only: html or text, not both }); ``` ### Python (smtplib) **Python** ```python from email.message import EmailMessage msg = EmailMessage() msg["From"] = "hello@acme.com" msg["To"] = "lead@example.com" msg["Subject"] = "Hello via SMTP" msg.set_content("

It works.

", subtype="html") # a single HTML part with smtplib.SMTP("smtp.koltrix.com", 2525) as s: s.starttls() s.login("apikey", os.environ["KOLTRIX_API_KEY"]) s.send_message(msg) ``` ### Go (net/smtp) `smtp.SendMail` upgrades to STARTTLS on its own, and `smtp.PlainAuth` then sends the key inside the encrypted connection: **Go** ```go package main "net/smtp" "os" ) func main() { msg := []byte("From: hello@acme.com\r\n" + "To: lead@example.com\r\n" + "Subject: Hello via SMTP\r\n" + "Content-Type: text/html; charset=UTF-8\r\n" + "\r\n" + "

It works.

\r\n") auth := smtp.PlainAuth("", "apikey", os.Getenv("KOLTRIX_API_KEY"), "smtp.koltrix.com") err := smtp.SendMail("smtp.koltrix.com:2525", auth, "hello@acme.com", []string{"lead@example.com"}, msg) if err != nil { panic(err) } } ``` ### PHP (PHPMailer) **PHP** ```php isSMTP(); $mail->Host = "smtp.koltrix.com"; $mail->Port = 2525; $mail->SMTPAuth = true; $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Username = "apikey"; $mail->Password = getenv("KOLTRIX_API_KEY"); $mail->setFrom("hello@acme.com"); $mail->addAddress("lead@example.com"); $mail->Subject = "Hello via SMTP"; $mail->isHTML(true); $mail->Body = "

It works.

"; // leave AltBody empty: one part only $mail->send(); ``` ## A session, line by line ```text S: 220 koltrix-smtp ready C: EHLO app.acme.com S: 250-koltrix-smtp S: 250-AUTH PLAIN LOGIN S: 250-8BITMIME S: 250-SIZE 26214400 S: 250 HELP C: AUTH PLAIN AGFwaWtleQBreF8uLi4= S: 235 2.7.0 Authentication successful C: MAIL FROM: S: 250 2.1.0 OK C: RCPT TO: S: 250 2.1.5 OK C: DATA S: 354 End data with . C: Subject: Hello via SMTP C: Content-Type: text/plain; charset=UTF-8 C: C: It works. C: . S: 250 2.0.0 OK: queued C: QUIT S: 221 2.0.0 Bye ``` The `AUTH PLAIN` argument is the base64 of a null byte, the username, a null byte and the API key. The relay understands `EHLO`, `HELO`, `AUTH`, `MAIL`, `RCPT`, `DATA`, `RSET`, `NOOP` and `QUIT`. ## Reply codes | Code | When | What to do | | --- | --- | --- | | `235 2.7.0` | Authentication succeeded. | | | `250` | Command accepted, or `250 2.0.0 OK: queued` after `DATA`. | | | `451 4.3.0 Queue failure, try again` | Koltrix couldn't queue the message. | Retry later; your client normally does. | | `452 4.5.3 Too many recipients` | More than 1,000 `RCPT TO` in one message. | Send the rest in another message. | | `452 4.5.3 …sending limit…` | Your plan's send quota is used up (at `RCPT` or `DATA`). | Temporary on purpose: your client keeps retrying while you upgrade or the quota resets. See [Limits](https://docs.koltrix.com/limits.md). | | `553 5.7.1` | The `MAIL FROM` address is not an active address on a verified domain in your workspace. | Send from one of your workspace's addresses (Settings → Domains). | | `501 5.5.4 Invalid FROM address` / `Invalid TO address` | The address couldn't be read. | Wrap it in angle brackets: `MAIL FROM:`. | | `501 5.5.2 Cannot decode credentials` | The `AUTH PLAIN` argument isn't valid base64. | | | `502 5.5.2` | Unknown command. | | | `503 5.5.1` | Commands out of order (`RCPT` before `MAIL`, `DATA` before `RCPT`). | | | `504 5.5.4` | An authentication method other than PLAIN or LOGIN. | | | `530 5.7.0 Authentication required` | `MAIL FROM` before `AUTH`. | | | `535 5.7.8 Authentication failed (…)` | The password isn't a `kx_` key, the key is invalid or revoked, or it lacks the `send` scope. The text in brackets says which. | | | `552 5.3.4 Message size exceeds fixed limit` | The message is over 25 MB. | Permanent; retrying the same message won't help. | | `554 5.6.0 Data read error` | The connection broke during `DATA`. | | ## Common mistakes - **Using port 587 or 465.** Those ports don't accept API keys. Use `2525`. - **Requiring TLS.** Many libraries can be told to use STARTTLS "if available"; that works. One that *requires* it can't connect to the relay. - **Expecting the `From:` header to be used.** The relay sends from the envelope sender. Most libraries set both to the same address; check yours does. - **Sending text and HTML together, or attachments.** See the limitation above. - **A key without the `send` scope.** Authentication fails with `535 … key missing 'send' permission`. Create a key with `send`. --- # Custom domains and DNS Source: https://docs.koltrix.com/domains To send and receive mail on your own domain, Koltrix needs five DNS records. **Settings → Domains & addresses** shows the exact values for your domain, each with a copy button and a live check; this page explains what they are and how to add them. It takes about ten minutes of work, then a wait for DNS (usually a few minutes, occasionally a few hours). If your DNS provider allows it, Koltrix can add the records for you instead. ## Automatic setup Open the domain in **Settings → Domains & addresses** (or the DNS step of onboarding). If one of these options is available for your domain, it appears above the records. The records stay below in every case, so you can always add them yourself. ### Set up automatically with your DNS provider If your DNS provider supports [Domain Connect](https://www.domainconnect.org/) and has enabled Koltrix, you see a **Set up automatically with ** button. 1. Press it. You go to your DNS provider and sign in there, as usual. 2. The provider shows the records Koltrix is asking for. Approve them. 3. You come straight back to Koltrix, which checks the records. A few details: - Koltrix only asks for the records that aren't right yet. A DMARC record that already works stays as it is, even a stricter `p=reject`. - An existing SPF record is merged by your provider, not duplicated. - If the domain already receives mail elsewhere, your provider shows you which MX records the new one replaces before you approve. - The link to your provider is signed by Koltrix, so nobody can use it to slip in different records. The button appears on its own as DNS providers enable Koltrix. There is nothing to switch on. ### Connect Cloudflare If your domain's DNS is on Cloudflare and the button above isn't offered, you see **Connect Cloudflare** instead. You give Koltrix a Cloudflare API token that can edit this one domain's DNS, and Koltrix adds the records with it. 1. Press **Connect Cloudflare**, then **Open Cloudflare's token page**. It opens Cloudflare's **Create API token** form with the right permissions already filled in: *Zone · Read* and *DNS · Edit*. 2. Under **Zone Resources**, choose **Include → Specific zone → your domain**. This way the token can't touch any other domain. 3. Press **Continue to summary**, then **Create Token**, and copy the token. 4. Paste it into Koltrix and press **Add the records**. Koltrix then: - adds only what is missing, and leaves records that already match alone; - edits your existing SPF record to include `include:_spf.koltrix.com` rather than adding a second one; - keeps a DMARC record that already works; - asks first if the domain already has other MX records, because replacing them moves your incoming mail to Koltrix; - never deletes anything else. It reports what it changed and runs the check straight away. **Koltrix uses the token once, for that request, and does not store or log it.** You don't need it afterwards, so delete it in Cloudflare under **My Profile → API Tokens**. ## The five records | # | What it does | Type | Name | Value | | - | ------------ | ---- | ---- | ----- | | 1 | Proves you own the domain | TXT | `_koltrix` | `koltrix-verify=` | | 2 | Delivers your mail to Koltrix | MX | `@` | `mail.koltrix.com`, priority `10` | | 3 | Lets Koltrix send for you | TXT | `@` | `v=spf1 include:_spf.koltrix.com -all` | | 4 | Signs your mail so it isn't spoofed | TXT | `kx1._domainkey` | `v=DKIM1; k=rsa; p=` | | 5 | Tells receivers what to do with fakes | TXT | `_dmarc` | `v=DMARC1; p=quarantine` | `@` means the domain itself. The token and the key are different for every domain, so copy them from Settings → Domains & addresses rather than from this page. The domain becomes **verified**, and mail starts to flow, once all five check out. ### 1. Ownership A TXT record at `_koltrix` holding a token made for this domain in this workspace. It proves that whoever added the domain controls its DNS, so nobody else can claim your domain in their workspace. Tokens are random and never reused. Leave the record in place after the domain verifies. ### 2. MX Points incoming mail for your domain at Koltrix. Name `@`, mail server `mail.koltrix.com`, priority `10`. Publishing it moves your incoming mail to Koltrix, so do it when you are ready to switch. Remove any other MX records for the domain at the same time: mail goes to the lowest priority number that answers, and an old record left behind keeps receiving some of it. ### 3. SPF A domain may have **only one** SPF record. Two `v=spf1` records is an error, and receivers then treat SPF as failed for all of them. - **No SPF record yet?** Add `v=spf1 include:_spf.koltrix.com -all`. - **Already have one** (for Google Workspace, a CRM, an invoicing tool)? Koltrix shows you a merged version: your record, with `include:_spf.koltrix.com` added before its `all` term and everything else kept. **Replace** your existing record with it; don't add a second one. For example, ``` v=spf1 include:_spf.google.com ~all ``` becomes ``` v=spf1 include:_spf.google.com include:_spf.koltrix.com ~all ``` The check looks for the exact term `include:_spf.koltrix.com`. Domains set up before it existed use `include:koltrix.com`, which is still accepted. SPF allows ten DNS lookups per record and each `include:` costs at least one, so a record with many providers in it can run out. ### 4. DKIM Koltrix generates a 2048-bit key for each domain, used by that domain alone, and signs every message on the mail server. You publish the public half as a TXT record at `kx1._domainkey`. The value is long: about 400 characters. Use the copy button rather than selecting it by hand, and paste it in one go. A single TXT string holds at most 255 characters, so **some DNS providers split a long value into several quoted pieces** when you save it (`"v=DKIM1; k=rsa; p=MIIB…" "…IDAQAB"`). That is fine: receivers join the pieces back together, and the check does too. What matters is that no character is missing and nothing extra (a space, a line break) was added in between. ### 5. DMARC Tells receivers what to do with mail that claims to be from your domain but fails SPF and DKIM. Koltrix suggests `v=DMARC1; p=quarantine`: fakes go to spam. - **Already have a DMARC record?** Keep it. Any valid `v=DMARC1` record passes, and you should have only one. - `p=none` passes too, with a note: it enforces nothing, and **sender logos (BIMI) need `p=quarantine` or `p=reject`**. - `p=reject` is the strongest setting: fakes are refused outright. The suggested record has no `rua=` reporting address, because aggregate reports are only useful if something reads them. If you use a DMARC report service, add its `rua=` address to the record; Koltrix accepts it either way. ## Adding them at your DNS provider Providers disagree about the **Name** field. Most add your domain to the end for you, so you type only the part in front of it. Settings → Domains shows both the short name and the whole name (`kx1._domainkey.example.com`) with a copy button for each. | Provider | Name for the domain itself | Name for the others | Notes | | -------- | -------------------------- | ------------------- | ----- | | Cloudflare | `@` | `_koltrix`, `kx1._domainkey`, `_dmarc` (without your domain) | The orange-cloud proxy only applies to A, AAAA and CNAME records, so it has nothing to do with these: TXT and MX are always DNS only. | | GoDaddy | `@` | the short name | GoDaddy adds the domain itself. Typing the whole name gives you `kx1._domainkey.example.com.example.com`. | | Namecheap | `@` | the short name | Under **Advanced DNS**. To add the MX record, set **Mail Settings** to **Custom MX** first. | | Amazon Route 53 | leave blank | the short name | Put TXT values in double quotes. Route 53 refuses a string over 255 characters, so split the DKIM value into two quoted pieces in the same record: `"first 255 characters" "the rest"`. | | Anything else | `@` or blank | the short name, or the whole name if it asks for one | If the saved record shows your domain twice, remove the second copy. | ## Checking Press **Check again** in Settings → Domains & addresses after you save the records. Each record shows where it stands: | Status | Meaning | | ------ | ------- | | **Verified** | Found, with the right value. | | **Waiting** | Not found yet. DNS can take a few minutes, sometimes a few hours. | | **Problem** | Found, but not right. The card says what was found instead, and what to change. | | **Recommended** | Not required: a new record for a domain that already works (see below). | Nothing about a record you have not added yet is harmful. A domain that is waiting for DNS just doesn't send or receive until it verifies. ## Upgrading an existing domain Domains verified before these five records existed **keep working exactly as they do today**: they stay verified, mail keeps flowing, and outbound mail stays signed. Nothing is broken and there is no deadline. Settings → Domains & addresses shows them as verified, with a **Security upgrade** banner listing only the records they don't have yet, usually: - the **ownership** record at `_koltrix`, and - the **DKIM** record at `kx1._domainkey`, which gives the domain its own signing key instead of the shared one. Add them when convenient and press **Check again**. As soon as the `kx1._domainkey` record checks out, Koltrix starts signing the domain's mail with its own key; you don't have to do anything else. Leave the old `koltrix._domainkey` record in place: mail sent before the switch was signed with it, and receivers may still check it. Your existing `include:koltrix.com` SPF record keeps working; changing it to `include:_spf.koltrix.com` is optional. ## If the domain is claimed by another workspace The same domain can be added to more than one workspace while it is waiting for DNS, so that someone who adds your domain first can't block you. Only one workspace can verify it: the first one whose own ownership token appears in DNS. If Settings → Domains & addresses says **Claimed elsewhere**, the domain is verified in another workspace. If that is a workspace of yours, use the domain there. If the domain is yours but the workspace isn't, add this workspace's ownership record and check again, and contact support. If the domain isn't yours, remove it. More fixes for a domain that won't verify are in [Troubleshooting](https://docs.koltrix.com/troubleshooting.md#domain-wont-verify). --- # Deliverability and suppressions Source: https://docs.koltrix.com/deliverability Whether your mail reaches the inbox depends on two things: your domain's DNS, and not mailing people who don't want your mail. Koltrix handles most of both; this page says what it does and what is left to you. ## Your domain Publish the five records in [Custom domains and DNS](https://docs.koltrix.com/domains.md). They prove the mail is really from you: - **SPF** says Koltrix's servers may send for your domain. - **DKIM** signs every message. Koltrix gives each domain its own 2048-bit key and signs outbound mail on its servers; you publish only the public half and never handle the private key. - **DMARC** tells receiving providers to treat unsigned mail claiming to be from you as suspect. A domain sends only once all five records check out. ## The suppression list The suppression list is the set of addresses your workspace will not mail. It applies to **everything** the workspace sends: the API, the SMTP relay, broadcasts, sequences and the Koltrix app. A send to a suppressed address is skipped silently; the rest of the recipients still get the message. An address is added automatically when: | Reason | How it happens | | --- | --- | | `bounce` | The recipient's provider permanently rejected a message (a `5xx` reply, or a bounce report that comes back later with a `5.x.x` status). Temporary failures (`4xx`) never suppress. | | `unsubscribe` | The person clicked the unsubscribe link in a broadcast or sequence, was unsubscribed through the API, or [replied asking to be removed](https://docs.koltrix.com/replies.md#unsubscribe-by-reply). | You can see and edit the list under **Settings → Blocked senders**, in the section **We won't email these**. Add an address there to stop mailing it, or remove one to mail it again. Before you remove a bounced address, check that the reason has gone away. If the mailbox still doesn't exist, the next message bounces and the address is suppressed again, and repeated bounces hurt your domain's reputation. ## Unsubscribe links Broadcasts and sequence emails get an unsubscribe link in the body and a `List-Unsubscribe` header, which lets mail providers show their own "Unsubscribe" button. Clicking either unsubscribes the person from the list and adds them to the suppression list. Transactional mail sent with the API or the SMTP relay doesn't get an unsubscribe link: receipts and password resets are mail people asked for. If you send something closer to marketing through the API, add your own link. ## Tracking | | Opens | Clicks | | --- | --- | --- | | REST API | Yes: a 1×1 image is added to the HTML part | No | | SMTP relay | No | No | | Broadcasts | Yes | Yes: links are rewritten through Koltrix | | Sequences | No | No | Opens are counted only when a person seems to have opened the message. Mail providers that fetch every image as soon as a message arrives (Apple Mail Privacy Protection, Gmail's image proxy and corporate scanners among them) are recorded in the message's events with `"prefetch": true` but not counted. Even so, treat open rates as a rough signal. Clicks and, above all, [replies](https://docs.koltrix.com/replies.md) are the reliable ones. ## New domains A new domain has no sending history, and receiving providers are wary of a domain that suddenly sends thousands of messages. Koltrix paces broadcasts, sequences and app sends from a new domain with a daily cap that rises over its first weeks. A broadcast that would go over today's cap is paused rather than sent in part, and a sequence step waits for the next day. You can help: - Start with mail people expect: receipts, sign-in links, replies to people who wrote to you. - Send broadcasts to recent, engaged subscribers first. - Use double opt-in for any list people can join from a public form. ## Checking a message - **Settings → Logs** shows every outbound message with its status, and the receiving server's reply for anything that bounced. - [mail-tester.com](https://www.mail-tester.com/) scores a message you send to it and names anything misconfigured. Aim for 9 or 10 out of 10. - If one provider puts your mail in spam and the others don't, look at that provider's postmaster tools (Google Postmaster Tools, Microsoft SNDS). --- # Newsletters Source: https://docs.koltrix.com/newsletters > **API only for now** > > Newsletters has no dashboard screen in this release. Everything on this page works over the API exactly as documented, and existing integrations are unaffected — we do not change or remove a /api/v2 endpoint when a screen is hidden. The UI returns in a later release. A **list** is a group of people you send the same email to. You add **subscribers** to it and send a **broadcast**: one message, delivered to every active subscriber as their own copy, with an unsubscribe link. For timed series, such as a welcome series, see [Sequences](https://docs.koltrix.com/sequences.md). Broadcasts use the same pipeline as everything else you send, so they share your [suppression list](https://docs.koltrix.com/deliverability.md#the-suppression-list): someone who unsubscribed or bounced is never mailed again by any list. Every endpoint is in the [API reference](https://docs.koltrix.com/api-reference.md#group-lists). Reading needs a key with the `read` or `newsletter` scope; anything that changes data needs `newsletter`. ## Lists ```bash curl https://api.koltrix.com/api/v2/lists \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com" }' ``` ```json { "id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com", "reply_to": "", "subscriber_count": 0, "created_at": "2026-10-02T10:11:12Z", "updated_at": "2026-10-02T10:11:12Z" } ``` `from_name` and `from_address` are the default sender for broadcasts and sequences on the list. Use an active address on a verified domain, as for any send. `subscriber_count` counts active subscribers. | Action | Request | | --- | --- | | List all lists | `GET /api/v2/lists` | | Get one | `GET /api/v2/lists/:id` | | Change fields | `PATCH /api/v2/lists/:id` with only the fields to change | | Delete, with its subscribers | `DELETE /api/v2/lists/:id` (returns `204`) | ## Subscribers ### Add one ```bash curl https://api.koltrix.com/api/v2/lists/$LIST_ID/subscribers \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "name": "Ada Lovelace", "tags": ["beta"] }' ``` The response is `201` with the subscriber, including its `id` and `status`. - Adding an email that is already on the list updates the name instead of failing. Someone who unsubscribed **stays** unsubscribed. - A new active subscriber is enrolled in the list's [sequences](https://docs.koltrix.com/sequences.md). | `status` | Meaning | | --- | --- | | `active` | Receives broadcasts and sequences. | | `pending` | Waiting to confirm a double opt-in. | | `unsubscribed` | Opted out. Also on your suppression list. | Lists created through the API don't require double opt-in, so subscribers you add are `active` straight away. Only add people who asked to hear from you. > **Current limitation** > > On a list that was set to require double opt-in in an > earlier version of the app, subscribers added through the API start as > `pending`, and Koltrix doesn't email them a confirmation link. ### Import many Send a CSV file's contents as a string: ```bash curl https://api.koltrix.com/api/v2/lists/$LIST_ID/subscribers/import \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "csv": "email,name,tags\nada@example.com,Ada Lovelace,beta;vip\nlinus@example.com,Linus,beta" }' ``` ```json { "imported": 2, "skipped": 0, "errors": null } ``` - A header row with `email`, `name` (or `first_name` or `full_name`) and `tags` columns is recognised. Without one, the first column is the email. - Separate tags with semicolons. - Rows with an invalid email are skipped and counted in `skipped`. - Every imported row becomes `active`, even on a list that requires double opt-in. Addresses on your suppression list are still never mailed. - Importing doesn't enrol anyone in sequences. Add people one at a time if they should start a sequence. ### List, unsubscribe, delete ```bash # Active subscribers, 100 at a time curl "https://api.koltrix.com/api/v2/lists/$LIST_ID/subscribers?status=active&limit=100&offset=0" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" # Unsubscribe: also stops their sequences and suppresses the address curl -X POST https://api.koltrix.com/api/v2/lists/$LIST_ID/subscribers/$SUB_ID/unsubscribe \ -H "Authorization: Bearer $KOLTRIX_API_KEY" # Delete the record from the list curl -X DELETE https://api.koltrix.com/api/v2/lists/$LIST_ID/subscribers/$SUB_ID \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` The list response is `{"data": [...], "total": 1284}`. Unsubscribing answers `{"status": "unsubscribed"}`; deleting answers `204`. Prefer unsubscribing: deleting the record doesn't add the address to your suppression list. ## Broadcasts ```bash curl https://api.koltrix.com/api/v2/lists/$LIST_ID/broadcast \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "subject": "What shipped in October", "body_html": "

Hi {{first_name|there}},

This month we shipped…

", "preheader": "Faster search and a new inbox" }' ``` ```json { "campaign_id": "1d2c3b4a-5f6e-4d7c-8b9a-0f1e2d3c4b5a", "status": "queued", "message": "Broadcast enqueued — recipients will be fanned out by the worker." } ``` **Body fields** | Name | Required | Description | | --- | --- | --- | | `subject` | Yes | Sent as written; merge variables aren't filled in here. | | `body_html` | Yes | The HTML body. | | `body_text` | No | Generated from the HTML when omitted. | | `preheader` | No | The preview text most inboxes show after the subject. | | `from_name, from_address` | No | Default to the list's. `from_address` is required if the list has none. | Each active subscriber gets their own copy with: - an unsubscribe link at the end and a `List-Unsubscribe` header, so mail providers can show their own Unsubscribe button, - open tracking, and links rewritten for click tracking, - the merge variables below filled in. A broadcast from a new domain is paced by its daily sending cap; one that would go over today's cap is paused. See [Deliverability](https://docs.koltrix.com/deliverability.md#new-domains). Each copy appears in your message log (`GET /api/v2/messages`) and fires [webhooks](https://docs.koltrix.com/webhooks.md) like any other send. ## Merge variables These are filled in per recipient in broadcast **bodies**: | Variable | Becomes | | --- | --- | | `{{name}}` | The subscriber's name, or nothing. | | `{{first_name}}` | The first word of the name, or nothing. | | `{{first_name\|there}}` | The first name, or the fallback after the bar (here, "there"). | | `{{email}}` | The subscriber's address. | | `{{unsubscribe_url}}` | Their personal unsubscribe link. | In HTML bodies, names are HTML-escaped, so a subscriber can't inject markup into your email. A variable Koltrix doesn't recognise is left exactly as written. Merge variables work in broadcasts and [sequences](https://docs.koltrix.com/sequences.md), but **not** in `POST /api/v2/emails`, which sends your text unchanged. ## Signup forms Embedded signup forms need the Newsletters screen to give you a form's address, and that screen isn't available in this release. Until it returns, add people from your own backend with `POST /api/v2/lists/:id/subscribers` after they sign up on your site. --- # Sequences Source: https://docs.koltrix.com/sequences > **API only for now** > > Sequences has no dashboard screen in this release. Everything on this page works over the API exactly as documented, and existing integrations are unaffected — we do not change or remove a /api/v2 endpoint when a screen is hidden. The UI returns in a later release. A **sequence** is a series of emails sent to each new subscriber of a [list](https://docs.koltrix.com/newsletters.md), on a schedule that starts when they join: a welcome email straight away, a setup guide a day later, a check-in three days after that. Koltrix schedules every step for every subscriber; you don't run a cron job. Every endpoint is in the [API reference](https://docs.koltrix.com/api-reference.md#group-sequences). Reading needs `read` or `newsletter`; changes need `newsletter`. ## Create a sequence ```bash curl https://api.koltrix.com/api/v2/sequences \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome series", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d" }' ``` ```json { "id": "3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "name": "Welcome series", "trigger": "subscribe", "active": true, "step_count": 0, "created_at": "2026-10-02T12:00:00Z", "updated_at": "2026-10-02T12:00:00Z" } ``` `trigger` is `subscribe`, the only trigger: a person is enrolled when they become an active subscriber of the list. New sequences are active. ## Add steps Each step has a `position` (starting at 0), a `delay_hours` counted from the previous step (from enrolment for step 0), a `subject` and a body. ```bash curl https://api.koltrix.com/api/v2/sequences/$SEQ_ID/steps \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "position": 0, "delay_hours": 0, "subject": "Welcome to Acme", "body_html": "

Hi {{first_name|there}}, glad you are here.

" }' curl https://api.koltrix.com/api/v2/sequences/$SEQ_ID/steps \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "position": 1, "delay_hours": 24, "subject": "Your five-minute setup", "body_html": "

Here is the setup guide we promised.

" }' curl https://api.koltrix.com/api/v2/sequences/$SEQ_ID/steps \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "position": 2, "delay_hours": 72, "subject": "How is it going?", "body_html": "

Anything I can help with? Just reply.

" }' ``` Each delay counts from the step before it, so this sends at 0 hours, 24 hours and 96 hours after someone joins. In step bodies, `{{email}}` and `{{unsubscribe_url}}` are filled in. The name variables are always empty in sequences, so give `{{first_name}}` a fallback, as above. The subject is sent as written. ## Inspect and delete | Action | Request | | --- | --- | | List sequences | `GET /api/v2/sequences` | | List a sequence's steps | `GET /api/v2/sequences/:id/steps` | | Delete a step | `DELETE /api/v2/sequences/:id/steps/:step_id` | | Delete a sequence and its steps | `DELETE /api/v2/sequences/:id` | Deletes answer `204`. Steps can't be edited: delete one and add a new one in its place. ## How a subscriber moves through it When someone becomes an active subscriber, by `POST /api/v2/lists/:id/subscribers` or by confirming a double opt-in, Koltrix enrols them in every active sequence on the list and schedules step 0. After each step it schedules the next, until the last. Before each step Koltrix checks: 1. **Still subscribed?** Someone who unsubscribed is skipped, and their enrolment ends. 2. **On the suppression list?** Then nothing is sent and the enrolment ends. 3. **Does the list have a From address?** If not, the enrolment is paused until you set `from_address` on the list. 4. **Within today's sending cap for a new domain?** If not, the step waits until tomorrow. Each step goes out with an unsubscribe link and a `List-Unsubscribe` header. Unsubscribing at any point stops the rest of the sequence. Subscribers added by CSV import aren't enrolled, and nor are people who were already on the list when you created the sequence. > **Current limitation** > > Sequence emails aren't recorded in the message log > and don't fire webhooks, and they have no open or click tracking. ## Limits Delays are whole hours. For "send in 30 seconds", use `POST /api/v2/emails`. --- # Receiving replies Source: https://docs.koltrix.com/replies Most transactional email is sent from `noreply@` and every answer is thrown away. Koltrix works the other way round: a reply to a receipt, a password reset or a shipping notice comes back to your **team inbox**, where someone can answer it, and it is counted on the message it replied to. You don't have to switch anything on. ## Mail sent with the API or the SMTP relay The From address of every API and SMTP send is a real mailbox in your workspace (that is why it has to be [registered](https://docs.koltrix.com/sending-email.md#before-you-send)). So when someone presses Reply: 1. The reply goes to your From address and arrives in your team inbox, in that mailbox, for whoever has access to it. 2. It is threaded with the message you sent, which is in the mailbox's Sent folder. 3. Koltrix matches the reply's `In-Reply-To` and `References` headers to the message and records it: `reply_count` goes up, `replied_at` is set the first time, `engagement_score` goes up by 10, and a `reply` event is added to the message's timeline. ```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", "to": "lead@example.com", "subject": "Your receipt for order 1042", "status": "sent", "reply_count": 1, "replied_at": "2026-10-02T15:12:40Z", "engagement_score": 12 } ``` (Abridged; the full response is in [Sending email](https://docs.koltrix.com/sending-email.md#checking-a-message).) A reply is the most reliable engagement signal there is. An open can be triggered by a mail provider fetching images, and a click by a link scanner; a reply needs a person. If replies should go somewhere else, such as an existing helpdesk, send from an address whose replies you forward there, or set up **Settings → Forwarding** for that mailbox. ## Mail sent from the Koltrix app Messages your team sends from the Koltrix app also carry a **signed reply address** in their `Reply-To` header: ```text Reply-To: reply+.@ ``` The signature is a keyed hash that only your workspace can produce. Without it, anyone could guess addresses and attach mail to another conversation. A reply with a missing or wrong signature is delivered as ordinary mail rather than threaded, and a valid address from one workspace can't thread a reply into another. The address uses plus-addressing on your existing domain, so there is no extra mailbox and no extra DNS record. A reply to that address is threaded under the original message and counted on it, and is always filed in the inbox, never in spam: you wrote to this person first. > **Current limitation** > > API and SMTP sends don't carry the signed reply > address yet. Their replies are matched by the `In-Reply-To` and `References` > headers instead, which almost every mail program sets, and > unsubscribe-by-reply (below) doesn't apply to them. ### Unsubscribe by reply If a reply to a signed reply address says little more than "unsubscribe", "take me off this list" or "stop emailing me", the sender is added to your [suppression list](https://docs.koltrix.com/deliverability.md#the-suppression-list) with the reason `unsubscribe`. Two kinds of reply are deliberately **not** treated as a request: - **Quoted text.** Almost every newsletter footer contains the word "unsubscribe", so the quoted part of a reply is ignored. - **A real message that mentions unsubscribing.** "Can you check whether we were unsubscribed by mistake?" is a question for a person. Only a short reply, under 200 characters once the quote is removed, that says essentially nothing else counts. Someone who can't find your unsubscribe link reaches for "Report spam" instead, and a spam complaint costs your domain's reputation far more than one lost subscriber. ## If replies don't arrive - **Check the domain's MX record.** Replies reach Koltrix only if your domain's MX record points to `mail.koltrix.com`. See [Custom domains and DNS](https://docs.koltrix.com/domains.md). - **Check who can see the mailbox.** A reply lands in the mailbox it was sent to. Teammates without access to that mailbox won't see it. - **Check Spam.** A reply that isn't matched to a message you sent goes through the normal spam filter like any other mail. Still stuck? Email support@koltrix.com with the message id. --- # Webhooks Source: https://docs.koltrix.com/webhooks A webhook is an HTTPS address of yours that Koltrix calls when something happens to the mail you send: it was handed off, it bounced, it was opened, a link in it was clicked. Use webhooks to update your own records without polling the API. Every request is signed with a secret that only you and Koltrix know, so you can check it really came from Koltrix. ## Add an endpoint Owners and admins add endpoints under **Settings → Webhooks** (also linked from **Developers**). Paste an `https://` URL and click **Add**. The endpoint starts receiving every `message.*` event straight away. Each endpoint has its own **signing secret**, a string that starts with `whsec_`. Store it with your handler's other secrets; you need it to [verify](#verify-the-signature) that a request came from Koltrix. Koltrix records every delivery attempt with the status code your server returned and the first 2 KB of its response. > **Current limitation** > > Settings → Webhooks doesn't yet show an endpoint's > signing secret, let you choose its events, pause it, send a test event or > list its deliveries. Until it does, email support@koltrix.com for your > endpoint's signing secret. To stop deliveries, remove the endpoint. ## Events | Event | When it is sent | | --- | --- | | `message.sent` | Koltrix's outbound mail server accepted the message for delivery. | | `message.bounced` | The message was rejected when Koltrix handed it off. A permanent rejection also adds the address to your [suppression list](https://docs.koltrix.com/deliverability.md#the-suppression-list). | | `message.opened` | The recipient's mail client loaded the open-tracking image. Automatic image fetching by mail providers is filtered out. | | `message.clicked` | The recipient clicked a tracked link. | `message.sent` and `message.bounced` are sent for mail you send with the [REST API](https://docs.koltrix.com/sending-email.md), the [SMTP relay](https://docs.koltrix.com/smtp-relay.md) and broadcasts. `message.opened` and `message.clicked` are sent for any message Koltrix tracks. The event names `message.delivered`, `message.complained` and `message.unsubscribed` are reserved: an endpoint may be subscribed to them, but Koltrix doesn't send them yet. > **Current limitation** > > A bounce that comes back later, as a bounce message > from the recipient's provider, marks the message `bounced` and suppresses the > address, but doesn't send `message.bounced`. Check > `GET /api/v2/messages/:id` if you need to catch those. ## The request Each event is a `POST` with a JSON body: ```http POST /hooks/koltrix HTTP/1.1 Host: app.acme.com Content-Type: application/json User-Agent: Koltrix-Webhook/1.0 X-Koltrix-Event: message.opened X-Koltrix-Signature: sha256=5d41402abc4b2a76b9719d911017c592ae0e1a2c9f5c1c8e0f6f0f1a3b4c5d6e {"event":"message.opened","from":"hello@acme.com","message_id":"6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20","subject":"Your receipt for order 1042","timestamp":1759413600,"to":"lead@example.com"} ``` | Header | Value | | --- | --- | | `X-Koltrix-Event` | The event name, the same as `event` in the body. | | `X-Koltrix-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the raw body, keyed with your signing secret. | | `User-Agent` | `Koltrix-Webhook/1.0` | ### Payloads Every body has `event` and `timestamp` (Unix seconds, when the event was sent). `message_id` is the same id the API returns for the message. **message.sent** ```json { "event": "message.sent", "timestamp": 1759413601, "message_id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "from": "hello@acme.com", "subject": "Your receipt for order 1042" } ``` **message.bounced** ```json { "event": "message.bounced", "timestamp": 1759413601, "message_id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "from": "hello@acme.com", "subject": "Your receipt for order 1042", "error": "550 5.1.1 : Recipient address rejected" } ``` **message.opened and message.clicked** ```json { "event": "message.opened", "timestamp": 1759413600, "message_id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "to": "lead@example.com", "from": "hello@acme.com", "subject": "Your receipt for order 1042" } ``` `message.sent` and `message.bounced` don't include the recipient; look it up by `message_id` with `GET /api/v2/messages/:id` if you need it. `message.clicked` doesn't include the link; the message's event timeline (`GET /api/v2/messages/:id/events`) has it. A test event is a `message.sent` event with `"test": true`, a `message_id` that starts with `test_` and placeholder addresses. Ignore events with `"test": true` in production code. ## Verify the signature Compute HMAC-SHA256 over the **raw request body** with your signing secret, hex-encode it, put `sha256=` in front and compare it with `X-Koltrix-Signature` in constant time. Reject the request if they differ. The `timestamp` is inside the signed body, so it can't be changed without breaking the signature. Rejecting events more than a few minutes old stops an old request from being replayed at you. **Node.js (Express)** ```ts const app = express(); // express.raw keeps the exact bytes; express.json would re-serialise them. app.post("/hooks/koltrix", express.raw({ type: "application/json" }), (req, res) => { const expected = "sha256=" + crypto.createHmac("sha256", process.env.KOLTRIX_WEBHOOK_SECRET!).update(req.body).digest("hex"); const given = req.get("X-Koltrix-Signature") ?? ""; const ok = given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)); if (!ok) return res.status(401).send("bad signature"); const event = JSON.parse(req.body.toString("utf8")); if (Math.abs(Date.now() / 1000 - event.timestamp) > 300) return res.status(400).send("stale"); res.sendStatus(200); // answer first, then do the work handle(event); }); ``` **Python (Flask)** ```python from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["KOLTRIX_WEBHOOK_SECRET"].encode() @app.post("/hooks/koltrix") def koltrix_hook(): raw = request.get_data() # the exact bytes, before any JSON parsing expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, request.headers.get("X-Koltrix-Signature", "")): abort(401) event = json.loads(raw) if abs(time.time() - event["timestamp"]) > 300: abort(400) handle(event) return "", 200 ``` **Go** ```go func koltrixHook(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { http.Error(w, "read", http.StatusBadRequest) return } mac := hmac.New(sha256.New, []byte(os.Getenv("KOLTRIX_WEBHOOK_SECRET"))) mac.Write(raw) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Koltrix-Signature"))) { http.Error(w, "bad signature", http.StatusUnauthorized) return } var event struct { Event string `json:"event"` Timestamp int64 `json:"timestamp"` MessageID string `json:"message_id"` } if json.Unmarshal(raw, &event) != nil || time.Since(time.Unix(event.Timestamp, 0)).Abs() > 5*time.Minute { http.Error(w, "stale or malformed", http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) go handle(event.Event, event.MessageID) } ``` **PHP** ```php 300) { http_response_code(400); exit; } http_response_code(200); ``` If verification fails for every request, the cause is almost always that the body was parsed and re-serialised before you hashed it. Hash the bytes exactly as they arrived. ## Delivery - Koltrix waits up to **8 seconds** for your endpoint to answer. Any `2xx` status counts as delivered. - Events are sent as they happen, so they can arrive out of order, and an open can arrive before the matching `message.sent`. - Answer quickly (record the event and return `200`) and do slow work afterwards. > **Current limitation** > > Each event is attempted **once**. A timeout or a > non-`2xx` answer is recorded and not retried. > If missing an event matters, reconcile with `GET /api/v2/messages` > periodically. Write your handler so the same event can be processed twice without harm, for example by remembering `message_id` + `event`. That keeps it safe when retries are added. ## Related - [Sending email](https://docs.koltrix.com/sending-email.md): the message ids these events refer to. - [Errors](https://docs.koltrix.com/errors.md): what the API returns when something goes wrong. - [Troubleshooting](https://docs.koltrix.com/troubleshooting.md#webhook-not-arriving): events that don't arrive or don't verify. --- # Build with AI Source: https://docs.koltrix.com/ai Copy a prompt, paste it into Claude Code, Cursor, ChatGPT or any coding assistant, and it reads the Koltrix documentation for you. Each prompt points the assistant at https://docs.koltrix.com/llms-full.txt (all the docs in one Markdown file) and tells it to ask you the few things only you know, like your language and your From address. ## Add Koltrix email sending to my app The assistant asks which language you use, then writes the integration, the retry logic and a test. ```text Add Koltrix email sending to my app. Before you write anything, read the Koltrix documentation: https://docs.koltrix.com/llms-full.txt (the whole docs as one Markdown file; https://docs.koltrix.com/llms.txt is the index, and any docs page is also available as Markdown by adding .md to its URL). Do not guess API fields or behaviour: if the docs don't say it, treat it as unsupported. First ask me which language and framework this project uses, which events should send an email, and which From address to use. Then look through my codebase and follow its conventions (HTTP client, config, error handling, test runner). Requirements: 1. API key. Read it from the environment variable KOLTRIX_API_KEY. Never hardcode it, never log it, never commit it. If the variable is missing, tell me to create a key in Koltrix under Settings → API keys (it is shown only once, and the default scopes read and send are enough) and put it in my secret store or .env file. Add only a placeholder line to .env.example. 2. Send with POST https://api.koltrix.com/api/v2/emails, with the headers Authorization: Bearer $KOLTRIX_API_KEY and Content-Type: application/json. The body has from, to (always a JSON array, even for one recipient), subject, and body_html and/or body_text; cc and bcc are optional arrays. There is no reply_to, attachments, templates or scheduled send on this endpoint, and unknown fields are silently ignored, so do not use them. The success response is 202 with {"id", "status": "queued", ...}. 3. The from address must be an exact address that is added under Settings → Domains & addresses on a domain that is verified in my workspace. Any other From address is refused with 403. If I have no verified domain yet, stop and point me to https://docs.koltrix.com/domains.md. 4. Send an Idempotency-Key header on every send: one stable key per logical email (for example "order-1042-receipt"), reused on every retry. Never generate a new key inside the retry loop. Keys are remembered for 24 hours. A retry after the first request finished returns 200 with the original body and the header Idempotent-Replayed: true. 5. Handle errors by HTTP status. 400, 401, 403 and 404: do not retry, fix the request, key or data. 409 and 503: retry shortly with the same Idempotency-Key. 500: retry with exponential backoff and the same key. 429 means two different things, so look at the code field: without a code it is the rate limit (60 requests per minute per API key), so wait the Retry-After seconds and retry; with "code": "quota_exceeded" it is the plan's send quota, so do NOT retry until resets_at and surface it to a person. 6. Limits to respect: 60 requests per minute per API key. Every recipient (to, cc and bcc) counts against the plan's send quota, and trial workspaces have tighter caps; the numbers are in https://docs.koltrix.com/limits.md. 7. If I want delivery events, add a webhook handler that verifies the X-Koltrix-Signature header (see https://docs.koltrix.com/webhooks.md): sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with the endpoint's signing secret from an environment variable, compared in constant time. 8. Write tests that mock the HTTP call. Check the URL, that the Authorization header comes from KOLTRIX_API_KEY, that the Idempotency-Key is present and is the same on a retry, the body shape, and both 429 branches (rate limit versus quota_exceeded). Never call the real API from tests: Koltrix has no test mode, so every request is real. 9. Finish by telling me how to run it, and offer to send one real email to an address I choose. ``` ## Set up my domain's DNS for Koltrix Walks you through the five records and checks each one with dig. It never invents a value. ```text Set up my domain's DNS for Koltrix. Read https://docs.koltrix.com/domains.md first. Do not invent any record value: the ownership token and the DKIM public key are different for every domain, so the exact values come from Koltrix. Ask me to open Settings → Domains & addresses in Koltrix, click Add domain, enter my domain, and paste the five records it shows (type, name and value) here. If Koltrix offers "Set up automatically" (Domain Connect) or "Connect Cloudflare" for my provider, tell me that is the easiest route and walk me through it instead. The five records, so you can check what I paste: 1. Ownership: TXT at _koltrix, value koltrix-verify=. 2. MX at @ pointing to mail.koltrix.com, priority 10. This moves my incoming mail to Koltrix, so remove other MX records for the domain only when I am ready to switch, and warn me first. 3. SPF: TXT at @. A domain may have only ONE SPF record. If it has none, the value is v=spf1 include:_spf.koltrix.com -all. If it already has one, merge include:_spf.koltrix.com into it (Koltrix shows a merged value) instead of adding a second record. 4. DKIM: TXT at kx1._domainkey, value v=DKIM1; k=rsa; p=. 5. DMARC: TXT at _dmarc, value v=DMARC1; p=quarantine (an existing DMARC record that already works stays as it is). Steps: 1. Ask which provider hosts my DNS (Cloudflare, GoDaddy, Namecheap, Route 53 or other) and give me exact steps for it. 2. Before changing anything, look at what exists with dig, and show me the output: dig +short TXT example.com, dig +short MX example.com, dig +short TXT _dmarc.example.com (replace example.com with my domain). 3. Confirm each record with me before I publish it. Do not change my DNS yourself unless I explicitly tell you to. 4. After I publish them, verify with dig and compare with what Koltrix shows: dig +short TXT _koltrix.example.com, dig +short MX example.com, dig +short TXT example.com, dig +short TXT kx1._domainkey.example.com, dig +short TXT _dmarc.example.com. 5. Tell me to click "Check again" in Koltrix. DNS usually takes a few minutes and sometimes a few hours; the domain becomes verified when all five records check out. 6. Then have me click Add address on the domain and add the exact mailbox I will send from, for example hello@example.com. The API only sends from addresses added this way. If you need more detail than https://docs.koltrix.com/domains.md gives, everything is also in https://docs.koltrix.com/llms-full.txt. ``` ## Receive and verify Koltrix webhooks A signed-webhook handler with the raw-body rule, replay protection and a test. ```text Receive and verify Koltrix webhooks in my app. Before you write anything, read the Koltrix documentation: https://docs.koltrix.com/llms-full.txt (the whole docs as one Markdown file; https://docs.koltrix.com/llms.txt is the index, and any docs page is also available as Markdown by adding .md to its URL). Do not guess API fields or behaviour: if the docs don't say it, treat it as unsupported. The page to follow is https://docs.koltrix.com/webhooks.md. First ask me which language and framework this project uses, and which public HTTPS URL the handler will have. Then add a handler at that route. Requirements: 1. Koltrix sends each event as a POST with a JSON body and the headers X-Koltrix-Event and X-Koltrix-Signature. The events that are sent today are message.sent, message.bounced, message.opened and message.clicked. Do not rely on message.delivered, message.complained or message.unsubscribed: they are reserved and not sent yet. 2. Verify the signature before doing anything else. X-Koltrix-Signature is "sha256=" followed by the hex HMAC-SHA256 of the raw request body, keyed with the endpoint's signing secret (it starts with whsec_). Hash the exact bytes that arrived: never parse and re-serialise the JSON first, and in frameworks that parse JSON by default, capture the raw body. Compare in constant time and answer 401 on a mismatch. 3. Read the signing secret from an environment variable, for example KOLTRIX_WEBHOOK_SECRET. Never hardcode it or log it. Settings → Webhooks does not show the secret yet, so I may need to email support@koltrix.com for it; tell me that if I don't have it. 4. The timestamp field (Unix seconds) is inside the signed body: reject events more than a few minutes old. 5. Koltrix waits up to 8 seconds for an answer and each event is attempted once: a timeout or non-2xx answer is recorded and not retried. Answer 200 quickly and do slow work afterwards. Make processing idempotent (for example remember message_id plus event), and ignore events with "test": true in production. 6. Events can arrive out of order, and an open can arrive before the matching message.sent. message.sent and message.bounced don't include the recipient; look it up with GET /api/v2/messages/:id if needed, and reconcile with GET /api/v2/messages periodically if missing an event matters. 7. Write a test that signs a sample payload with a test secret and checks that a valid signature passes, a modified body fails, and a stale timestamp is rejected. 8. Tell me how to add the endpoint in Koltrix (Settings → Webhooks, paste the https:// URL and click Add). ``` ## Migrate from SendGrid, Resend, Postmark or SES Finds every send in your code, maps it to Koltrix and flags what Koltrix doesn't support. ```text Migrate my email sending from SendGrid, Resend, Postmark or Amazon SES to Koltrix. Before you write anything, read the Koltrix documentation: https://docs.koltrix.com/llms-full.txt (the whole docs as one Markdown file; https://docs.koltrix.com/llms.txt is the index, and any docs page is also available as Markdown by adding .md to its URL). Do not guess API fields or behaviour: if the docs don't say it, treat it as unsupported. First ask me which provider I use today. Then search my codebase for every place that sends email or handles the provider's webhooks (SDK imports, API calls, SMTP settings, environment variable names) and list them for me before you change anything. Koltrix facts you must respect: 1. Sending is POST https://api.koltrix.com/api/v2/emails with Authorization: Bearer $KOLTRIX_API_KEY. The body fields are from, to (array), cc, bcc, subject, body_html and body_text. Nothing else is read. 2. Not supported on that endpoint: reply_to, attachments, custom headers, templates, scheduled sends, categories, tags or metadata, and merge variables (the body is sent exactly as written). Koltrix has no official SDKs; it is plain HTTPS. For each feature my current code uses that Koltrix lacks, tell me and ask what to do before you drop or work around it. If I only need SMTP, the SMTP relay is an alternative (https://docs.koltrix.com/smtp-relay.md), but it does not parse multipart messages or attachments. 3. Each request is one message to one set of recipients. For per-recipient personalisation, build each message in my app and make one API call per recipient. 4. Add an Idempotency-Key to every send (one stable key per logical email, reused on retries). Error handling: 429 without a code is the rate limit (60 requests per minute per key; wait Retry-After); 429 with "code": "quota_exceeded" is the plan quota (do not retry until resets_at); 409, 500 and 503 are retried with the same key; 400, 401, 403 and 404 are not retried. 5. The From address must be an address on a verified domain that is added in Koltrix. Setting up the domain means five DNS records; do not duplicate my existing SPF record, merge into it (https://docs.koltrix.com/domains.md). Pointing MX at Koltrix moves incoming mail, so ask me before touching MX. 6. Webhooks: the events are message.sent, message.bounced, message.opened and message.clicked, signed with X-Koltrix-Signature (https://docs.koltrix.com/webhooks.md). Map my provider's events onto these and tell me which ones have no equivalent. Events are attempted once, with no retries. 7. Read the key from the environment variable KOLTRIX_API_KEY; never hardcode it. Keep the old provider's code behind a switch or a feature flag until a real test email has arrived, then remove it. 8. Write tests with the HTTP call mocked. Koltrix has no test mode, so never call the real API from tests. 9. Finish with a checklist of what I still have to do by hand: verify the domain, create the API key, add the webhook endpoint and switch traffic. ``` ## Connect Koltrix to Claude or ChatGPT with MCP Steps for Claude, Claude Code, ChatGPT and Cursor, with the one command for Claude Code. ```text Connect Koltrix to my AI assistant with MCP. Read https://docs.koltrix.com/mcp.md first. The server URL is https://mcp.koltrix.com/mcp and it uses OAuth sign-in with my Koltrix account. There is no API key for MCP, so do not ask me for one and never put an API key anywhere. Ask me which app I use, then give me only the steps for that app: 1. Claude (claude.ai or Claude Desktop): Settings → Connectors → Add custom connector, name it Koltrix, paste https://mcp.koltrix.com/mcp, click Connect, then sign in to Koltrix, choose the workspace and click Approve. 2. Claude Code: run claude mcp add --transport http koltrix https://mcp.koltrix.com/mcp (add --scope user to make it available in every project), then run /mcp inside Claude Code, choose koltrix and authenticate. The OAuth step opens my browser, so I have to do that part. 3. ChatGPT: Settings → Apps & Connectors (turn on Developer mode under Advanced settings if my plan needs it), create a custom connector named Koltrix, paste https://mcp.koltrix.com/mcp and choose OAuth. Menu names vary between plans. 4. Cursor: add {"mcpServers": {"koltrix": {"url": "https://mcp.koltrix.com/mcp"}}} to ~/.cursor/mcp.json (every project) or .cursor/mcp.json (one project), then sign in when Cursor asks. If you can run shell commands and I use Claude Code, run the command in step 2 for me. Afterwards explain what the connection can do: read, search, organise (labels, archive, read and starred state) and draft mail in my Koltrix inbox. Sending is off by default: a workspace owner or admin has to allow it, I have to opt in when I connect, and the assistant must show me the message and get my explicit yes for each send. No assistant can forward or permanently delete mail. Everything about MCP is on https://docs.koltrix.com/mcp.md, and the whole documentation is in https://docs.koltrix.com/llms-full.txt. ``` ## Add Koltrix to CLAUDE.md, AGENTS.md or Cursor rules A short block that makes your coding assistant read the docs before it writes Koltrix code. ```text ## Koltrix (email API) Koltrix docs for AI: before writing or changing any Koltrix code, read https://docs.koltrix.com/llms-full.txt (the whole documentation as one Markdown file). The index is https://docs.koltrix.com/llms.txt, and any docs page is also available as Markdown by adding .md to its URL, for example https://docs.koltrix.com/sending-email.md. Do not guess fields: if the docs don't say it, it isn't supported. - Send with POST https://api.koltrix.com/api/v2/emails and the header Authorization: Bearer . The key lives in the environment variable KOLTRIX_API_KEY. Never hardcode it, log it or commit it. - Always send an Idempotency-Key header: one stable key per logical email, reused on every retry. - The from address must be an active address on a verified domain (Settings → Domains & addresses), or the API answers 403. - The send endpoint has no reply_to, attachments, templates or scheduled send. Don't assume another provider's fields. - 429 without a code field is the rate limit (60 requests per minute per key): wait Retry-After. 429 with "code": "quota_exceeded" is the plan's send quota: don't retry, tell a person. - Verify webhooks: X-Koltrix-Signature is sha256= plus the hex HMAC-SHA256 of the raw body; compare in constant time. - There is no test mode. Tests must mock HTTP and never call the real API. - To let an assistant read, organise and draft mail in Koltrix, use MCP: https://mcp.koltrix.com/mcp ``` --- # AI assistants (MCP) Source: https://docs.koltrix.com/mcp Koltrix works with the AI assistant you already use. Connect Claude, ChatGPT, Cursor or any other app that supports the **Model Context Protocol (MCP)**, and you can ask it things like: - "What came in overnight that needs a reply from me?" - "Find the thread with Dana about the contract renewal and summarise it." - "Label everything from our accountant *Finance* and archive the receipts." - "Draft a reply to the latest message from Acme saying we'll ship on Friday." The assistant works through your Koltrix account, with exactly the access you have in the app, and only after you approve it. > **Sending is off unless you turn it on** > > By default an assistant can read, search, organise and **draft**. Drafts are > saved to your Drafts folder and wait for you to click Send in Koltrix. A > workspace owner or admin can also [allow assistants to send](#sending-email-from-an-assistant): > then, if you opt in when you connect, the assistant must show you the message > and get your explicit yes before it sends. No assistant can forward or > permanently delete mail. Koltrix's own AI (sorting, summaries, suggested replies and auto-reply drafts inside the app) is separate and unchanged: it never sends without a person clicking Send. ## Server URL ```text https://mcp.koltrix.com/mcp ``` This is the only thing you paste. There is no API key: the first time an app connects, it sends you to Koltrix to sign in and approve it. ## Set it up ### Claude (claude.ai) 1. In Claude, open **Settings → Connectors** and choose **Add custom connector**. 2. Name it `Koltrix` and paste `https://mcp.koltrix.com/mcp` as the URL. 3. Click **Connect**. Claude opens Koltrix: sign in if you need to, choose the workspace, check the permissions and click **Approve**. 4. In a chat, turn Koltrix on from the tools menu and ask about your mail. On Claude Team and Enterprise plans an owner may need to add the connector for the organisation first; everyone then connects their own Koltrix account. ### Claude Desktop Claude Desktop uses the same connectors as claude.ai. Add Koltrix under **Settings → Connectors → Add custom connector** with the URL above, in either app, and sign in when asked. You don't need to edit a configuration file. ### Claude Code ```bash claude mcp add --transport http koltrix https://mcp.koltrix.com/mcp ``` Then run `/mcp` inside Claude Code, choose **koltrix** and authenticate. Your browser opens Koltrix to sign in and approve. Add `--scope user` to the command to make Koltrix available in every project rather than just the current one. ### ChatGPT 1. In ChatGPT, open **Settings → Apps & Connectors**. Under **Advanced settings**, turn on **Developer mode** if your plan requires it for custom connectors. 2. Choose **Create** (or **Add custom connector**), name it `Koltrix`, paste `https://mcp.koltrix.com/mcp` and choose **OAuth** for authentication. 3. Sign in to Koltrix and approve when asked. Koltrix offers the `search` and `fetch` tools that ChatGPT's connectors and deep research expect, alongside the full tool set below. Menu names vary between ChatGPT plans and change from time to time; look for "connectors" or "apps". ### Cursor Add Koltrix to `~/.cursor/mcp.json` (every project) or `.cursor/mcp.json` (one project): **mcp.json** ```json { "mcpServers": { "koltrix": { "url": "https://mcp.koltrix.com/mcp" } } } ``` Open Cursor's MCP settings, find Koltrix and sign in when Cursor asks. ### Other apps Any MCP client that supports **remote servers over Streamable HTTP** with **OAuth** sign-in can connect with the URL above. For an app that can only start local (stdio) servers, bridge to Koltrix with [`mcp-remote`](https://www.npmjs.com/package/mcp-remote): ```json { "mcpServers": { "koltrix": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.koltrix.com/mcp"] } } } ``` ## Signing in and permissions When an app connects for the first time, Koltrix shows an approval page at `app.koltrix.com` with: - the **app's name** and the website it will send you back to, - the **workspace** to connect, if you belong to more than one, - the **permissions** it asks for, in plain words, - if an admin has allowed sending in that workspace, an unticked box, **Also allow sending email**. Click **Approve** to connect or **Deny** to refuse. The connection belongs to you, in that one workspace. To use a second workspace, connect again and choose it. | Permission | What it allows | | --- | --- | | `mail.read` | Search and read your mail, and list your mailboxes and labels. | | `mail.organize` | Add and remove labels, archive, mark read or unread, and star. All of these can be undone in Koltrix. | | `mail.draft` | Save drafts to your Drafts folder for you to review. On its own it can't send them. | | `mail.send` | Send a draft that already exists, after the assistant has shown you the message and you've said yes. See [Sending email from an assistant](#sending-email-from-an-assistant). | Apps ask for the first three by default, and the approval page lists each one. `mail.send` is never part of that default. It appears as its own checkbox, **unchecked**, and only when a workspace owner or admin has turned assistant sending on for the workspace you are connecting. If you don't tick it, the connection can't send, whatever the app asks for. Behind the scenes this is standard OAuth 2.1, so it works with any compliant client without configuration: - The server publishes `https://mcp.koltrix.com/.well-known/oauth-protected-resource` and `https://mcp.koltrix.com/.well-known/oauth-authorization-server`, and points to them from a `401` response. - Apps register themselves automatically (dynamic client registration) and must use PKCE. - An access token lasts an hour and renews itself with a refresh token for up to 30 days of inactivity. Tokens are tied to you, the workspace, the app and the permissions you approved, and Koltrix stores only hashes of them. ## Tools These are the tools an assistant can call. Every tool works only on the mailboxes you can open in the Koltrix app, and returns links back to the message in Koltrix. ### Reading (`mail.read`) | Tool | Inputs | Returns | | --- | --- | --- | | `search` | `query` | Matching threads as `{results: [{id, title, url}]}`. Built for ChatGPT connectors and deep research. | | `fetch` | `id` (from `search`) | One thread as `{id, title, text, url, metadata}`. | | `search_mail` | `query`, and optionally `folder`, `label`, `from`, `unread`, `limit` | Thread summaries: subject, participants, date, snippet, unread state. | | `list_threads` | Optionally `folder` or `category` or `label`, `limit`, `cursor` | A page of thread summaries, newest first, and a cursor for the next page. | | `get_thread` | `thread_id` | Every message in the thread with its from, to, date, subject and text. Attachments are listed by name, not downloaded. | | `list_mailboxes` | None | The mailboxes (addresses) you can open. | | `list_labels` | None | Your workspace's labels. | | `get_inbox_summary` | None | Counts by category and unread, and the conversations waiting for your reply. | ### Organising (`mail.organize`) | Tool | Inputs | Does | | --- | --- | --- | | `apply_label` | `thread_id`, `label` (name or id) | Adds the label to the thread. | | `remove_label` | `thread_id`, `label` (name or id) | Removes the label. | | `archive_thread` | `thread_id` | Moves the thread out of the Inbox to Archive. | | `mark_read` | `thread_id` | Marks the thread read. | | `mark_unread` | `thread_id` | Marks the thread unread. | | `star_thread` | `thread_id` | Stars the thread. | Each of these is reversible in Koltrix and safe to repeat. ### Drafting (`mail.draft`) | Tool | Inputs | Returns | | --- | --- | --- | | `create_draft` | `to`, `subject`, `body_text`, and optionally `cc`, `in_reply_to_thread_id`, `from_mailbox` | `{draft_id, review_url}` | `create_draft` saves the draft in your Drafts folder; with `in_reply_to_thread_id` it is a reply in that thread. `review_url` opens it in Koltrix, where you can edit it and click Send yourself. `create_draft` never sends anything. ### Sending (`mail.send`) Only listed when the connection holds `mail.send` and the workspace allows sending. See [Sending email from an assistant](#sending-email-from-an-assistant). | Tool | Inputs | Returns | | --- | --- | --- | | `send_draft` | `draft_id`, `confirmed`, `confirmation` | `{ok, status, draft_id, from, recipients, sent_url, note}`: the draft is queued to send and shows in Sent. The reply carries no email content. | In an app that shows [interactive cards](#interactive-cards), `send_draft` is not offered. The Send button on the draft card sends instead, through a second tool, `send_draft_from_card`, that the app hides from the assistant. ## Interactive cards Some assistant apps can show a tool's result as a small interactive card in the conversation, using the **MCP Apps** extension to the Model Context Protocol. When your app supports it, Koltrix shows: | Card | Shown for | What you can do | | --- | --- | --- | | **Mailbox picker** | `list_mailboxes` | Pick which mailbox the assistant drafts from. Your choice goes back to the conversation as a message. | | **Conversation list** | `search_mail`, `list_threads`, `get_inbox_summary` | See sender, subject, snippet, time, unread dot and labels, and open a conversation in Koltrix. | | **Conversation** | `get_thread` | Read the messages as plain text, with attachments listed by name and size. | | **Draft review** | `create_draft` | See From, To, Cc, Subject and the whole message, then **Send**, **Edit in Koltrix** or **Discard**. | An app that doesn't support cards gets the same tools with text results, exactly as before. Nothing about the setup changes: connect it as described in [Set it up](#set-it-up). > **Which apps show cards** > > Anthropic documents MCP Apps support for Claude on the web, in Claude Desktop and > on mobile, and OpenAI documents it for ChatGPT. Claude may ask you to allow the > app to display a card the first time. Claude Code shows the text result; other > apps vary. Whether a given app shows cards is decided by the app: Koltrix offers > cards only to an app that says, when it connects, that it supports them. > > - **The app doesn't support cards:** nothing is wrong. The assistant works in > text, and sending uses the confirmation described under [Sending email from > an assistant](#sending-email-from-an-assistant). > - **The app supports cards but you can't see one** (you declined its prompt, an > organisation turned interactive connectors off, or it failed to draw): the > assistant can't send from that chat, because in an app that supports cards the > Send button is the only way. Use **Edit in Koltrix** if you can see the card, > or open the draft from the link the assistant gives you, and send it in > Koltrix. ### Sending from the draft card When sending is allowed (an admin turned it on and you ticked **Also allow sending email** when you connected), the draft card shows a **Send** button. Clicking it is the confirmation. The assistant does not get a send tool in that app: it can write the draft, but only you can send it. - **A real click, inside the card.** The button calls `send_draft_from_card`, a tool the app hides from the assistant. The card holds a one-time token that Koltrix issued together with the draft. The token is delivered to the card and not to the assistant's text, works for 30 minutes, belongs to that draft, that connection and the exact subject, recipients and text you saw, and is used up by the send. If the draft is edited after the card appeared, or the card is older than 30 minutes, **Send** is refused and the card tells you to open the draft in Koltrix. - **Every other check still applies.** The `mail.send` permission, the workspace setting (turning it off blocks the very next click), your access to the From mailbox, the 10-recipient and daily limits, no Bcc or attachments, billing and quota, and a single send per draft. The message goes out immediately and appears in Sent. - **Clear states.** The card shows *Sending…*, then *Sent* with a link to Sent, or the reason it was refused. When sending is off it says **Sending is off — open in Koltrix** and shows **Open in Koltrix** instead of a Send button. - **Discard deletes nothing.** It closes the card and tells the conversation the draft was not sent. The draft stays in your Drafts. No assistant tool can delete mail. ### What a card can and can't do A card is a small page the app shows in a sandbox. Koltrix's cards load nothing from the internet (no fonts, images or scripts), run no network requests of their own, and show everything that came from an email as plain text, so a hostile subject or message can't run code or load a tracking image. They open links only by asking your app, and only to Koltrix. The only tool a card can call is the draft card's `send_draft_from_card`. Cards follow your app's light or dark theme. ## Sending email from an assistant Assistants can read, search, organise and draft out of the box. **Sending is a separate, opt-in capability.** It is off by default and it takes three things before a single message can go: 1. A workspace **owner or admin** turns it on for the workspace. 2. **You** opt in when you connect the assistant, with the `mail.send` permission. 3. **For every message**, the assistant shows you the recipients, subject and full text, and you say yes in the conversation. ### Turn it on 1. **An owner or admin allows it** Open **Settings → AI assistants** and turn on **Allow assistants to send email after you confirm**. It is off for every workspace until someone does this, and turning it off again blocks sending straight away, even for apps that were already allowed. Turn it back on and those apps can send again. 2. **Connect the assistant and opt in** Connect the app as described in [Set it up](#set-it-up). On the approval page, tick **Also allow sending email**. The box is unticked by default and only appears if the workspace allows sending. Then click **Approve**. Apps you connected before sending was turned on can't send. Reconnect them (revoke the old connection first if you like) and tick the box. 3. **Ask it to send** Ask for a draft ("draft a reply to Dana saying we'll ship on Friday"). Review it in the assistant, or open it in your Drafts folder. When you're happy, say so ("yes, send it"). The assistant calls `send_draft`, and the message goes out from the draft's From mailbox and appears in that mailbox's Sent folder like any other message. ### `send_draft` `send_draft` sends a draft that already exists, one you made through `create_draft` or in the Koltrix app, so the exact message is visible in your Drafts folder. There is no tool that sends new text directly, and none that forwards or deletes. **Inputs** | Name | Type | Required | Description | | --- | --- | --- | --- | | `draft_id` | `string` | Yes | The draft to send. It is the `draft_id` that `create_draft` returned. | | `confirmed` | `boolean` | Yes | Must be exactly `true`. Anything else is refused. | | `confirmation` | `string` | Yes | The draft's recipients and subject, in the form `a@x.com, b@y.com \| Subject`: every To address, then every Cc address, comma-separated, then ` \| `, then the subject. The order of the addresses, capitals and extra spaces don't matter, and neither does a `Name ` form. A missing or extra address does, and so does a different subject. | **send_draft arguments** ```json { "draft_id": "d_7c1f0a92", "confirmed": true, "confirmation": "dana@acme.example, sam@acme.example | Shipping on Friday" } ``` Koltrix refuses the call unless `confirmed` is `true` **and** `confirmation` matches the draft's current To and Cc addresses and subject. That forces the assistant to have the real recipients in front of it, and it stops a draft that was edited or swapped after you looked at it from being sent. When it refuses, Koltrix replies with the draft's actual recipients and subject and tells the assistant to show you the message first. **What must be true, checked on every call:** - The connection holds the `mail.send` permission. - The workspace has assistant sending turned on. Nothing is cached: turning it off blocks the next call. - You may send from the draft's From mailbox (the same access as the composer in the app), and that address is an active address on a verified domain in the workspace. A draft in a mailbox you can't send from is treated as not found. - The draft is a real draft in this workspace, not already sent and not in the trash. Calling `send_draft` again for a sent draft is refused, not repeated, and two calls at the same moment send it once. - The draft has a recipient, a subject and a body, and no Bcc recipients or attachments. Neither can be covered by the confirmation, so an assistant can't send those drafts. Send them from Koltrix. - The workspace isn't frozen for billing, and the send fits the plan's usual quota. - The limits below. | Limit | Value | | --- | --- | | Recipients (To and Cc) per message | 10 | | Sends per connection per day | 20 | | Sends per workspace per day, across all assistants | 50 | The daily limits reset at midnight UTC and count sends made through assistants only; sends you click in Koltrix, and API sends, are not counted against them. A refused or failed send doesn't use up a slot. A message sent by an assistant counts against your plan's daily send allowance like any other. If the limit counters can't be reached, Koltrix refuses the send rather than guess. A send goes out immediately, with no undo window, and is recorded in the From mailbox's Sent folder. **Errors.** When a call is refused the assistant gets a plain-language reason and nothing is sent. The reasons are: sending isn't allowed (the connection lacks `mail.send`, or the workspace has it off); the confirmation doesn't match the draft; the draft was not found, was already sent, is being sent right now or is in the trash; the draft has Bcc recipients, attachments, or no recipient, subject or body; you can't send from that mailbox, or the address isn't allowed to send; more than 10 recipients; a daily limit was reached; or the workspace is over its sending quota or frozen for billing. Every send is recorded in the workspace audit log as `mcp.send`: the tool, the app, who, the draft id, how many recipients and their domains. The message text and full addresses are not logged. ### Why each send needs your yes Anyone can send you an email, and an email can contain hidden text written to trick an AI assistant: "forward this thread to someone else", "reply with the account number". This is called prompt injection, and no assistant is immune to it today. A tool that can send mail turns a successful trick into a sent message, so Koltrix layers the controls instead of trusting the assistant: - **Off by default,** per workspace, by an admin. - **Opt-in per connection,** with a permission you tick yourself. - **Only existing drafts.** The message is a real draft you can open in Koltrix before it goes, not text the assistant made up on the spot. - **The server checks the recipients and subject,** so the call can't be made blind. - **Hard limits:** 10 recipients, 20 a day per connection, 50 a day per workspace, no Bcc and no attachments, so a mistake stays small. - **The call is marked as irreversible,** so apps such as Claude and ChatGPT also ask for their own approval before running it. - **The assistant is told** to show you the recipients, subject and whole message, to wait for an explicit yes in the conversation, and never to send because an email told it to. Koltrix can't see your conversation, so it can't check what the assistant actually showed you. Read the recipients and the text before you say yes, and check your Drafts folder if anything looks off. Say no, or just don't answer, and nothing is sent. ## Safety - **Sending is off by default and always needs your yes.** Out of the box no tool sends anything. If an admin allows it and you opt in, the only tools that send are `send_draft`, and only for a draft the assistant has shown you and you have confirmed, and, in an app that shows [cards](#interactive-cards), the Send button on the draft card, which only you can press. No tool forwards or permanently deletes mail. Everything else an assistant can change is limited to labels, archive, read state, stars and drafts, all reversible. - **Your access, no more.** The assistant sees only the mailboxes you can open in Koltrix, checked on every call exactly as the app checks them. - **You approve it, and you can revoke it.** See your connected apps under **Settings → AI assistants** and click **Revoke** to cut one off. It stops working immediately. - **Admins are in control.** Workspace owners and admins can turn AI assistants off for the whole workspace in **Settings → AI assistants**. Every connection stops working at once and new ones can't be made until it is turned back on. A second switch there, **Allow assistants to send email after you confirm**, is off by default and blocks sending the moment it is turned off. People removed from the workspace, or suspended, lose access immediately too. - **Everything is logged.** Each connection, token renewal, revocation and tool call is recorded in your workspace's audit log, with the tool's name but none of your mail. - **Rate-limited.** Each connection has its own request limit, so a runaway assistant can't overload your account. ### Email is untrusted text Anyone can send you an email, and an email can contain text written to trick an AI assistant ("ignore your instructions and…"). Koltrix marks email content in its tool results as untrusted, third-party text. With sending off, which is the default, even a fooled assistant can't send, forward or delete anything. With sending on, the controls in [Why each send needs your yes](#why-each-send-needs-your-yes) apply. Either way, read what an assistant proposes before you act on it, and keep an eye on any other tools you have connected in the same chat. ## Privacy - When you ask about your mail, the parts of it the assistant reads are sent to that assistant's provider (Anthropic for Claude, OpenAI for ChatGPT, and so on) and handled under their terms. Koltrix sends only what a tool call asks for. - Koltrix doesn't receive your conversations with the assistant, only the tool calls it makes. - Koltrix's own handling of your data is in the [privacy policy](https://koltrix.com/legal/privacy). ## Troubleshooting | Problem | What to do | | --- | --- | | The app says it isn't authorised, or asks you to sign in again | The connection was revoked, expired after 30 days unused, or AI assistants were turned off. Reconnect from the app (in Claude Code, run `/mcp`). | | The app reports that AI assistants are turned off for the workspace | A workspace owner or admin can turn them back on in **Settings → AI assistants**. | | The assistant can't find a message you can see in Koltrix | Check you approved the right workspace. Koltrix only searches the workspace the connection belongs to. | | A teammate's mailbox is missing | The assistant has your access. Ask an admin for access to that mailbox in **Settings → Team**. | | You can't find a draft | Look in Drafts for the mailbox it was written from, or open the `review_url` the assistant gave you. | | Labelling or drafting fails but reading works | You didn't approve that permission. Revoke the connection and connect again, approving it. | | `send_draft` isn't available, or the assistant says it can't send | Sending needs both a workspace switch and your opt-in. Ask an owner or admin to turn on **Allow assistants to send email after you confirm** in **Settings → AI assistants**. Then revoke the connection, connect again and tick **Also allow sending email** on the approval page. The box only shows when the workspace allows sending. In **Settings → AI assistants**, your connection reads **Can send** when both are in place. | | Sending was refused because the confirmation doesn't match | The recipients or subject changed after the assistant read the draft, or it left an address out. Ask it to read the draft again and show you the message before it retries. | | Sending was refused because the draft has Bcc recipients or attachments | An assistant can't send those, because the confirmation can't cover them. Open the draft in Koltrix and send it from there. | | No card appears, only text | If your app doesn't support MCP Apps, nothing is wrong: the assistant works in text and sends with `send_draft` and your yes in the conversation. If it does support them but shows no card (you declined the prompt, or your organisation turned interactive connectors off), the assistant can't send from that chat: open the draft in Koltrix from the link it gives you and send it there. In Claude, allow the card when it asks. | | The draft card says it expired, or the draft changed | A card's Send works for 30 minutes, once, for the draft exactly as shown. Open the draft in Koltrix (**Edit in Koltrix**) to review and send it there. | | The draft card says **Sending is off** | The workspace setting or your `mail.send` permission is missing; see [Turn it on](#turn-it-on). The card links to the draft in Koltrix so you can send it there. | | Sending was refused because of a limit | A message can go to at most 10 recipients, a connection can send 20 messages a day and a workspace 50 a day through assistants. The count resets at midnight UTC. Send the rest from Koltrix, or wait. | | The approval page says the redirect address isn't allowed | The app tried to send you back to an address that isn't `https` (or a local address). Update the app, or report it to its maker. | | Connecting from a work network fails | Check that the network lets your app reach `mcp.koltrix.com` and your browser reach `app.koltrix.com`. | Still stuck? Email support@koltrix.com with the app you use and roughly when it failed. --- # API reference Source: https://docs.koltrix.com/api-reference The Koltrix REST API is JSON over HTTPS at `https://api.koltrix.com/api/v2`. Authenticate every request with `Authorization: Bearer kx_…` (an API key; see [Authentication](https://docs.koltrix.com/authentication.md)). Each key may make 60 requests a minute. Errors are JSON with an `error` string; see [Errors](https://docs.koltrix.com/errors.md). `POST /api/v2/emails` accepts an `Idempotency-Key` header so a retry never sends twice. There is no test mode: every request is real. The OpenAPI 3.1 description is at https://docs.koltrix.com/openapi.yaml (JSON: https://docs.koltrix.com/openapi.json). ## Endpoints ### Transactional email Send one email to one or more recipients. Requires the `send` scope. Pass an `Idempotency-Key` header so a retry can never send twice. - [Send an email](https://docs.koltrix.com/api-reference/send-email.md): `POST /api/v2/emails` ### Messages Your outbound message log: every message sent through the API, the SMTP relay and broadcasts, one record per recipient. Requires the `read` or `send` scope. - [List messages](https://docs.koltrix.com/api-reference/list-messages.md): `GET /api/v2/messages` - [Get a message](https://docs.koltrix.com/api-reference/get-message.md): `GET /api/v2/messages/:id` - [List a message's events](https://docs.koltrix.com/api-reference/get-message-events.md): `GET /api/v2/messages/:id/events` ### Your key Check which workspace an API key belongs to and which scopes it has. - [Get the current key](https://docs.koltrix.com/api-reference/get-me.md): `GET /api/v2/me` ### Lists Newsletter audiences, available over the API only for now. Reading needs `read` or `newsletter`; changes need `newsletter`. - [List lists](https://docs.koltrix.com/api-reference/list-lists.md): `GET /api/v2/lists` - [Create a list](https://docs.koltrix.com/api-reference/create-list.md): `POST /api/v2/lists` - [Get a list](https://docs.koltrix.com/api-reference/get-list.md): `GET /api/v2/lists/:id` - [Update a list](https://docs.koltrix.com/api-reference/update-list.md): `PATCH /api/v2/lists/:id` - [Delete a list](https://docs.koltrix.com/api-reference/delete-list.md): `DELETE /api/v2/lists/:id` ### Subscribers The people on a list. Reading needs `read` or `newsletter`; changes need `newsletter`. - [List subscribers](https://docs.koltrix.com/api-reference/list-subscribers.md): `GET /api/v2/lists/:id/subscribers` - [Add a subscriber](https://docs.koltrix.com/api-reference/create-subscriber.md): `POST /api/v2/lists/:id/subscribers` - [Import subscribers from CSV](https://docs.koltrix.com/api-reference/import-subscribers.md): `POST /api/v2/lists/:id/subscribers/import` - [Delete a subscriber](https://docs.koltrix.com/api-reference/delete-subscriber.md): `DELETE /api/v2/lists/:id/subscribers/:sub_id` - [Unsubscribe a subscriber](https://docs.koltrix.com/api-reference/unsubscribe-subscriber.md): `POST /api/v2/lists/:id/subscribers/:sub_id/unsubscribe` ### Broadcasts Send one message to every active subscriber on a list. Requires the `newsletter` scope. - [Send a broadcast](https://docs.koltrix.com/api-reference/send-broadcast.md): `POST /api/v2/lists/:id/broadcast` ### Sequences Timed series of emails sent to people who join a list. Reading needs `read` or `newsletter`; changes need `newsletter`. - [List sequences](https://docs.koltrix.com/api-reference/list-sequences.md): `GET /api/v2/sequences` - [Create a sequence](https://docs.koltrix.com/api-reference/create-sequence.md): `POST /api/v2/sequences` - [Delete a sequence](https://docs.koltrix.com/api-reference/delete-sequence.md): `DELETE /api/v2/sequences/:id` - [List a sequence's steps](https://docs.koltrix.com/api-reference/list-sequence-steps.md): `GET /api/v2/sequences/:id/steps` - [Add a step](https://docs.koltrix.com/api-reference/add-sequence-step.md): `POST /api/v2/sequences/:id/steps` - [Delete a step](https://docs.koltrix.com/api-reference/delete-sequence-step.md): `DELETE /api/v2/sequences/:id/steps/:step_id` --- # Send an email Source: https://docs.koltrix.com/api-reference/send-email **POST** `https://api.koltrix.com/api/v2/emails` Queues the message and returns 202 with the id of the first recipient's message record (one record is kept per recipient). The From address must be an active address on a verified domain in your workspace. Fields not listed here, such as reply_to or attachments, are ignored. `to` recipients appear in the To header and `cc` recipients in Cc; `bcc` recipients receive the message but never appear in any header, and a message with only `bcc` recipients is sent with `To: undisclosed-recipients:;`. A retry with the same Idempotency-Key within 24 hours returns the original response with status 200 and the header Idempotent-Replayed: true. Resource: Transactional email. Requires an API key with the `send` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `from` | body | `string` | Yes | | `hello@acme.com` or `Acme `. Must be an active address on a verified domain in this workspace. | | `to` | body | `array` | Yes | | Array of recipient addresses. Must be an array, even for one recipient. | | `cc` | body | `array` | No | | Array of additional recipients. | | `bcc` | body | `array` | No | | Array of additional recipients. They receive the message but never appear in any header. | | `subject` | body | `string` | Yes | | Subject line. | | `body_html` | body | `string` | No | | HTML body. Send this, `body_text`, or both. | | `body_text` | body | `string` | No | | Plain-text body. Generated from `body_html` when omitted. | | `Idempotency-Key` | header | `string` | No | | Any string identifying this logical send; reuse it on retries. Remembered for 24 hours. The Try it panel generates one per run. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X POST "https://api.koltrix.com/api/v2/emails" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "from": "hello@acme.com", "to": [ "lead@example.com" ], "subject": "Your receipt for order 1042", "body_html": "

Thanks for your order.

", "body_text": "Thanks for your order." }' ``` **Node** ```js import { randomUUID } from "node:crypto"; 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": randomUUID(), }, body: JSON.stringify({ "from": "hello@acme.com", "to": [ "lead@example.com" ], "subject": "Your receipt for order 1042", "body_html": "

Thanks for your order.

", "body_text": "Thanks for your order." }), }); console.log(res.status, await res.json()); ``` **Python** ```python import os, uuid, requests res = requests.post( "https://api.koltrix.com/api/v2/emails", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", "Idempotency-Key": str(uuid.uuid4()), }, json={ "from": "hello@acme.com", "to": [ "lead@example.com" ], "subject": "Your receipt for order 1042", "body_html": "

Thanks for your order.

", "body_text": "Thanks for your order." }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "strings" "fmt" "io" "net/http" "os" "github.com/google/uuid" ) func main() { body := strings.NewReader(`{ "from": "hello@acme.com", "to": [ "lead@example.com" ], "subject": "Your receipt for order 1042", "body_html": "

Thanks for your order.

", "body_text": "Thanks for your order." }`) req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/emails", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) req.Header.Set("Idempotency-Key", uuid.NewString()) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", "Idempotency-Key: " . bin2hex(random_bytes(16)), ], CURLOPT_POSTFIELDS => json_encode([ "from" => "hello@acme.com", "to" => [ "lead@example.com", ], "subject" => "Your receipt for order 1042", "body_html" => "

Thanks for your order.

", "body_text" => "Thanks for your order.", ]), ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 202 Accepted Accepted and 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 } ``` ### 200 OK (replay) A retry with the same Idempotency-Key after the first request finished. The original response, not a second send. Headers: `Idempotent-Replayed: true` ```json { "id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "status": "queued", "queued": true, "tracking_url": "/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "recipient_count": 1 } ``` ### 400 Bad Request A required field is missing or invalid. The text names the problem. ```json { "error": "from, to, subject are required" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the send scope. ```json { "error": "missing permission: send" } ``` ### 403 Forbidden (From address) The From address is not an active address in this workspace. The message says whether the domain is verified. ```json { "error": "from address 'hello@acme.com' is not registered for this account. The domain is verified — add this exact address in Dashboard → Domains." } ``` ### 409 Conflict A retry arrived while the first request with this Idempotency-Key was still running. Wait a moment and retry with the same key. ```json { "error": "a request with this Idempotency-Key is still in progress" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` ### 429 Too Many Requests (quota) Your plan's send quota is used up. Retrying will not help until resets_at; tell a person. ```json { "code": "quota_exceeded", "error": "You've hit today's API sending limit. It resets at midnight UTC, or upgrade to send more now.", "kind": "api_sends", "limit": 200, "used": 200, "window": "day", "resets_at": "2026-10-03T00:00:00Z", "upgrade_url": "https://app.koltrix.com/settings/billing" } ``` ### 503 Service Unavailable Koltrix could not check the Idempotency-Key, so it sent nothing. Retry with the same key. ```json { "error": "idempotency store unavailable, please retry" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # List messages Source: https://docs.koltrix.com/api-reference/list-messages **GET** `https://api.koltrix.com/api/v2/messages` Newest first. `status` is one of `queued`, `sent` or `bounced`. Resource: Messages. Requires an API key with the `read` or `send` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `status` | query | `string` | No | | Only messages with this status: `queued`, `sent` or `bounced`. One of: `queued`, `sent`, `bounced`. | | `recipient` | query | `string` | No | | Only messages whose recipient contains this text (case-insensitive). | | `limit` | query | `number` | No | `100` | Page size. Default 100, maximum 500. | | `offset` | query | `number` | No | | How many messages to skip. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/messages?status=sent&recipient=example.com&limit=50&offset=0" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/messages?status=sent&recipient=example.com&limit=50&offset=0", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/messages?status=sent&recipient=example.com&limit=50&offset=0", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/messages?status=sent&recipient=example.com&limit=50&offset=0", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "data": [ { "id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", "tenant_id": "5c4b3a29-1807-4f6e-9d5c-4b3a29180716", "message_id": "0b9e6f4c-8a1d-4f2e-b7c3-5d6e7f8a9b0c@acme.com", "from": "hello@acme.com", "to": "lead@example.com", "subject": "Your receipt for order 1042", "status": "sent", "open_count": 2, "click_count": 0, "created_at": "2026-10-02T14:00:00Z", "sent_at": "2026-10-02T14:00:01Z", "opened_at": "2026-10-02T14:07:04Z" } ], "total": 1 } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the read scope. ```json { "error": "missing permission: read" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Get a message Source: https://docs.koltrix.com/api-reference/get-message **GET** `https://api.koltrix.com/api/v2/messages/:id` One message with its status, engagement counters and timestamps. `engagement_score` adds 1 per open, 5 per click and 10 per reply. Currently answers 404 while the message is still queued, usually for under a second. Resource: Messages. Requires an API key with the `read` or `send` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The message id returned by `POST /api/v2/emails`. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```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 } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the read scope. ```json { "error": "missing permission: read" } ``` ### 404 Not Found No such record in this workspace. ```json { "error": "message not found" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # List a message's events Source: https://docs.koltrix.com/api-reference/get-message-events **GET** `https://api.koltrix.com/api/v2/messages/:id/events` Every open, click and reply, oldest first. `kind` is `open`, `click` or `reply`; a click also has the `url`. `prefetch: true` marks an open that looks like automatic image fetching by a mail provider; those are not counted in `open_count`. Resource: Messages. Requires an API key with the `read` or `send` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The message id. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20/events" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20/events", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20/events", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20/events", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "data": [ { "kind": "open", "prefetch": true, "ip": "17.58.0.12", "user_agent": "Mozilla/5.0", "created_at": "2026-10-02T14:00:03Z" }, { "kind": "open", "prefetch": false, "ip": "203.0.113.7", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5)", "created_at": "2026-10-02T14:07:04Z" }, { "kind": "reply", "prefetch": false, "user_agent": "inbound-mail", "created_at": "2026-10-02T15:12:40Z" } ], "total": 3 } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the read scope. ```json { "error": "missing permission: read" } ``` ### 404 Not Found No such record in this workspace. ```json { "error": "message not found" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Get the current key Source: https://docs.koltrix.com/api-reference/get-me **GET** `https://api.koltrix.com/api/v2/me` Returns the workspace short name (`tenant`) and the key's scopes. Works with any valid key, so it makes a good health check. Resource: Your key. Works with any valid API key. ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/me" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/me", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/me", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/me", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "tenant": "acme", "permissions": [ "read", "send" ] } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # List lists Source: https://docs.koltrix.com/api-reference/list-lists **GET** `https://api.koltrix.com/api/v2/lists` Every list in the workspace. Resource: Lists. Requires an API key with the `read` or `newsletter` scope. ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/lists" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/lists", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/lists", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "data": [ { "id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com", "reply_to": "", "subscriber_count": 1284, "created_at": "2026-09-12T09:14:22Z", "updated_at": "2026-10-01T08:00:00Z" } ] } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Create a list Source: https://docs.koltrix.com/api-reference/create-list **POST** `https://api.koltrix.com/api/v2/lists` Returns 201 with the new list. `from_address` is the default sender for broadcasts and sequences on the list; use an active address on a verified domain. Resource: Lists. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `name` | body | `string` | Yes | | The list's name. | | `description` | body | `string` | No | | A note for your team. | | `from_name` | body | `string` | No | | Default sender name. | | `from_address` | body | `string` | No | | Default sender address. | | `reply_to` | body | `string` | No | | Stored with the list. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X POST "https://api.koltrix.com/api/v2/lists" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com" }' ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com" }), }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.post( "https://api.koltrix.com/api/v2/lists", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, json={ "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com" }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "strings" "fmt" "io" "net/http" "os" ) func main() { body := strings.NewReader(`{ "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com" }`) req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/lists", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "name" => "Product updates", "description" => "What shipped this month", "from_name" => "Acme", "from_address" => "hello@acme.com", ]), ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 201 Created Created. ```json { "id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com", "reply_to": "", "subscriber_count": 0, "created_at": "2026-10-02T10:11:12Z", "updated_at": "2026-10-02T10:11:12Z" } ``` ### 400 Bad Request A required field is missing or invalid. The text names the problem. ```json { "error": "name is required" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Get a list Source: https://docs.koltrix.com/api-reference/get-list **GET** `https://api.koltrix.com/api/v2/lists/:id` One list. 404 if there is no such list. Resource: Lists. Requires an API key with the `read` or `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "name": "Product updates", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com", "reply_to": "", "subscriber_count": 1284, "created_at": "2026-09-12T09:14:22Z", "updated_at": "2026-10-01T08:00:00Z" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 404 Not Found No such record in this workspace. ```json { "error": "list not found" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Update a list Source: https://docs.koltrix.com/api-reference/update-list **PATCH** `https://api.koltrix.com/api/v2/lists/:id` Changes only the fields you send and returns the updated list. Resource: Lists. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | | `name` | body | `string` | No | | New name. | | `description` | body | `string` | No | | New description. | | `from_name` | body | `string` | No | | New default sender name. | | `from_address` | body | `string` | No | | New default sender address. | | `reply_to` | body | `string` | No | | New reply-to value. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X PATCH "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Changelog" }' ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", { method: "PATCH", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Changelog" }), }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.patch( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, json={ "name": "Changelog" }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "strings" "fmt" "io" "net/http" "os" ) func main() { body := strings.NewReader(`{ "name": "Changelog" }`) req, _ := http.NewRequest("PATCH", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "PATCH", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "name" => "Changelog", ]), ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "name": "Changelog", "description": "What shipped this month", "from_name": "Acme", "from_address": "hello@acme.com", "reply_to": "", "subscriber_count": 1284, "created_at": "2026-09-12T09:14:22Z", "updated_at": "2026-10-02T10:12:00Z" } ``` ### 400 Bad Request The body is not valid JSON, or a field has the wrong type. ```json { "error": "invalid body" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Delete a list Source: https://docs.koltrix.com/api-reference/delete-list **DELETE** `https://api.koltrix.com/api/v2/lists/:id` Deletes the list and its subscribers. Cannot be undone. Returns 204 with an empty body. Resource: Lists. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X DELETE "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.delete( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("DELETE", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "DELETE", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 204 No Content Done. The body is empty. Empty body. ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # List subscribers Source: https://docs.koltrix.com/api-reference/list-subscribers **GET** `https://api.koltrix.com/api/v2/lists/:id/subscribers` `status` is one of `pending` (waiting for double opt-in), `active` or `unsubscribed`. Resource: Subscribers. Requires an API key with the `read` or `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | | `status` | query | `string` | No | | Only subscribers with this status. One of: `pending`, `active`, `unsubscribed`. | | `limit` | query | `number` | No | `100` | Page size. Default 100. | | `offset` | query | `number` | No | | How many to skip. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers?status=active&limit=100&offset=0" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers?status=active&limit=100&offset=0", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers?status=active&limit=100&offset=0", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers?status=active&limit=100&offset=0", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "data": [ { "id": "9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "email": "ada@example.com", "name": "Ada Lovelace", "status": "active", "unsubscribe_token": "4f9a0c1d2e3b4a5f6c7d8e9f0a1b2c3d", "tags": [ "beta" ], "created_at": "2026-09-20T14:01:00Z", "updated_at": "2026-09-20T14:01:00Z" } ], "total": 1 } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Add a subscriber Source: https://docs.koltrix.com/api-reference/create-subscriber **POST** `https://api.koltrix.com/api/v2/lists/:id/subscribers` Returns 201 with the subscriber. Adding an email that is already on the list updates its name instead of failing; someone who unsubscribed stays unsubscribed. On a list that requires double opt-in the subscriber starts as `pending`. Resource: Subscribers. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | | `email` | body | `string` | Yes | | The address. Stored lower-cased. | | `name` | body | `string` | No | | The person's name, used by the name merge variables in broadcasts. | | `tags` | body | `array` | No | | Array of strings. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X POST "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "ada@example.com", "name": "Ada Lovelace", "tags": [ "beta" ] }' ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "email": "ada@example.com", "name": "Ada Lovelace", "tags": [ "beta" ] }), }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.post( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, json={ "email": "ada@example.com", "name": "Ada Lovelace", "tags": [ "beta" ] }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "strings" "fmt" "io" "net/http" "os" ) func main() { body := strings.NewReader(`{ "email": "ada@example.com", "name": "Ada Lovelace", "tags": [ "beta" ] }`) req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "email" => "ada@example.com", "name" => "Ada Lovelace", "tags" => [ "beta", ], ]), ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 201 Created Created. ```json { "id": "9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "email": "ada@example.com", "name": "Ada Lovelace", "status": "active", "unsubscribe_token": "4f9a0c1d2e3b4a5f6c7d8e9f0a1b2c3d", "tags": [ "beta" ], "created_at": "2026-10-02T10:14:33Z", "updated_at": "2026-10-02T10:14:33Z" } ``` ### 400 Bad Request A required field is missing or invalid. The text names the problem. ```json { "error": "invalid email: …" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Import subscribers from CSV Source: https://docs.koltrix.com/api-reference/import-subscribers **POST** `https://api.koltrix.com/api/v2/lists/:id/subscribers/import` Send the CSV as a string. A header row with `email`, `name` (or `first_name` or `full_name`) and `tags` columns is recognised; without one, the first column is the email. Separate tags with semicolons. Every imported row becomes `active`, even on a list that requires double opt-in, so import only people who agreed to hear from you. Addresses on your suppression list are still never mailed. Rows with an invalid email are skipped. Resource: Subscribers. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | | `csv` | body | `string` | Yes | | The CSV file's contents. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X POST "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/import" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "csv": "email,name,tags\nada@example.com,Ada Lovelace,beta;vip\nlinus@example.com,Linus,beta" }' ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/import", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "csv": "email,name,tags\nada@example.com,Ada Lovelace,beta;vip\nlinus@example.com,Linus,beta" }), }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.post( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/import", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, json={ "csv": "email,name,tags\nada@example.com,Ada Lovelace,beta;vip\nlinus@example.com,Linus,beta" }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "strings" "fmt" "io" "net/http" "os" ) func main() { body := strings.NewReader(`{ "csv": "email,name,tags\nada@example.com,Ada Lovelace,beta;vip\nlinus@example.com,Linus,beta" }`) req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/import", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "csv" => "email,name,tags ada@example.com,Ada Lovelace,beta;vip linus@example.com,Linus,beta", ]), ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "imported": 2, "skipped": 0, "errors": null } ``` ### 400 Bad Request A required field is missing or invalid. The text names the problem. ```json { "error": "csv body required" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Delete a subscriber Source: https://docs.koltrix.com/api-reference/delete-subscriber **DELETE** `https://api.koltrix.com/api/v2/lists/:id/subscribers/:sub_id` Removes the subscriber from the list. To stop mailing someone but keep the record, unsubscribe them instead. Returns 204 with an empty body. Resource: Subscribers. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | | `sub_id` | path | `string` | Yes | | The subscriber id. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X DELETE "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.delete( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("DELETE", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "DELETE", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 204 No Content Done. The body is empty. Empty body. ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 404 Not Found No such record in this workspace. ```json { "error": "subscriber not found" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Unsubscribe a subscriber Source: https://docs.koltrix.com/api-reference/unsubscribe-subscriber **POST** `https://api.koltrix.com/api/v2/lists/:id/subscribers/:sub_id/unsubscribe` Marks the subscriber `unsubscribed`, stops any sequence they are in and adds the address to your suppression list, exactly as if they had clicked the unsubscribe link. No body. Resource: Subscribers. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | | `sub_id` | path | `string` | Yes | | The subscriber id. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X POST "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a/unsubscribe" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a/unsubscribe", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.post( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a/unsubscribe", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/subscribers/9d8c7b6a-5f4e-4d3c-b2a1-0f9e8d7c6b5a/unsubscribe", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "status": "unsubscribed" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 404 Not Found No such record in this workspace. ```json { "error": "subscriber not found" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Send a broadcast Source: https://docs.koltrix.com/api-reference/send-broadcast **POST** `https://api.koltrix.com/api/v2/lists/:id/broadcast` Creates a campaign and queues it straight away; returns 202. Each subscriber gets their own copy with an unsubscribe link and header, open tracking and tracked links. In the body, the merge variables {{name}}, {{first_name}}, {{first_name|fallback}}, {{email}} and {{unsubscribe_url}} are filled in per recipient (the subject is sent as written). `from_address` and `from_name` default to the list's. Resource: Broadcasts. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The list id. | | `subject` | body | `string` | Yes | | Subject line. | | `body_html` | body | `string` | Yes | | HTML body. | | `body_text` | body | `string` | No | | Plain-text body. Generated from the HTML when omitted. | | `preheader` | body | `string` | No | | Preview text shown after the subject in most inboxes. | | `from_name` | body | `string` | No | | Sender name. Defaults to the list's. | | `from_address` | body | `string` | No | | Sender address. Defaults to the list's; required if the list has none. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X POST "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/broadcast" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "subject": "What shipped in October", "body_html": "

Hi {{first_name|there}},

This month we shipped…

", "body_text": "Hi {{first_name|there}}, this month we shipped…", "preheader": "Faster search and a new inbox" }' ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/broadcast", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "subject": "What shipped in October", "body_html": "

Hi {{first_name|there}},

This month we shipped…

", "body_text": "Hi {{first_name|there}}, this month we shipped…", "preheader": "Faster search and a new inbox" }), }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.post( "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/broadcast", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, json={ "subject": "What shipped in October", "body_html": "

Hi {{first_name|there}},

This month we shipped…

", "body_text": "Hi {{first_name|there}}, this month we shipped…", "preheader": "Faster search and a new inbox" }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "strings" "fmt" "io" "net/http" "os" ) func main() { body := strings.NewReader(`{ "subject": "What shipped in October", "body_html": "

Hi {{first_name|there}},

This month we shipped…

", "body_text": "Hi {{first_name|there}}, this month we shipped…", "preheader": "Faster search and a new inbox" }`) req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/lists/0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d/broadcast", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "subject" => "What shipped in October", "body_html" => "

Hi {{first_name|there}},

This month we shipped…

", "body_text" => "Hi {{first_name|there}}, this month we shipped…", "preheader" => "Faster search and a new inbox", ]), ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 202 Accepted Accepted and queued. ```json { "campaign_id": "1d2c3b4a-5f6e-4d7c-8b9a-0f1e2d3c4b5a", "status": "queued", "message": "Broadcast enqueued — recipients will be fanned out by the worker." } ``` ### 400 Bad Request A required field is missing or invalid. The text names the problem. ```json { "error": "subject and body_html required" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # List sequences Source: https://docs.koltrix.com/api-reference/list-sequences **GET** `https://api.koltrix.com/api/v2/sequences` Every sequence in the workspace, with its number of steps. Resource: Sequences. Requires an API key with the `read` or `newsletter` scope. ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/sequences" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/sequences", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/sequences", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/sequences", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "data": [ { "id": "3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "name": "Welcome series", "trigger": "subscribe", "active": true, "step_count": 3, "created_at": "2026-09-21T12:00:00Z", "updated_at": "2026-09-21T12:00:00Z" } ] } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Create a sequence Source: https://docs.koltrix.com/api-reference/create-sequence **POST** `https://api.koltrix.com/api/v2/sequences` Returns 201. `trigger` defaults to `subscribe`, the only trigger: people are enrolled when they become active on the list. Resource: Sequences. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `name` | body | `string` | Yes | | The sequence's name. | | `list_id` | body | `string` | Yes | | The list whose new subscribers are enrolled. | | `trigger` | body | `string` | No | `subscribe` | `subscribe` (the default and only value). One of: `subscribe`. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X POST "https://api.koltrix.com/api/v2/sequences" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Welcome series", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "trigger": "subscribe" }' ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/sequences", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Welcome series", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "trigger": "subscribe" }), }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.post( "https://api.koltrix.com/api/v2/sequences", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, json={ "name": "Welcome series", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "trigger": "subscribe" }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "strings" "fmt" "io" "net/http" "os" ) func main() { body := strings.NewReader(`{ "name": "Welcome series", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "trigger": "subscribe" }`) req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/sequences", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "name" => "Welcome series", "list_id" => "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "trigger" => "subscribe", ]), ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 201 Created Created. ```json { "id": "3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9", "list_id": "0b3c6d2e-91f4-4a7b-8c5d-2e3f4a5b6c7d", "name": "Welcome series", "trigger": "subscribe", "active": true, "step_count": 0, "created_at": "2026-10-02T12:00:00Z", "updated_at": "2026-10-02T12:00:00Z" } ``` ### 400 Bad Request A required field is missing or invalid. The text names the problem. ```json { "error": "name is required" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Delete a sequence Source: https://docs.koltrix.com/api-reference/delete-sequence **DELETE** `https://api.koltrix.com/api/v2/sequences/:id` Deletes the sequence and its steps. Returns 204 with an empty body. Resource: Sequences. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The sequence id. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X DELETE "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.delete( "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("DELETE", "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "DELETE", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 204 No Content Done. The body is empty. Empty body. ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # List a sequence's steps Source: https://docs.koltrix.com/api-reference/list-sequence-steps **GET** `https://api.koltrix.com/api/v2/sequences/:id/steps` The steps in order of `position`. Resource: Sequences. Requires an API key with the `read` or `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The sequence id. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X GET "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps", { method: "GET", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.get( "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "GET", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 200 OK Success. ```json { "data": [ { "id": "7e6d5c4b-3a29-4f18-8e7d-6c5b4a392817", "sequence_id": "3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9", "position": 0, "delay_hours": 0, "subject": "Welcome to Acme", "body_html": "

Hi {{first_name|there}}, glad you're here.

", "body_text": "Hi {{first_name|there}}, glad you're here.", "created_at": "2026-10-02T12:01:00Z" } ] } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Add a step Source: https://docs.koltrix.com/api-reference/add-sequence-step **POST** `https://api.koltrix.com/api/v2/sequences/:id/steps` Returns 201. `delay_hours` is counted from the previous step (from enrolment for the first step). In step bodies {{email}} and {{unsubscribe_url}} are filled in; the name variables are always empty, so give {{first_name}} a fallback. Resource: Sequences. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The sequence id. | | `position` | body | `number` | Yes | | Order in the sequence, starting at 0. | | `delay_hours` | body | `number` | Yes | | Hours to wait after the previous step. | | `subject` | body | `string` | Yes | | Subject line. | | `body_html` | body | `string` | No | | HTML body. | | `body_text` | body | `string` | No | | Plain-text body. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X POST "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "position": 0, "delay_hours": 0, "subject": "Welcome to Acme", "body_html": "

Hi {{first_name|there}}, glad you'\''re here.

", "body_text": "Hi {{first_name|there}}, glad you'\''re here." }' ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps", { method: "POST", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ "position": 0, "delay_hours": 0, "subject": "Welcome to Acme", "body_html": "

Hi {{first_name|there}}, glad you're here.

", "body_text": "Hi {{first_name|there}}, glad you're here." }), }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.post( "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, json={ "position": 0, "delay_hours": 0, "subject": "Welcome to Acme", "body_html": "

Hi {{first_name|there}}, glad you're here.

", "body_text": "Hi {{first_name|there}}, glad you're here." }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "strings" "fmt" "io" "net/http" "os" ) func main() { body := strings.NewReader(`{ "position": 0, "delay_hours": 0, "subject": "Welcome to Acme", "body_html": "

Hi {{first_name|there}}, glad you're here.

", "body_text": "Hi {{first_name|there}}, glad you're here." }`) req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps", body) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "POST", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), "Content-Type: application/json", ], CURLOPT_POSTFIELDS => json_encode([ "position" => 0, "delay_hours" => 0, "subject" => "Welcome to Acme", "body_html" => "

Hi {{first_name|there}}, glad you're here.

", "body_text" => "Hi {{first_name|there}}, glad you're here.", ]), ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 201 Created Created. ```json { "id": "7e6d5c4b-3a29-4f18-8e7d-6c5b4a392817", "sequence_id": "3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9", "position": 0, "delay_hours": 0, "subject": "Welcome to Acme", "body_html": "

Hi {{first_name|there}}, glad you're here.

", "body_text": "Hi {{first_name|there}}, glad you're here.", "created_at": "2026-10-02T12:01:00Z" } ``` ### 400 Bad Request A required field is missing or invalid. The text names the problem. ```json { "error": "subject is required" } ``` ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Delete a step Source: https://docs.koltrix.com/api-reference/delete-sequence-step **DELETE** `https://api.koltrix.com/api/v2/sequences/:id/steps/:step_id` Returns 204 with an empty body. Resource: Sequences. Requires an API key with the `newsletter` scope. ## Parameters | Name | In | Type | Required | Default | Description | | --- | --- | --- | --- | --- | --- | | `id` | path | `string` | Yes | | The sequence id. | | `step_id` | path | `string` | Yes | | The step id. | ## Request examples Every example reads the API key from the environment variable `KOLTRIX_API_KEY`. **cURL** ```bash curl -X DELETE "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps/7e6d5c4b-3a29-4f18-8e7d-6c5b4a392817" \ -H "Authorization: Bearer $KOLTRIX_API_KEY" ``` **Node** ```js const res = await fetch("https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps/7e6d5c4b-3a29-4f18-8e7d-6c5b4a392817", { method: "DELETE", headers: { Authorization: `Bearer ${process.env.KOLTRIX_API_KEY}`, }, }); console.log(res.status, await res.json()); ``` **Python** ```python import os, requests res = requests.delete( "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps/7e6d5c4b-3a29-4f18-8e7d-6c5b4a392817", headers={ "Authorization": f"Bearer {os.environ['KOLTRIX_API_KEY']}", }, ) print(res.status_code, res.json()) ``` **Go** ```go package main import ( "fmt" "io" "net/http" "os" ) func main() { req, _ := http.NewRequest("DELETE", "https://api.koltrix.com/api/v2/sequences/3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9/steps/7e6d5c4b-3a29-4f18-8e7d-6c5b4a392817", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() out, _ := io.ReadAll(res.Body) fmt.Println(res.StatusCode, string(out)) } ``` **PHP** ```php "DELETE", CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ "Authorization: Bearer " . getenv("KOLTRIX_API_KEY"), ], ]); $response = curl_exec($ch); echo curl_getinfo($ch, CURLINFO_HTTP_CODE), " ", $response; ``` ## Responses ### 204 No Content Done. The body is empty. Empty body. ### 401 Unauthorized The API key is missing, malformed, unknown or revoked. ```json { "error": "invalid or revoked api key" } ``` ### 403 Forbidden The key is valid but does not have the newsletter scope. ```json { "error": "missing permission: newsletter" } ``` ### 429 Too Many Requests (rate limit) More than 60 requests a minute on this key. Wait Retry-After seconds. No code field. Headers: `Retry-After: 60` ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` See also: [Errors](https://docs.koltrix.com/errors.md), [Authentication](https://docs.koltrix.com/authentication.md). --- # Errors Source: https://docs.koltrix.com/errors Errors from `https://api.koltrix.com/api/v2` are JSON with an `error` string: ```json { "error": "missing permission: send" } ``` The `error` text is written for a person and may change wording. Decide what to do from the **HTTP status**, and for `429` from the `code` field (see below). Successful responses use `200`, `201` (created), `202` (accepted for sending) and `204` (deleted, empty body). ## Should I retry? | Status | Retry? | | --- | --- | | `400`, `401`, `403`, `404` | No. Fix the request, key or data first. | | `409` | Yes, after a short wait, with the **same** `Idempotency-Key`. | | `429` without `code` | Yes, after `Retry-After` seconds. | | `429` with `"code": "quota_exceeded"` | Not until `resets_at`. Tell a person. | | `500` | Yes, with backoff. For `POST /api/v2/emails`, retry with the same `Idempotency-Key` and nothing is sent twice. | | `503` | Yes, with the same `Idempotency-Key`. | A good default: retry `409`, `429` (rate limit), `500` and `503` with exponential backoff starting at one second, give up after a few minutes, and log everything else for a person to look at. ## 400 Bad Request The request can't be processed as sent. | `error` | Endpoint | Fix | | --- | --- | --- | | `invalid body` | Any with a body | Send valid JSON with `Content-Type: application/json`, and check field types: `to`, `cc` and `bcc` are arrays. | | `from, to, subject are required` | `POST /emails` | Include all three, non-empty. | | `from address is not a valid email` | `POST /emails` | `from` must contain an address, like `hello@acme.com` or `Acme `. | | `subject and body_html required` | `POST /lists/:id/broadcast` | Include both. | | `from_address required (and list has none configured)` | `POST /lists/:id/broadcast` | Pass `from_address`, or set one on the list. | | `csv body required` | `POST /lists/:id/subscribers/import` | Send `{"csv": "…"}` with the file's contents. | | `name is required` | `POST /lists`, `POST /sequences` | Include a non-empty `name`. | | `subject is required` | `POST /sequences/:id/steps` | Include a non-empty `subject`. | | `invalid email: …` | `POST /lists/:id/subscribers` | The `email` isn't a valid address. | ## 401 Unauthorized The API key is missing or not accepted. See [Authentication](https://docs.koltrix.com/authentication.md#errors). | `error` | Fix | | --- | --- | | `missing Bearer token` | Send `Authorization: Bearer kx_…`. | | `invalid api key format` | The token must start with `kx_`. | | `invalid or revoked api key` | The key doesn't exist or was revoked. Create a new one. | ## 403 Forbidden The key is valid but may not do this. | `error` | Fix | | --- | --- | | `missing permission: send` (or `read`, `newsletter`) | Use a key with that scope. [Which scope each endpoint needs](https://docs.koltrix.com/authentication.md#which-scope-each-endpoint-needs). | | `from address '…' is not registered for this account. The domain is verified — add this exact address in Dashboard → Domains.` | Add the address under **Settings → Domains & addresses** → your domain → **Add address**. | | `from address '…' is not registered for this account. Add and verify the domain in Dashboard → Domains first, then add the mailbox.` | [Verify the domain](https://docs.koltrix.com/domains.md) first, then add the address. | ## 404 Not Found | `error` | Meaning | | --- | --- | | `message not found` | No message with this id in this workspace. Straight after sending it can also mean the message is still queued; poll again in a second. | | `list not found` | No list with this id in this workspace. | | `subscriber not found` | No subscriber with this id. | A path that doesn't exist at all returns `404` too. ## 409 Conflict `a request with this Idempotency-Key is still in progress`: a retry arrived while the first request with the same key was still running. Wait a moment and retry with the same key; you'll get the first request's response. See [Retrying safely](https://docs.koltrix.com/sending-email.md#retrying-safely). ## 429 Too Many Requests Two different things answer `429`. Tell them apart by the `code` field. **Rate limit**, no `code`: ```json { "error": "rate limit exceeded — 60 requests per minute per API key" } ``` Wait for the number of seconds in the `Retry-After` header, then continue. **Send quota**, `"code": "quota_exceeded"`: ```json { "code": "quota_exceeded", "error": "You've hit today's API sending limit. It resets at midnight UTC, or upgrade to send more now.", "kind": "api_sends", "limit": 200, "used": 200, "window": "day", "resets_at": "2026-10-03T00:00:00Z", "upgrade_url": "https://app.koltrix.com/settings/billing" } ``` Retrying won't help until `resets_at`. Surface it to a person. Details in [Limits and quotas](https://docs.koltrix.com/limits.md). ## 500 Internal Server Error Something failed on Koltrix's side. The `error` text describes it. Retry with backoff; if it keeps happening, email support@koltrix.com with the time and the endpoint. `POST /api/v2/emails` releases its `Idempotency-Key` when it fails before the message is queued, so retrying with the same key is safe and sends at most one message. ## 503 Service Unavailable `idempotency store unavailable, please retry`: Koltrix couldn't check the `Idempotency-Key`, so it sent nothing rather than risk sending twice. Retry with the same key. ## SMTP relay The SMTP relay answers with SMTP reply codes, not HTTP statuses. They are listed in [SMTP relay → Reply codes](https://docs.koltrix.com/smtp-relay.md#reply-codes). --- # Limits and quotas Source: https://docs.koltrix.com/limits Koltrix has two kinds of limit: - **Plan quotas**: how much your workspace may use, such as sends per month or domains. They depend on your plan. - **Rate limits**: how fast you may call the API. They are the same on every plan. The numbers below are generated from the same configuration file the API enforces, so they can't drift from what your workspace actually does. Prices are on [koltrix.com/pricing](https://koltrix.com/pricing). ## Plan quotas | Limit | Trial | Starter | Pro | Scale | | --- | --- | --- | --- | --- | | Team members included | 2 | 3 | 10 | Unlimited | | Domains | 1 | 2 | 10 | Unlimited | | Addresses | 3 | 10 | 50 | Unlimited | | Workspace storage | 1 GB | 10 GB | 50 GB | Unlimited | | Inbox sends / day | 50 | 500 | 2,000 | Unlimited | | API + SMTP sends / month | 1,400 | 10,000 | 50,000 | Unlimited | | API + SMTP sends / day | 200 | Unlimited | Unlimited | Unlimited | | AI actions / month | 175 | 300 | 2,000 | Unlimited | | AI actions / day | 25 | Unlimited | Unlimited | Unlimited | | Newsletter contacts | 100 | 1,000 | 10,000 | Unlimited | Unlimited means no ceiling is applied. A limit shown as 0 is not available on that plan. **API + SMTP sends** is one allowance shared by `POST /api/v2/emails` and the SMTP relay. Every recipient counts, including `cc` and `bcc`. A request that is replayed with an `Idempotency-Key` doesn't count again. **Inbox sends** are messages your team sends from the Koltrix app. They have their own daily allowance and don't use the API allowance. ### When quotas reset | Window | Resets | | --- | --- | | Daily | 00:00 UTC | | Monthly | 00:00 UTC on the 1st of each calendar month | Domains, addresses, seats, contacts and storage don't reset: they are counts of what you have. Remove something or upgrade to make room. ### During the trial Trial caps are tighter, because email infrastructure attracts abuse and every customer shares the sending reputation it would damage. - 1 domain, which must pass SPF, DKIM and DMARC before any external send - 3 addresses and 2 team members - 50 external sends a day from the inbox - 200 API and SMTP sends a day - 1 GB of storage - 25 AI actions a day - After 7 days the workspace becomes read-only; after another 30 days without a plan the data is deleted, following three warning emails ## What happens at a quota A request that would go over a quota is refused. Nothing is queued to be sent later. | Quota | Where it applies | Answer | | --- | --- | --- | | API + SMTP sends (daily or monthly) | `POST /api/v2/emails` | `429` with `Retry-After` until the reset | | API + SMTP sends (daily or monthly) | SMTP relay | `452 4.5.3` at `RCPT` or `DATA` | | Inbox sends, AI actions | The Koltrix app | `429` until the reset | | Domains, addresses, seats, contacts | The Koltrix app | `402` | | Storage | Incoming mail | Deferred with `452 4.2.2`, not lost | Every quota refusal has the same JSON body, with `"code": "quota_exceeded"`: ```json { "code": "quota_exceeded", "error": "You've hit this month's API sending limit. It resets on the 1st, or upgrade to send more now.", "kind": "api_sends", "limit": 10000, "used": 10000, "window": "month", "resets_at": "2026-11-01T00:00:00Z", "upgrade_url": "https://app.koltrix.com/settings/billing" } ``` | Field | Meaning | | --- | --- | | `code` | Always `quota_exceeded`. Branch on this. | | `error` | A sentence you can show a person. | | `kind` | `api_sends`, `inbox_sends`, `ai_actions`, `domains`, `addresses`, `seats`, `contacts` or `storage`. | | `limit` / `used` | The cap and how much is used. | | `window` | `day` or `month` for quotas that reset; absent otherwise. | | `resets_at` | When the quota resets; absent for quotas that don't. | | `upgrade_url` | Where an owner can change the plan. | Treat a quota refusal as something to tell a person about. Retrying straight away won't work; wait until `resets_at` or upgrade. The SMTP relay answers with a temporary `452` on purpose: a mail server that gets it keeps the message and tries again for several days, which gives you time to upgrade instead of losing the mail. ## Rate limits Each **API key** may make **60 requests per minute** to `/api/v2`, counted over a sliding window. Over that, the API answers: ```http HTTP/1.1 429 Too Many Requests Retry-After: 60 {"error": "rate limit exceeded — 60 requests per minute per API key"} ``` - The limit is per key, so one busy integration can't slow down another that uses a different key. - One `POST /api/v2/emails` can address many recipients, so the request limit rarely limits how much you can send. - A rate-limit `429` has no `code` field. A quota `429` has `"code": "quota_exceeded"`. Handle them differently: wait `Retry-After` seconds for the first, tell a person about the second. The Koltrix app and the [AI assistants](https://docs.koltrix.com/mcp.md) connection have their own limits, sized for people rather than scripts. When an admin allows assistants to send, a message sent through one goes to at most 10 recipients, and a connection can send 20 a day and a workspace 50 a day (UTC), on top of your plan's quota. See [Sending email from an assistant](https://docs.koltrix.com/mcp.md#sending-email-from-an-assistant). ## If a limit doesn't fit Email support@koltrix.com. Limits exist to stop abuse and protect the sending reputation every customer shares; a real workload that runs into one is a conversation, not a refusal. --- # Troubleshooting Source: https://docs.koltrix.com/troubleshooting The problems people run into most, what causes them and what to do. For a specific error message, [Errors](https://docs.koltrix.com/errors.md) lists every one. ## Email isn't arriving Work through these in order. 1. **Find the message.** Open **Settings → Logs**, or call `GET /api/v2/messages?recipient=
`. - Not there at all: the send was refused. Check the HTTP response or SMTP reply your code got. - `queued` for more than a minute: email support@koltrix.com with the message id. The queue is normally empty within a second. - `bounced`: the `error` field has the receiving server's reason. A typo in the address, or a mailbox that doesn't exist, is the usual cause. - `sent`: Koltrix handed it off. Carry on below. 2. **Check spam** at the recipient. If it's there, the message was delivered but not trusted. 3. **Check the domain** under **Settings → Domains & addresses**. Every record should be verified, DKIM above all; unsigned mail goes to spam. 4. **Check the suppression list.** If the address is under **Settings → Blocked senders → We won't email these**, Koltrix skips it on purpose. See [Deliverability](https://docs.koltrix.com/deliverability.md#the-suppression-list). 5. **Score a message** at [mail-tester.com](https://www.mail-tester.com/). Anything under 8 out of 10 points to something it will name. ## "from address … is not registered for this account" (403) The From address isn't an active address in your workspace. Koltrix only sends from addresses you have added, even on a verified domain. - If the message says **the domain is verified**: open the domain under **Settings → Domains & addresses**, click **Add address** and add the exact address you send from. - Otherwise: [verify the domain](https://docs.koltrix.com/domains.md) first, then add the address. Display names are fine: `Acme ` only needs `hello@acme.com` added. ## "invalid or revoked api key" (401) - Did you copy the whole key, including `kx_`? - Is the header `Authorization: Bearer kx_…`, with `Bearer` and a space? - Was the key revoked in **Settings → API keys**? Revoked keys are refused immediately. Create a new one. ## "missing permission: …" (403) The key works but lacks the scope this endpoint needs. Scopes can't be changed after a key is created: create a key with the right scopes, deploy it, then revoke the old one. [Which scope each endpoint needs](https://docs.koltrix.com/authentication.md#which-scope-each-endpoint-needs). ## 429 Too Many Requests Look at the body. - **No `code` field:** the rate limit of 60 requests per minute per API key. Wait `Retry-After` seconds. Sending to many recipients? Put them in one request or use fewer, larger batches. - **`"code": "quota_exceeded"`:** your plan's send quota is used up until `resets_at`. Retrying won't help; upgrade or wait. See [Limits and quotas](https://docs.koltrix.com/limits.md). ## `400 invalid body` on a send The JSON didn't parse into the expected fields. Most often `to` was sent as a string; it must be an array: `"to": ["lead@example.com"]`. Also check the `Content-Type: application/json` header. ## Message still `queued` `GET /api/v2/messages/:id` returns `status: "queued"` until the message has been handed to the receiving server, usually within a second or two. Poll again, or use [webhooks](https://docs.koltrix.com/webhooks.md). ## Domain won't verify Settings shows what it found for each record. Common causes (the records are explained in [Custom domains and DNS](https://docs.koltrix.com/domains.md)): - **Not long enough.** DNS can take minutes to hours. Press **Check again** every few minutes. - **The name is doubled.** If the saved record reads `kx1._domainkey.example.com.example.com`, your provider added the domain for you. Use the short name: `kx1._domainkey`, `_koltrix`, `_dmarc`, or `@` for the domain itself. - **Two SPF records.** A domain may have only one. Replace your existing SPF record with the merged one Koltrix shows; don't add a second. - **The DKIM value is damaged.** A long value split into quoted pieces is fine; a missing character, an added space or a line break is not. Paste it with the copy button in one go. Route 53 needs you to split it yourself: `"first 255 characters" "the rest"`. - **Claimed elsewhere.** The domain is verified in another workspace. See [If the domain is claimed by another workspace](https://docs.koltrix.com/domains.md#if-the-domain-is-claimed-by-another-workspace). [dnschecker.org](https://dnschecker.org) shows whether a record has reached DNS servers around the world. ## Webhook not arriving 1. **Is the endpoint listed** under **Settings → Webhooks**, with an `https://` URL? 2. **Does the event fire?** Koltrix sends `message.sent`, `message.bounced`, `message.opened` and `message.clicked`. Clicks aren't tracked on API or SMTP sends, and SMTP relay sends have no open tracking, so those events don't come for them. See [Webhooks](https://docs.koltrix.com/webhooks.md#events). 3. **Did your server answer `2xx` within 8 seconds?** Koltrix tries each event once and doesn't retry, so a slow or failing handler loses the event. Answer first, then do the work. 4. **Is your server rejecting the signature?** See below. ## Webhook signature doesn't verify Almost always one of these: - **Hashing parsed JSON instead of the raw body.** Your framework parsed the body before your handler saw it. Use the raw bytes (in Express, `express.raw({ type: "application/json" })`). - **Changing the body**, for example trimming whitespace, before hashing it. - **The wrong secret.** Each endpoint has its own `whsec_…` secret. Working examples are in [Webhooks](https://docs.koltrix.com/webhooks.md#verify-the-signature). ## SMTP relay problems | Symptom | Cause and fix | | --- | --- | | Can't connect, or the connection times out | Use `smtp.koltrix.com` port `2525`. Ports 587 and 465 don't accept API keys. | | Certificate or "TLS" errors on the relay | Connect to the host name `smtp.koltrix.com` (not an IP address), on port 2525 with STARTTLS. The certificate is issued for that name. | | `535 5.7.8 Authentication failed (…)` | The text in brackets says why: the password isn't a `kx_` key, the key is invalid or revoked, or it lacks the `send` scope. | | `501 5.5.4 Invalid FROM address` | Put the address in angle brackets: `MAIL FROM:`. | | The message arrives with MIME boundaries or garbage in the body | The relay doesn't parse multipart messages. Send only an HTML or only a text body, with no attachments. | | `452 4.5.3` | Either over 1,000 recipients in one message, or your send quota is used up. The text says which. | More in [SMTP relay](https://docs.koltrix.com/smtp-relay.md#reply-codes). ## AI assistant problems See [AI assistants → Troubleshooting](https://docs.koltrix.com/mcp.md#troubleshooting). ## Still stuck Email **support@koltrix.com** with what you tried, what you expected and what happened, the message id if it's about a send, and roughly when it happened. We answer within one business day. --- # FAQ Source: https://docs.koltrix.com/faq ## Can I switch from Postmark, Resend or Mailgun? Yes. It is usually a day's work. 1. **Add your domain** and publish its [DNS records](https://docs.koltrix.com/domains.md). If your domain already has an SPF record for your current provider, Koltrix shows a merged record that allows both, so you can switch gradually. The MX record moves your domain's incoming mail to Koltrix, so publish it when you're ready. 2. **Add the addresses you send from** to the domain. 3. **Change the API call.** `POST /api/v2/emails` takes `from`, `to` (an array), `cc`, `bcc`, `subject`, `body_html` and `body_text`. See [Sending email](https://docs.koltrix.com/sending-email.md). Or point your SMTP settings at the [relay](https://docs.koltrix.com/smtp-relay.md). 4. **Bring your suppression list.** Add the addresses under **Settings → Blocked senders → We won't email these**. 5. **Move your webhooks.** Koltrix signs them differently; see [Webhooks](https://docs.koltrix.com/webhooks.md). Check the "Current limitation" notes on those pages against what your integration relies on, such as attachments or BCC. ## How is Koltrix different? Transactional email services send your application's mail and stop there. Koltrix also gives your team the inbox where the replies land, on the same domain, with the same suppression list and the same message log. A customer who replies to a receipt reaches a person, and that reply is counted on the message. ## How much does it cost? Plans and prices are on [koltrix.com/pricing](https://koltrix.com/pricing). New workspaces start with a free trial, no card needed. Each plan's limits are in [Limits and quotas](https://docs.koltrix.com/limits.md). ## Where is my data? Koltrix runs on OVHcloud servers in Frankfurt, Germany, in the European Union. There is no choice of region today. The details are in the [privacy policy](https://koltrix.com/legal/privacy) and the [list of subprocessors](https://koltrix.com/legal/subprocessors). Koltrix is not SOC 2 or HIPAA certified. If you need a certification, ask before you sign up. ## Does Koltrix's AI send email? Not on its own. Koltrix's AI features summarise, sort and draft, and none of them can send, forward or permanently delete a message: a person always clicks Send. The [AI assistants connection](https://docs.koltrix.com/mcp.md) lets tools like Claude read, organise and draft. Sending through it is off by default. A workspace owner or admin can turn it on, you opt in when you connect, and the assistant must show you the message and get your yes before it sends. It can never forward or permanently delete mail. You can't bring your own AI provider key today. ## Is the API stable? `/api/v2` is the stable, public API, and it's what these docs describe. Changes to it add things; they don't change or remove existing fields and behaviour without a new version and notice. Changes are listed in the [Changelog](https://docs.koltrix.com/changelog.md). The app talks to Koltrix through other, internal endpoints. Don't build on those: they change without notice. ## Can I belong to more than one workspace? Yes. Switch between them from the workspace menu in the app. API keys belong to the workspace they were created in, and an AI assistant connection belongs to the workspace you chose when you approved it. ## Is there an SDK? Not yet. The API is a few JSON endpoints, and examples for cURL, Node.js, Python, Go and PHP are in [Sending email](https://docs.koltrix.com/sending-email.md#code-samples). ## What does Koltrix do for deliverability? - Signs every message with your domain's own DKIM key. - Suppresses addresses that hard-bounce or unsubscribe, across everything you send. - Adds unsubscribe links and headers to broadcasts and sequences. - Paces sending from new domains. The rest is your DNS and your list. See [Deliverability and suppressions](https://docs.koltrix.com/deliverability.md). ## My question isn't here Email **support@koltrix.com**. We answer within one business day. --- # Integrations Source: https://docs.koltrix.com/integrations Koltrix speaks plain HTTP, SMTP and webhooks, so it fits anything that does. These are the patterns that come up most. - **Something happens elsewhere, and you want to send an email:** receive that system's webhook, then call `POST /api/v2/emails`. - **Something happens to your email, and you want another system to know:** add a Koltrix [webhook](https://docs.koltrix.com/webhooks.md) and act on the event. ## Stripe receipts Send a receipt when a Checkout payment succeeds. Using the Stripe event id as the `Idempotency-Key` means Stripe's own retries never send a second receipt. **Next.js route handler** ```ts const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function POST(req: Request) { const raw = await req.text(); const event = stripe.webhooks.constructEvent( raw, req.headers.get("stripe-signature")!, process.env.STRIPE_WEBHOOK_SECRET!, ); if (event.type === "checkout.session.completed") { const session = event.data.object as Stripe.Checkout.Session; const amount = ((session.amount_total ?? 0) / 100).toFixed(2); 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": event.id, }, body: JSON.stringify({ from: "Acme Billing ", to: [session.customer_details!.email!], subject: "Your Acme receipt", body_html: `

Thanks for your order of ${amount} ${session.currency?.toUpperCase()}.

`, }), }); // Let Stripe retry if Koltrix couldn't take it; the key prevents duplicates. if (!res.ok && res.status >= 500) return new Response("retry", { status: 500 }); } return new Response("ok"); } ``` ## GitHub notifications Email a code owner when a pull request is opened against `main`: **Next.js route handler** ```ts export async function POST(req: Request) { // Verify X-Hub-Signature-256 here before trusting the payload. const { action, pull_request, repository } = await req.json(); if (action !== "opened" || pull_request.base.ref !== "main") { return new Response("ignored"); } 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": `pr-${repository.id}-${pull_request.number}-opened`, }, body: JSON.stringify({ from: "ci@acme.com", to: ["codeowner@acme.com"], subject: `[${repository.full_name}] ${pull_request.title}`, body_html: `

Review #${pull_request.number}

`, }), }); return new Response("ok"); } ``` ## Slack alerts on bounces Forward `message.bounced` events to a Slack incoming webhook: **Next.js route handler** ```ts export async function POST(req: Request) { const raw = await req.text(); const expected = "sha256=" + crypto.createHmac("sha256", process.env.KOLTRIX_WEBHOOK_SECRET!).update(raw).digest("hex"); const given = req.headers.get("x-koltrix-signature") ?? ""; if ( given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected)) ) { return new Response("bad signature", { status: 401 }); } const event = JSON.parse(raw); if (event.event === "message.bounced") { await fetch(process.env.SLACK_WEBHOOK_URL!, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: `:warning: "${event.subject}" bounced (${event.message_id}): ${event.error}`, }), }); } return new Response("ok"); } ``` One endpoint can fan out to several places: branch on `event.event`. The payload of each event is in [Webhooks](https://docs.koltrix.com/webhooks.md#payloads). ## Zapier, Make and n8n There is no Koltrix app in these marketplaces yet, but their generic HTTP steps work well: - **Send an email from a workflow:** add an HTTP request step (Zapier: *Webhooks by Zapier → Custom Request*) that `POST`s JSON to `https://api.koltrix.com/api/v2/emails` with the header `Authorization: Bearer kx_…`. Remember that `to` is an array. - **Start a workflow from a Koltrix event:** add the workflow's webhook URL as a Koltrix [webhook](https://docs.koltrix.com/webhooks.md). Its events arrive as JSON. Use a key with only the `send` scope for these tools. ## AI assistants To work with your inbox from Claude, ChatGPT, Cursor or another AI assistant, connect Koltrix's MCP server. See [AI assistants (MCP)](https://docs.koltrix.com/mcp.md). ## Incoming mail Mail to your domain arrives in your Koltrix team inbox. There is no webhook for incoming mail and no API to read it yet. To pass a mailbox's mail on to another system, set up **Settings → Forwarding**, or let an AI assistant read it through the [MCP connection](https://docs.koltrix.com/mcp.md). ## Languages There are no official SDKs. The API is a handful of JSON endpoints, and each language's standard HTTP client is enough. | Language | REST API | SMTP relay | | --- | --- | --- | | Node.js | `fetch` (Node 18+) | `nodemailer` | | Python | `requests` or `httpx` | `smtplib` | | Go | `net/http` | `net/smtp` (see the [relay page](https://docs.koltrix.com/smtp-relay.md#go-netsmtp) for authentication) | | PHP | cURL or Guzzle | PHPMailer | | Ruby | `Net::HTTP` | the `mail` gem | Code for each is in [Sending email](https://docs.koltrix.com/sending-email.md#code-samples) and [SMTP relay](https://docs.koltrix.com/smtp-relay.md#examples). --- # Changelog Source: https://docs.koltrix.com/changelog Changes that affect developers: the REST API, the SMTP relay, webhooks, domain setup and these docs. Newest first. For new features in the Koltrix app, see the [product changelog](https://koltrix.com/changelog). `/api/v2` is the stable API. Changes to it are additive: new fields and endpoints may appear, but existing fields, status codes and behaviour don't change without a new version and notice. ## 2026-10-04 **Interactive cards in AI assistants.** In an app that supports the MCP Apps extension, `list_mailboxes`, `search_mail`, `list_threads`, `get_inbox_summary`, `get_thread` and `create_draft` now also show a card: a mailbox picker, a conversation list, a plain-text conversation view and a draft review card with **Send**, **Edit in Koltrix** and **Discard**. The server offers cards only to an app that declares MCP Apps support (`io.modelcontextprotocol/ui`) when it connects, and serves the pages as `ui://koltrix/*` resources; other apps get exactly the text results they had. For an app that shows cards, sending moves to the Send button: `send_draft` is not offered, and the card calls a new app-only tool, `send_draft_from_card`, with a one-time token (30 minutes, single use, bound to the draft, the connection and the text shown) delivered only to the card. Every send check and limit is unchanged. Cards load nothing from the internet and show mail content as plain text. See [Interactive cards](https://docs.koltrix.com/mcp.md#interactive-cards). ## 2026-10-03 **AI assistants (MCP).** Connect Claude, ChatGPT, Cursor and other MCP apps to your Koltrix inbox at `https://mcp.koltrix.com/mcp`. Assistants can search, read, organise and draft; they can't forward or permanently delete mail. Sign-in is OAuth, with per-connection permissions, revocation and an admin switch. See [AI assistants](https://docs.koltrix.com/mcp.md). **AI assistants can send, if you allow it.** A new `send_draft` tool sends a draft you already have, but only when a workspace owner or admin has turned on **Allow assistants to send email after you confirm** (off by default), you have opted in with the new `mail.send` permission when connecting, and the assistant has shown you the message and got your yes. The call must carry `confirmed: true` and a `confirmation` of the form `a@x.com, b@y.com | Subject` matching the draft's recipients and subject. Limits: 10 recipients per message, 20 sends a day per connection and 50 a day per workspace. Connections made before this can't send until they are reconnected with the box ticked. Koltrix's own AI in the app is unchanged: it never sends without a click. See [Sending email from an assistant](https://docs.koltrix.com/mcp.md#sending-email-from-an-assistant). **Docs.** Every page was checked against the API's code and corrected. New pages: [Errors](https://docs.koltrix.com/errors.md), [Deliverability and suppressions](https://docs.koltrix.com/deliverability.md) and this changelog. Known gaps are now marked "Current limitation" where they apply. ## 2026-10-02 **Five DNS records for new domains.** A new domain now asks for an ownership record (`_koltrix`), MX, SPF (`include:_spf.koltrix.com`), its own DKIM key (`kx1._domainkey`) and DMARC. If your domain already has an SPF record, Koltrix shows a merged one. Domains verified before this keep working unchanged and show the new records as a recommended upgrade; once their `kx1._domainkey` record checks out, their mail is signed with their own key automatically. See [Custom domains and DNS](https://docs.koltrix.com/domains.md). **Domain claims.** A domain can be pending in more than one workspace, so nobody can block its real owner by adding it first. The first workspace whose own ownership record appears in DNS verifies it. ## 2026-10-01 **Developers hub.** API keys, SMTP settings, webhooks and delivery logs are gathered under **Developers** in the app, with a copy-and-run quickstart. ## 2026-09-29 **Send quotas on the API and SMTP relay.** Sends through `POST /api/v2/emails` and the SMTP relay count against your plan's API send allowance, per recipient. Over the allowance the API answers `429` with `"code": "quota_exceeded"` and `resets_at`, and the relay answers a temporary `452`, so mail servers retry rather than drop the message. See [Limits and quotas](https://docs.koltrix.com/limits.md). ## 2026-09-01 **Per-key rate limit.** `/api/v2` allows 60 requests per minute per API key and answers `429` with `Retry-After` above that. ## 2026-08-31 **SMTP relay limits.** At most 1,000 recipients per message (`452 4.5.3` above that, so the rest can be sent in another message), and a message over 25 MB is refused with a permanent `552 5.3.4` instead of a retryable error. ## 2026-08-30 **Idempotency without duplicates.** The `Idempotency-Key` is claimed before a message is queued, so a retry that races the original can no longer send a second copy. A retry during the original answers `409`; if the idempotency store is unavailable the API answers `503` and sends nothing. **Bounces that come back later.** A permanent bounce report that arrives after the message was sent now marks the message `bounced` and adds the address to the suppression list. **Unsubscribes suppress.** Unsubscribing, from a link or through the API, adds the address to the workspace's suppression list, so no list, sequence or transactional send mails it again. ## 2026-07-30 **Display names in From.** `"from": "Acme "` is accepted by `POST /api/v2/emails`.