Sending email
There are three ways to send mail through Koltrix and they all share the same pipeline — the same outbound table, the same suppression list, the same DKIM signer, the same webhook stream:
- REST API —
POST /api/v2/emails. The standard integration for backend code. - SMTP relay — port 2525, AUTH PLAIN with your API key as the password. The drop-in for libraries that already speak SMTP.
- Broadcasts — send to a newsletter list via
POST /api/v2/lists/:id/broadcast. Covered in Newsletters.
This page covers (1) in depth.
POST /api/v2/emails
curl https://api.koltrix.com/api/v2/emails \
-H "Authorization: Bearer kx_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"from": "[email protected]",
"to": ["[email protected]"],
"cc": ["[email protected]"],
"bcc": ["[email protected]"],
"subject": "Your invoice for May",
"body_html": "<h1>Thanks!</h1><p>See you next month.</p>",
"body_text": "Thanks!\n\nSee you next month."
}'Required scope
The API key must include the send scope. Without it the call returns
403 missing permission: send.
Request body
| Field | Type | Notes |
|---|---|---|
from | string | Required. Must be an active address on a verified sending domain for the account. |
to | string[] | Required. At least one recipient. |
cc | string[] | Optional. |
bcc | string[] | Optional. Stripped from the visible headers. |
subject | string | Required. |
body_html | string | HTML body. Provide either this, body_text, or both. |
body_text | string | Plain-text body. Provide either this, body_html, or both. |
The from address is enforced strictly: it must match an exact mailbox
that's been added under Settings → Domains → <domain> → Add
address and marked active. Unknown local-parts on a verified domain
are rejected — the API tells you which case failed:
{
"error": "from address '[email protected]' is not registered for this account. The domain is verified — add this exact address in Dashboard → Domains."
}Headers
| Header | Notes |
|---|---|
Authorization | Required. Bearer kx_... with the send scope. |
Content-Type | application/json |
Idempotency-Key | Strongly recommended. Any unique string per logical send. Cached 24h. |
Response
{
"id": "01HX9C8...",
"status": "queued",
"queued": true,
"tracking_url": "/api/v2/messages/01HX9C8...",
"recipient_count": 1
}The request returns 202 Accepted once the job is on the queue. Actual
SMTP delivery happens inside the worker — usually within a second. Poll
GET /api/v2/messages/{id} for the current status and counters, or
subscribe to webhooks and stop polling entirely.
Status flow
Each send walks through these states:
queued → sent → opened → clicked → replied
↘ bounced (5xx — recipient auto-suppressed)
↘ failed (unrecoverable error before SMTP)queued → sent happens inside the worker once delivery is accepted by
the receiving server. Opens and clicks are recorded by the tracking
pixel and link rewriter respectively; replies are matched on inbound
mail.
Idempotency
When the network blinks and your client retries, you don't want two
copies of the welcome email. Include Idempotency-Key: <unique-string>
and we cache the first response for 24 hours per (account, key).
Subsequent calls with the same key return the original response with
header Idempotent-Replayed: true — no second send happens.
Generate the key once per logical send (a UUIDv4 from your DB, or a hash of the message contents) and reuse it across retries. Don't generate a new key inside the retry loop — that defeats the purpose.
Tracking and how to opt out
Open and click tracking are on by default. The worker:
- Injects a 1×1 tracking pixel just before
</body>. A hit on the pixel marks the message as opened and firesmessage.opened. - Rewrites every tracked
<a href="https://...">through a redirect endpoint. Clicks land at the original URL after recording the event and firingmessage.clicked.
To opt out of tracking on a specific transactional send (good for
password resets and receipts), use the SMTP relay with header
X-Koltrix-No-Track: 1. To opt out at the campaign level for
broadcasts, untick "Track opens" / "Track clicks" in the composer.
The List-Unsubscribe header is added automatically to broadcast
sends regardless of tracking flags — that's a deliverability
requirement, not analytics.
Polling a sent message
curl https://api.koltrix.com/api/v2/messages/01HX9C8... \
-H "Authorization: Bearer kx_..."Returns the canonical row:
{
"id": "01HX9C8...",
"from": "[email protected]",
"to": "[email protected]",
"subject": "Your invoice for May",
"status": "opened",
"open_count": 2,
"click_count": 1,
"reply_count": 0,
"engagement_score": 65,
"first_open_after_seconds": 423,
"message_id": "<[email protected]>",
"created_at": "2026-05-22T14:00:00Z",
"sent_at": "2026-05-22T14:00:01Z",
"opened_at": "2026-05-22T14:07:04Z",
"clicked_at": "2026-05-22T14:09:12Z"
}For the per-event timeline (every open + click + reply with IP, UA,
prefetch flag) hit GET /api/v2/messages/{id}/events.
Code samples
Node (fetch)
import { randomUUID } from "node:crypto";
await fetch("https://api.koltrix.com/api/v2/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.KOLTRIX_KEY!}`,
"Content-Type": "application/json",
"Idempotency-Key": randomUUID(),
},
body: JSON.stringify({
from: "[email protected]",
to: ["[email protected]"],
subject: "Hello",
body_html: "<p>Hi</p>",
body_text: "Hi",
}),
});Python (requests)
import os, uuid, requests
requests.post(
"https://api.koltrix.com/api/v2/emails",
headers={
"Authorization": f"Bearer {os.environ['KOLTRIX_KEY']}",
"Content-Type": "application/json",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Hello",
"body_html": "<p>Hi</p>",
"body_text": "Hi",
},
)Go (net/http)
body := strings.NewReader(`{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Hello",
"body_html": "<p>Hi</p>",
"body_text": "Hi"
}`)
req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/emails", body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", uuid.NewString())
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()SMTP alternative
If you have a library that already speaks SMTP, point it at
smtp.koltrix.com:2525 with AUTH PLAIN:
| Setting | Value |
|---|---|
| Host | smtp.koltrix.com |
| Port | 2525 |
| Username | apikey (any value works) |
| Password | Your full kx_ key (with send) |
Full client examples in SMTP relay.