Sending email
Koltrix sends mail for your application in two ways, and both go through the same pipeline: the same suppression list, the same signing, the same message log and the same webhooks.
- The REST API:
POST /api/v2/emails. The right choice for new code. - The SMTP relay: for software that already speaks SMTP.
This page covers the REST API.
Before you send
- An API key with the
sendscope. See Authentication. - A registered From address. The
fromaddress 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, then click Add address on the domain and add the exact mailbox you will send from, for example[email protected].
POST /api/v2/emails
/api/v2/emailscurl https://api.koltrix.com/api/v2/emails \
-H "Authorization: Bearer $KOLTRIX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-receipt" \
-d '{
"from": "Acme <[email protected]>",
"to": ["[email protected]"],
"subject": "Your receipt for order 1042",
"body_html": "<h1>Thanks!</h1><p>Your order is on its way.</p>",
"body_text": "Thanks! Your order is on its way."
}'Request body
fromstringrequired[email protected] or Acme <[email protected]>. The address must be an active address on a verified domain in this workspace.tostring[]requiredccstring[]bccstring[]subjectstringrequiredbody_htmlstringbody_textstringbody_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.
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. |
Response
202 Accepted once the message is queued:
{
"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 or, better, with
webhooks.
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. |
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. |
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.
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 but never against your send quota.
What happens after you send
- Suppressed recipients are skipped. An address on your suppression list is not mailed.
- The message is signed with your domain's DKIM key and handed to Koltrix's outbound mail servers.
- An open-tracking pixel is added to the HTML part.
- A copy appears in your team inbox, in the Sent folder, so teammates can see what your application sent.
- Replies come back to your From address, which is a real mailbox in your team inbox, threaded with the message. See Receiving replies.
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
curl https://api.koltrix.com/api/v2/messages/6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20 \
-H "Authorization: Bearer $KOLTRIX_KEY"{
"id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20",
"from": "[email protected]",
"to": "[email protected]",
"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": "[email protected]",
"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_idis theMessage-IDheader of the sent mail, without the angle brackets.errorholds the reason whenstatusisbounced.engagement_scoreadds 1 per open, 5 per click and 10 per reply.first_open_after_secondsis 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.
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.
Code samples
curl https://api.koltrix.com/api/v2/emails \
-H "Authorization: Bearer $KOLTRIX_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-receipt" \
-d '{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Your receipt",
"body_html": "<p>Thanks for your order.</p>"
}'// 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_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-1042-receipt",
},
body: JSON.stringify({
from: "[email protected]",
to: ["[email protected]"],
subject: "Your receipt",
body_html: "<p>Thanks for your order.</p>",
}),
});
if (!res.ok) throw new Error(`Koltrix ${res.status}: ${await res.text()}`);
const { id } = await res.json();import os
import requests
res = requests.post(
"https://api.koltrix.com/api/v2/emails",
headers={
"Authorization": f"Bearer {os.environ['KOLTRIX_KEY']}",
"Idempotency-Key": "order-1042-receipt",
},
json={
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Your receipt",
"body_html": "<p>Thanks for your order.</p>",
},
timeout=10,
)
res.raise_for_status()
message_id = res.json()["id"]package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
body, _ := json.Marshal(map[string]any{
"from": "[email protected]",
"to": []string{"[email protected]"},
"subject": "Your receipt",
"body_html": "<p>Thanks for your order.</p>",
})
req, _ := http.NewRequest("POST", "https://api.koltrix.com/api/v2/emails", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("KOLTRIX_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
$ch = curl_init("https://api.koltrix.com/api/v2/emails");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("KOLTRIX_KEY"),
"Content-Type: application/json",
"Idempotency-Key: order-1042-receipt",
],
CURLOPT_POSTFIELDS => json_encode([
"from" => "[email protected]",
"to" => ["[email protected]"],
"subject" => "Your receipt",
"body_html" => "<p>Thanks for your order.</p>",
]),
]);
$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 before you use it.