Documentation

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
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"}]
  }'
node.js
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 }
FieldTypeNotes
fromstringSender. The domain must be verified for your workspace.
to / cc / bccstring | string[]Recipients. Max 50 in total. Suppressed addresses are skipped.
reply_tostring | string[]Optional reply-to addresses.
subjectstringRequired unless template_id is used.
html / textstringAt least one is required (max 512 KB combined).
tags{name, value}[]Up to 10 tags, [A-Za-z0-9_-].
idempotency_keystringOr the Idempotency-Key header. Same key + same body returns the original email.
attachmentsarrayUp 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 / variablesstring / objectRender a saved template; variables are HTML-escaped.
categorytransactional | subscriptionEvery email gets List-Unsubscribe headers (one-click when there is a single recipient). subscription requires exactly one recipient.
track_opens / track_clicksbooleanOptional. 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

GET /api/v1/emails/:id
curl https://csend.net/api/v1/emails/msg_xxx \
  -H "Authorization: Bearer cm_live_YOUR_KEY"
GET /api/v1/emails
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.

contacts
# 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.

verify (node.js)
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.