REST API reference
Authentication
Create an API key under API keys. Send it as Authorization: Bearer cm_live_…. Keys are shown once and stored only as a hash. cm_test_ keys run every validation but never send email.
Send an email — POST /api/v1/emails
curl -X POST https://csend.net/api/v1/emails \
-H "Authorization: Bearer cm_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1234-receipt" \
-d '{
"from": "Acme <receipts@your-verified-domain.com>",
"to": ["customer@example.com"],
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"tags": [{"name": "category", "value": "receipt"}]
}'const res = await fetch("https://csend.net/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CARIO_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "order-1234-receipt",
},
body: JSON.stringify({
from: "Acme <receipts@your-verified-domain.com>",
to: ["customer@example.com"],
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
}),
});
const email = await res.json(); // { id, object: "email", status: "queued", test_mode }| Field | Type | Notes |
|---|---|---|
| from | string | Sender. The domain must be verified for your workspace. |
| to / cc / bcc | string | string[] | Recipients. Max 50 in total. Suppressed addresses are skipped. |
| reply_to | string | string[] | Optional reply-to addresses. |
| subject | string | Required unless template_id is used. |
| html / text | string | At least one is required (max 512 KB combined). |
| tags | {name, value}[] | Up to 10 tags, [A-Za-z0-9_-]. |
| idempotency_key | string | Or the Idempotency-Key header. Same key + same body returns the original email. |
| attachments | array | Up to 10: {"file_id": "file_…"} from an upload, or {"filename", "content" (base64), "content_type"} for small inline files. 10 MB per file, 20 MB per email. |
| template_id / variables | string / object | Render a saved template; variables are HTML-escaped. |
| category | transactional | subscription | Every email gets List-Unsubscribe headers (one-click when there is a single recipient). subscription requires exactly one recipient. |
| track_opens / track_clicks | boolean | Optional. Overrides the sending domain's tracking setting for this email. Both are off unless turned on. |
Attachments — POST /api/v1/uploads
Requests are limited to 1 MB, so larger files are uploaded in pieces with the same API key: start with {"filename", "content_type", "size", "kind": "attachment"}, send each piece as raw bytes to PUT /api/v1/uploads/:id/chunks/:index (the start response gives chunk_size), then call POST /api/v1/uploads/:id/complete to get a file_id. Executable files (.exe, .js, .bat …) are refused. Every email is also screened automatically for phishing, spam and malware patterns; flagged messages are still sent and are reviewed by our trust & safety team.
Retrieve and list
curl https://csend.net/api/v1/emails/msg_xxx \
-H "Authorization: Bearer cm_live_YOUR_KEY"curl "https://csend.net/api/v1/emails?limit=20&before=msg_xxx" \
-H "Authorization: Bearer cm_live_YOUR_KEY"Contacts — /api/v1/audiences
Keep your campaign audiences in sync from your own system. Keys need the contacts:read or contacts:write scope. Create audiences in the dashboard under Contacts; their ids are shown there and returned by GET /api/v1/audiences. Adding an existing email updates it. Only add people who agreed to receive email from you.
# add or update a contact (custom fields become {{variables}})
curl -X POST https://csend.net/api/v1/audiences/aud_123/contacts \
-H "Authorization: Bearer cm_live_..." -H "Content-Type: application/json" \
-d '{"email":"ada@example.com","first_name":"Ada","data":{"plan":"pro"}}'
# list (50 per page, ?q= searches by email)
curl https://csend.net/api/v1/audiences/aud_123/contacts?page=1 -H "Authorization: Bearer cm_live_..."
# remove
curl -X DELETE https://csend.net/api/v1/audiences/aud_123/contacts/ada@example.com -H "Authorization: Bearer cm_live_..."Message statuses
- queuedStored and waiting for the sending worker.
- retryingA temporary (4xx / network) failure; retried with exponential backoff.
- acceptedThe upstream SMTP server returned 250. This is NOT proof of inbox delivery.
- delivered / bounced / complainedOnly set from real Amazon SES events, when event publishing is configured.
- failedPermanent rejection, or a send interrupted mid-attempt (never resent to avoid duplicates).
- simulatedSent with a cm_test_ key. Nothing left the platform.
Errors and limits
401 invalid/revoked key · 403 missing scope, unverified sender domain or suspended workspace · 409 idempotency conflict · 413 too large · 422 validation or all recipients suppressed · 429 per-minute, daily or monthly limit · 503 sending paused or not configured. Every response carries an X-Request-ID.
Webhook signatures
Each delivery is an HTTPS POST signed with HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}. Reject requests older than 5 minutes.
import crypto from "node:crypto";
// headers: webhook-id, webhook-timestamp, webhook-signature ("v1,<base64>")
function verify(secret, id, ts, rawBody, signatureHeader) {
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window
const key = Buffer.from(secret.replace("whsec_", ""), "base64");
const expected = "v1," + crypto.createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
return signatureHeader.split(" ").some((s) =>
s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}SMTP
The CARIO SMTP gateway accepts mail on port 587 (STARTTLS required, then AUTH PLAIN or LOGIN). Username: cario, password: an API key with the emails:send scope. Messages pass through the same checks, quotas, suppressions and queue as the REST API. Add X-Cario-Tags: key=value,key2=value2 to tag a message (letters, digits, _ and -; other characters become _). Attachments are supported (up to 10 per message).
Availability: the gateway runs on our server, but its host and port are shown in Settings only once a public TCP endpoint is published. Until then, use the REST API.