Guide

Send your first email

Single sends, multi-inbox fan-out, scheduling, threading.

POST /v1/sends is the primitive. Whether you’re firing a one-off, blasting a sequence step across an audience, or replying to a thread, the same endpoint creates the same Send row, enqueues the same worker job, and fires the same events. Campaigns are not load-bearing — they’re one orchestration shape that produces sends, not the only way.

Single send

The simplest call: one inbox, one recipient, immediate delivery.

curl -X POST "https://api.clement.so/v1/sends" \
  -H "Authorization: Bearer $CLEMENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data '{
    "inbox_id": "inb_01HZ8X3J9K7P2QV6R5N4M3T2L1",
    "to": [{"email": "alice@example.com", "name": "Alice"}],
    "subject": "Quick question about your stack",
    "body_text": "Hi {{first_name}}, would love to chat.",
    "variables": {"first_name": "Alice"}
  }'
import { Clement } from "@clement/sdk";
const c = new Clement({ apiKey: process.env.CLEMENT_API_KEY! });

const send = await c.sends.create(
  {
    inbox_id: "inb_01HZ8X3J9K7P2QV6R5N4M3T2L1",
    to: [{ email: "alice@example.com", name: "Alice" }],
    subject: "Quick question about your stack",
    body_text: "Hi {{first_name}}, would love to chat.",
    variables: { first_name: "Alice" },
  },
  { idempotencyKey: crypto.randomUUID() },
);

A few rules:

  • Idempotency-Key is required. Use a stable UUID per logical send — retries with the same key dedupe. A retry that arrives while the first request is still running gets 409 idempotency_request_in_progress; retry again shortly. Errors aren’t cached, so after a 4xx/5xx you can fix the cause and retry with the same key.
  • At least one of body_html or body_text is required; the other is auto-derived.
  • variables are merged into {{mustache}} placeholders. Values are HTML-escaped automatically in body_html.
  • metadata (a {string: string} dict, max 20 keys × 256 chars) is echoed back on every event for that send — use it to correlate to your own records.
  • append_signature: true appends the sending inbox’s signature (set via PATCH /v1/inboxes/{id}) to each body part you supply. It defaults to false: ad-hoc sends go out exactly as written. Campaign sends always carry the signature. The inbox’s reply_to, if set, applies to every send. Replies are only detected in inboxes connected to the workspace, so point reply_to at one of them; replies to any other address don’t stop the sequence.

Schedule for later

Set send_at to an RFC-3339 timestamp:

{ "send_at": "2026-05-01T14:00:00Z", ... }

Scheduled sends still respect per-inbox daily caps and business-hours rules. To bypass those (admin scope only), pass respect_schedule: false.

Multi-inbox fan-out

To send the same email through multiple inboxes — round-robin or least-busy — use from_strategy:

{
  "from_strategy": { "type": "rotate", "inbox_ids": ["inb_A", "inb_B", "inb_C"] },
  "to": [
    { "email": "alice@example.com" },
    { "email": "bob@example.com" }
  ],
  "subject": "...",
  "body_text": "..."
}

from_strategy.type is one of:

  • single — requires inbox_id (not inbox_ids).
  • rotate — round-robin across the listed inboxes.
  • least_busy — picks the inbox with the most remaining capacity in the current window.

Fan-out returns a BulkSendResult with one entry per recipient — the IDs of the created sends and any per-recipient errors (suppression, missing email, etc.). Caps and ramp-ups still apply.

Plan and monthly quota

Live sends need an active or trialing plan: without one, POST /v1/sends and thread replies return 402 subscription_required. A send queued with send_at is checked again when it goes out; if the plan has lapsed by then it fails with failure_reason: "subscription_required". Each recipient counts toward the plan’s monthly send quota as soon as the send is created (queued and in-flight sends included; sends canceled before pickup don’t count), and a fan-out is checked as a whole: if the batch doesn’t fit, nothing is sent and you get 429 quota_exceeded. Test mode is never gated.

Reply in a thread

curl -X POST "https://api.clement.so/v1/threads/thr_01HZ.../replies" \
  -H "Authorization: Bearer $CLEMENT_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data '{ "body_text": "Following up on this — does Tuesday still work?" }'

POST /v1/threads/{id}/replies is sugar over POST /v1/sends that copies the thread’s headers and inbox. Identical engine underneath, including append_signature (default false).

You can also pass thread_id to POST /v1/sends directly. The send must come from the thread’s own inbox (inbox_id, or a single from_strategy); any other inbox returns 400, since a different mailbox can’t continue that conversation. The reply cites the thread’s latest message in In-Reply-To unless you set in_reply_to_message_id.

Cancel a scheduled send

Only valid while the send is in scheduled state:

curl -X POST "https://api.clement.so/v1/sends/snd_.../cancel" \
  -H "Authorization: Bearer $CLEMENT_API_KEY"

Once the worker has the lock and state has flipped to sending, cancellation returns 409 — the email is already on its way out the door.

Lifecycle and events

Each send fires events:

  1. send.scheduled — row created.
  2. send.sending — worker has the lock.
  3. send.sent or send.failed — provider responded.
  4. Subsequent events on the resulting message: message.opened, message.clicked, message.replied, message.bounced.

All of them carry the original send’s metadata. Subscribe to whichever you care about via webhooks.