SendClippersendclipperDocsQuickstartWebhooksSign in

Introduction

SendClipper is a sending API and a real mailbox on one verified domain. Your application sends transactional email through a JSON endpoint, receives delivery events over signed webhooks, and can read the replies that land in a mailbox. Campaigns, a drag-and-drop template designer and sandboxes for test mail come with it. To let an AI assistant do the setup, the MCP server connects Claude Code, Cursor and others to this API.

Base URLhttps://sendclipper.com/v1
FormatJSON request and response bodies, UTF-8. Timestamps are ISO 8601 in UTC. Ids are UUIDs unless a public id is shown (msg_…, tpl_…, evt_…).
AuthAPI key in an Authorization: Bearer header for your servers; a session for the dashboard.
VersioningEverything lives under /v1. Fields and event types are added without notice; nothing is removed or retyped inside /v1. Ignore what you do not recognise.

Quickstart

Four steps from an empty account to a delivered message.

  1. Sign in at /login. Every account has a workspace, and the person it was created for is its Owner.
  2. Add and verify a domain on the Domains screen (or through the API). Publish the DNS records it shows (MX, SPF, DKIM and DMARC) and press Check now. A domain must be verified before anything can be sent from it.
  3. Create an API key on the API Keys screen with the sending permission. The full key (sc_live_…) is shown once; store it in your secret manager.
  4. Send:
cURL
curl -X POST https://sendclipper.com/v1/emails \
  -H "Authorization: Bearer sc_live_<your key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "from": "hello@send.acme.io",
    "to": "ada@lumen.io",
    "subject": "Welcome to Acme",
    "html": "<h1>Hi Ada</h1><p>Thanks for signing up.</p>",
    "text": "Hi Ada, thanks for signing up."
  }'
Node.js (fetch)
// Node.js 18+. No SDK needed: the API is plain JSON over HTTPS.
const res = await fetch("https://sendclipper.com/v1/emails", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SENDCLIPPER_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify({
    from: "hello@send.acme.io",
    to: "ada@lumen.io",
    subject: "Welcome to Acme",
    html: "<h1>Hi Ada</h1><p>Thanks for signing up.</p>",
  }),
});
if (!res.ok) throw new Error(await res.text());
const { id, status } = await res.json(); // status: "queued"
Python (requests)
import os, uuid, requests

r = requests.post(
    "https://sendclipper.com/v1/emails",
    headers={
        "Authorization": f"Bearer {os.environ['SENDCLIPPER_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "from": "hello@send.acme.io",
        "to": "ada@lumen.io",
        "subject": "Welcome to Acme",
        "html": "<h1>Hi Ada</h1><p>Thanks for signing up.</p>",
    },
    timeout=10,
)
r.raise_for_status()
print(r.json()["id"], r.json()["status"])  # queued
Response
HTTP/1.1 201 Created
Content-Type: application/json

{
  "id": "0b6c2d4e-7f18-4c1a-9e3b-2d5f6a7b8c9d",
  "messageId": "msg_9f2b7c10ad",
  "sesMessageId": null,
  "from": "hello@send.acme.io",
  "to": "ada@lumen.io",
  "domain": "send.acme.io",
  "subject": "Welcome to Acme",
  "status": "queued",
  "createdAt": "2026-09-10T09:41:02.518Z",
  "templateId": null
}

The message is accepted as queued and handed to the provider by a background worker within a second or two. Watch it move to sent, delivered and beyond on the Emails screen, with GET /v1/emails/:id, or by subscribing a webhook.

Sending to yourself while testing? Point your app at a sandbox instead: same request body, different token and path, and nothing leaves the platform.

A new workspace can send 100 emails a day (403 sandbox_cap_reached past that; the count resets at midnight UTC). A review lifts the cap, not a setting. Once your domain is verified and you have subscribed to a plan (Settings › Plan & billing), the dashboard banner offers Request review. A person on our side looks at the workspace, and you get an email when it is out of the sandbox, or a note saying what to fix first. Then you can ask again. Sending keeps working up to the cap in the meantime.

Authentication

API keys

An API key is sc_live_ followed by 32 hex characters. Send it as a bearer token on every request:

http
Authorization: Bearer sc_live_9f2b7c10ad42c3d4e5f60718a1b2c3d4

Keys are stored hashed; the dashboard shows only sc_live_9f2b••••••••a17c after creation. A revoked key answers 401 immediately. Every request made with a key is rate-limited per key (below).

GET/v1/api-keysAPI key or session · read
POST/v1/api-keysSession · manage:keys
FieldTypeDescription
namerequiredstring1 to 100 characters. Shown in the list and in the key's audit trail.
permissionrequired"full" | "sending" | "read"What the key may do. See the table below.
domainIduuidRestrict the key to one domain. Omit for a workspace-wide key. A domain the workspace does not own is 404.

The 201 response carries token (the full key, returned once only) plus id, name, masked, permission, domainId, createdAt and lastUsedAt.

DELETE/v1/api-keys/:idSession · manage:keys

Revokes the key. 204; requests with it fail from the next one.

Which routes take an API key. A key reaches the send routes, /v1/mailboxes/**, and an explicit list of management routes, each marked API key or session in this reference. Everything else needs a signed-in session. The groups:

  • Send: POST /v1/emails, /batch.
  • Domains: every route on /v1/domains.
  • Templates: every route on /v1/templates and the template tags, except the two AI-assistant routes (GET ai/providers, POST ai/assist).
  • Sandboxes: every route on /v1/sandboxes, credentials included.
  • Webhooks: /v1/webhook-endpoints, /v1/webhook-dispatches and GET /v1/webhook-deliveries.
  • Suppressions: every route on /v1/suppressions.
  • Contacts: /v1/contacts (including the CSV/vCard export and the import preview) and /v1/audiences.
  • Email logs: GET /v1/emails, /stats/summary and /:id.
  • Mailboxes: /v1/mailboxes/**.
  • Who am I: GET /v1/workspace and the masked GET /v1/api-keys.

A key never mints or revokes a key, and cannot resend a failed message (POST /v1/emails/:id/resend; retry with POST /v1/emails instead). A route outside the list answers with one of three refusals:

StatusCodeWhen
401api_key_not_acceptedThe route needs a signed-in dashboard session.
403api_key_domain_scopedThe key is limited to one domain; management routes need a workspace-wide key.
403insufficient_roleThe key's permission lacks what the route needs (see below).

Permissions and roles

Key permissionCan call
sendingSend (POST /v1/emails, /batch) plus every read route on the list above: mailboxes and their mail, email logs, domains, templates, sandboxes, webhooks, suppressions, contacts. Cannot change anything else.
readRead routes only. Cannot send or change anything.
fullEverything a workspace Admin can do on the API-key routes: send, and manage domains, templates, sandboxes (credentials included), webhooks, suppressions, contacts, email logs and mailboxes.

Dashboard users have a role instead: Owner, Admin, Developer or Viewer. Each route below names the capability it needs; Owner and Admin hold all of them, Developer holds the developer ones (manage:keys, manage:webhooks, send:email, manage:mailboxes, manage:sandboxes, manage:templates, use:mailbox), and Viewer holds read only. A missing capability is 403 insufficient_role naming the roles that can.

Domain-scoped keys

A key created with domainId is bounded to that one domain:

  • It may send only from an address on exactly that domain: acme.io does not cover billing.acme.io. Anything else is 403 api_key_domain_scope.
  • It may manage and read only the mailboxes on that domain. Another domain's mailbox answers 404, never 403.
  • It is refused on management routes (domains, templates, sandboxes, webhooks, suppressions, contacts, email logs) with 403 api_key_domain_scoped, because those span the workspace. Use a workspace-wide key for them.
  • Deleting the domain deletes the key with it.

Give this kind of key to a single service: a support tool that reads support@acme.io cannot send as billing.

Sessions

The dashboard signs in with POST /v1/auth/login and holds a 7-day JWT. An account is created with POST /v1/auth/register, which requires acceptedTermsVersion: the version of the Terms of Service and Acceptable Use Policy the person accepted, currently "2026-09-23" (shown at the top of both pages). Without it the answer is 400 terms_not_accepted, with the value to send in detail.version. Sign-up is by invitation: without the inviteCode from an invite link it answers 403 registration_closed. Session tokens work on every route; API keys work on the send routes, /v1/mailboxes/** and the management routes listed above. Two-factor authentication, password reset and session management (/v1/sessions) live under Settings and are not part of the integration surface.

Conventions

Errors

A deliberate refusal carries a stable machine-readable code in an error envelope. Branch on error.code, show error.message to a person, and read error.detail for specifics (a missing merge tag, a limit, a retry delay).

403: named refusal
{
  "error": {
    "code": "api_key_domain_scope",
    "message": "This API key may only send from acme.io.",
    "detail": { "domain": "billing.acme.io" }
  }
}

A request body that fails validation is a 400 in the framework's own shape: a top-level message array, one sentence per failed field.

400: validation
{
  "statusCode": 400,
  "message": [
    "to must be a valid email address",
    "subject should not be empty"
  ],
  "error": "Bad Request"
}
StatusMeaning
400Validation failed, or a named refusal such as a missing merge tag.
401No credential, an invalid or revoked key, or an expired session.
403The credential is valid but may not do this (role, key permission, domain scope, unverified domain).
404Not found, including anything that belongs to another workspace. Nothing on this API answers 403 for another tenant's object.
409A state conflict: a template that is not published, a resend already in flight, a reused Idempotency-Key with a different body.
429Rate limited. Honour Retry-After.
5xxOur fault. Safe to retry with the same Idempotency-Key.

The error code reference lists every code by route.

Idempotency

Every non-GET request accepts an Idempotency-Key header: 1 to 255 printable ASCII characters (a UUID works well). Retrying with the same key and the same body returns the original response with Idempotency-Replayed: true, so a timed-out send never becomes two emails.

  • Keys are remembered for 24 hours, scoped to the workspace, the route and the credential.
  • Same key, different body: 409 idempotency_key_reused. Same key while the first request is still running: 409 idempotency_key_in_flight with Retry-After: 1. Wait and retry with the same key; do not mint a new one.
  • An empty or malformed key is 400 idempotency_key_invalid rather than silently ignored.
  • Only successful (2xx) responses are stored; a failed request can be retried under the same key.
  • Multipart uploads (POST /v1/contacts/import) cannot be fingerprinted and answer Idempotency-Status: skipped-unparseable-body.

Rate limits

LimitDefaultApplies to
Per key10 requests/second, bursts to 50Each API key, independently.
Per workspace100 requests/secondEvery key and every signed-in user of the workspace together.

Over the limit a request answers 429 with a Retry-After header in whole seconds and the precise delay in milliseconds in detail.retryAfterMs. There are no X-RateLimit-* headers on successful responses. GET /v1/rate-limits returns the workspace's current numbers, which the API Keys screen also shows.

http
HTTP/1.1 429 Too Many Requests
Retry-After: 1

{
  "error": {
    "code": "rate_limited",
    "message": "This API key is limited to 10 requests/second (burst 50). Retry in 1 second.",
    "detail": {
      "limit": "per_key",
      "limitPerSecond": 10,
      "burst": 50,
      "retryAfterMs": 340,
      "workspaceLimitPerSecond": 100
    }
  }
}

Batch sends count as one request however many items they carry. Use the batch endpoint for volume.

Pagination

Two shapes, chosen per list:

  • Page and size: ?page=1&pageSize=50 answering { items, total, page, pageSize }. Used by GET /v1/emails (max 100) and GET /v1/contacts (max 200).
  • Cursor: ?limit=50&cursor=… answering { data, page: { cursor, hasMore } }. Pass page.cursor back to continue; a tampered cursor is 400 invalid_cursor. Used by GET /v1/suppressions and GET /v1/webhook-dispatches, and by the mailbox lists (GET /v1/mailboxes/:id/messages, /v1/mailbox/messages), which keep their items and add page.

Every other list returns a plain array, sometimes with a limit query.

Node.js SDK

@sendclipper/node is the official library for this API. It runs on Node.js 20 or newer, has no runtime dependencies and ships both ESM and CommonJS. The package is on npm and its source is on GitHub.

Install

Use whichever package manager your project already uses:

bash
npm install @sendclipper/node
# or
pnpm add @sendclipper/node
# or
yarn add @sendclipper/node
# or
bun add @sendclipper/node

Create an API key under Settings › API keys, then make a client:

js
import { SendClipper } from '@sendclipper/node';

const sc = new SendClipper('sc_live_xxxxxxxxxxxxxxxx');
// or: new SendClipper()  reads SENDCLIPPER_API_KEY

Send an email

js
const { data, error } = await sc.emails.send({
  from: 'hello@send.acme.io',
  to: 'ada@lumen.io',
  subject: 'Your receipt',
  html: '<p>Thanks for your order.</p>',
  text: 'Thanks for your order.',
});

if (error) {
  console.error(error.name, error.message); // e.g. domain_not_verified
} else {
  console.log(`Email ${data.id} accepted`);
}

from must be a bare address on a domain you verified. Each message has one recipient in to; for several, use batch.send. To send a published template, pass its id or send name with its variables:

js
await sc.emails.send({
  from: 'hello@send.acme.io',
  to: 'ada@lumen.io',
  templateId: 'tpl_9f2b7c10ad42', // or template: 'welcome'
  variables: { first_name: 'Ada' },
});

Errors and retries

A call never throws for an API refusal or a network failure. It resolves to { data, error, headers }, and exactly one of data and error is set. error.name is the API's own code (see Error codes) and error.statusCode is null when no answer arrived.

Network errors, 429 and 5xx answers are retried twice with backoff, honouring Retry-After. Every JSON write carries an Idempotency-Key, so a retried send never goes out twice. Pass your own idempotencyKey to make your own retries safe as well. maxRetries and timeoutMs can be set on the client or per call.

Verify webhooks

Verify each delivery against the raw body, before you parse it:

js
import express from 'express';
import { verifyWebhook, WebhookVerificationError } from '@sendclipper/node';

app.post('/hooks/sendclipper', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const event = verifyWebhook({
      payload: req.body, // a Buffer, not parsed JSON
      headers: req.headers,
      secret: process.env.SENDCLIPPER_WEBHOOK_SECRET,
    });
    console.log(event.event, event.data.to);
    res.sendStatus(200);
  } catch (err) {
    if (err instanceof WebhookVerificationError) return res.status(400).send(err.reason);
    throw err;
  }
});

During a secret rotation either signature is accepted, and a delivery signed more than five minutes away from now is refused (how signing works).

Sandboxes

A sandbox traps mail while you develop, so nothing is delivered. Send to it with its own token, then read the message back in a test:

js
const { data: box } = await sc.sandboxes.create({ name: 'my-app-dev' });
const { data: token } = await sc.sandboxes.resetCredentials(box.id);
await sc.sandboxes.send(token.apiToken, {
  from: 'app@example.com', to: 'test@example.com', subject: 'Hi', html: '<p>Hi</p>',
});

// In a test: wait for the message to arrive
const { data: msg } = await sc.sandboxes.messages.waitFor(box.id, {
  match: (m) => m.subject === 'Hi',
});

What it covers

ResourceMethods
emailssend, get, list, iterate, stats
batchsend
domainslist, create, records, verify, enableReceiving, disableReceiving, setRegion, deletionPreview, remove
templateslist, get, html, create, update, remove, duplicate, publish, unpublish, preview, setTags, projects
sandboxeslist, get, create, update, remove, credentials, resetCredentials, send
sandboxes.messageslist, get, raw, remove, clear, markAllRead, waitFor
webhookslist, create, update, remove, rotateSecret, deliveries, verify
webhooks.dispatcheslist, iterate, get, replay, replayAll
contactslist, iterate, create, import, importPreview, export
suppressionslist, iterate, create, remove, export
mailboxeslist, get, create, update, archive
Management calls need a key with Full access. A sending-only key can call emails.send and batch.send. The README lists every resource, including templates' tags and assets, audiences, automations and React email.

Python SDK

sendclipper is the official Python library for this API. It runs on Python 3.9 or newer, has no runtime dependencies and is fully typed. The package is on PyPI and its source is on GitHub.

Install

Use whichever tool your project already uses:

bash
pip install sendclipper
# or
uv add sendclipper
# or
poetry add sendclipper

Create an API key under Settings › API keys, then make a client:

python
from sendclipper import SendClipper

sc = SendClipper("sc_live_xxxxxxxxxxxxxxxx")
# or: SendClipper()  reads SENDCLIPPER_API_KEY

Send an email

python
data, error = sc.emails.send({
    "from": "hello@send.acme.io",
    "to": "ada@lumen.io",
    "subject": "Your receipt",
    "html": "<p>Thanks for your order.</p>",
    "text": "Thanks for your order.",
})

if error:
    print(error.name, error.message)  # e.g. domain_not_verified
else:
    print(f"Email {data['id']} accepted")

Parameters and responses are plain dicts keyed by the API's own field names (templateId, sendClass), so everything on this page applies as written. from must be a bare address on a domain you verified, with one recipient in to; for several, use batch.send. To send a published template, pass its id or send name with its variables:

python
sc.emails.send({
    "from": "hello@send.acme.io",
    "to": "ada@lumen.io",
    "templateId": "tpl_9f2b7c10ad42",  # or "template": "welcome"
    "variables": {"first_name": "Ada"},
})

Errors and retries

A call never raises for an API refusal or a network failure. It returns a result with data, error and headers that unpacks to data, error, and exactly one of the two is set. error.name is the API's own code (see Error codes) and error.status_code is None when no answer arrived. .unwrap() returns data or raises SendClipperApiError.

Network errors, 429 and 5xx answers are retried twice with backoff, honouring Retry-After. Every JSON write carries an Idempotency-Key, so a retried send never goes out twice. Pass your own idempotency_key= to make your own retries safe as well. max_retries and timeout (seconds) can be set on the client or per call.

Verify webhooks

Verify each delivery against the raw body, before you parse it. The same call works in Django (request.body) and FastAPI (await request.body()):

python
import os
from flask import Flask, request
from sendclipper import verify_webhook, WebhookVerificationError

app = Flask(__name__)

@app.post("/hooks/sendclipper")
def sendclipper_hook():
    try:
        event = verify_webhook(
            request.get_data(),  # raw bytes, not request.json
            request.headers,
            os.environ["SENDCLIPPER_WEBHOOK_SECRET"],
        )
    except WebhookVerificationError as err:
        return err.reason, 400
    print(event["event"], event["data"]["to"])
    return "", 200

During a secret rotation either signature is accepted, and a delivery signed more than five minutes away from now is refused (how signing works).

Sandboxes

A sandbox traps mail while you develop, so nothing is delivered. Send to it with its own token, then read the message back in a test:

python
box = sc.sandboxes.create({"name": "my-app-dev"}).unwrap()
token = sc.sandboxes.reset_credentials(box["id"]).unwrap()
sc.sandboxes.send(token["apiToken"], {
    "from": "app@example.com", "to": "test@example.com", "subject": "Hi", "html": "<p>Hi</p>",
})

# In a test: wait for the message to arrive
msg, error = sc.sandboxes.messages.wait_for(box["id"], match=lambda m: m["subject"] == "Hi")

What it covers

ResourceMethods
emailssend, get, list, iterate, stats
batchsend
domainslist, create, records, verify, enable_receiving, disable_receiving, set_region, deletion_preview, remove
templateslist, get, html, create, update, remove, duplicate, publish, unpublish, preview, set_tags, projects
sandboxeslist, get, create, update, remove, credentials, reset_credentials, send
sandboxes.messageslist, get, raw, remove, clear, mark_all_read, wait_for
webhookslist, create, update, remove, rotate_secret, deliveries, verify
webhooks.dispatcheslist, iterate, get, replay, replay_all
contactslist, iterate, create, import_, import_preview, export
suppressionslist, iterate, create, remove, export
mailboxeslist, get, create, update, archive
Management calls need a key with Full access. A sending-only key can call emails.send and batch.send. contacts.import_ has a trailing underscore because import is a Python keyword. The README lists every resource, including templates' tags and assets, audiences and automations.

PHP SDK

sendclipper/sendclipper-php is the official PHP library for this API. It runs on PHP 8.1 or newer and needs only the curl and json extensions. The package is on Packagist and its source is on GitHub.

Install

bash
composer require sendclipper/sendclipper-php

Create an API key under Settings › API keys, then make a client:

php
use SendClipper\SendClipper;

$sc = new SendClipper('sc_live_xxxxxxxxxxxxxxxx');
// or: new SendClipper()  reads SENDCLIPPER_API_KEY

Send an email

php
[$data, $error] = $sc->emails->send([
    'from' => 'hello@send.acme.io',
    'to' => 'ada@lumen.io',
    'subject' => 'Your receipt',
    'html' => '<p>Thanks for your order.</p>',
    'text' => 'Thanks for your order.',
]);

if ($error) {
    echo $error->name, ': ', $error->message; // e.g. domain_not_verified
} else {
    echo "Email {$data['id']} accepted";
}

Parameters and responses are plain arrays keyed by the API's own field names (templateId, sendClass), so everything on this page applies as written. from must be a bare address on a domain you verified, with one recipient in to; for several, use batch->send. To send a published template, pass its id or send name with its variables:

php
$sc->emails->send([
    'from' => 'hello@send.acme.io',
    'to' => 'ada@lumen.io',
    'templateId' => 'tpl_9f2b7c10ad42', // or 'template' => 'welcome'
    'variables' => ['first_name' => 'Ada'],
]);

Errors and retries

A call never throws for an API refusal or a network failure. It returns a Result with data, error and headers that destructures to [$data, $error], and exactly one of the two is set. $error->name is the API's own code (see Error codes) and $error->statusCode is null when no answer arrived. ->unwrap() returns the data or throws an ApiException.

Network errors, 429 and 5xx answers are retried twice with backoff, honouring Retry-After. Every JSON write carries an Idempotency-Key, so a retried send never goes out twice. Pass ['idempotencyKey' => ...] as the last argument to make your own retries safe as well; maxRetries and timeout (seconds) can be set on the client or per call.

Verify webhooks

Verify each delivery against the raw body, before you decode it. In Laravel or Symfony pass $request->getContent() and $request->headers; a PSR-7 request works too:

php
use SendClipper\Exception\WebhookVerificationException;
use SendClipper\Webhook;

try {
    $event = Webhook::verify(
        file_get_contents('php://input'), // the raw body, not json_decode()d
        getallheaders(),
        (string) getenv('SENDCLIPPER_WEBHOOK_SECRET'),
    );
} catch (WebhookVerificationException $e) {
    http_response_code(400);
    exit($e->reason);
}
error_log($event['event'] . ' ' . $event['data']['to']);
http_response_code(200);

During a secret rotation either signature is accepted, and a delivery signed more than five minutes away from now is refused (how signing works).

Sandboxes

A sandbox traps mail while you develop, so nothing is delivered. Send to it with its own token, then read the message back in a test:

php
$box = $sc->sandboxes->create(['name' => 'my-app-dev'])->unwrap();
$token = $sc->sandboxes->resetCredentials($box['id'])->unwrap();
$sc->sandboxes->send($token['apiToken'], [
    'from' => 'app@example.com', 'to' => 'test@example.com', 'subject' => 'Hi', 'html' => '<p>Hi</p>',
]);

// In a test: wait for the message to arrive
[$msg, $error] = $sc->sandboxes->messages->waitFor($box['id'], fn (array $m) => $m['subject'] === 'Hi');

What it covers

ResourceMethods
emailssend, get, list, iterate, stats
batchsend
domainslist, create, records, verify, enableReceiving, disableReceiving, setRegion, deletionPreview, remove
templateslist, get, html, create, update, remove, duplicate, publish, unpublish, preview, setTags, projects
sandboxeslist, get, create, update, remove, credentials, resetCredentials, send
sandboxes->messageslist, get, raw, remove, clear, markAllRead, waitFor
webhookslist, create, update, remove, rotateSecret, deliveries, verify
webhooks->dispatcheslist, iterate, get, replay, replayAll
contactslist, iterate, create, import, importPreview, export
suppressionslist, iterate, create, remove, export
mailboxeslist, get, create, update, archive
Management calls need a key with Full access. A sending-only key can call emails->send and batch->send. The README lists every resource, including templates' tags and assets, audiences and automations.

Go SDK

github.com/sendclipper/sendclipper-go is the official Go library for this API. It needs Go 1.23 or newer and nothing outside the standard library. Its reference is on pkg.go.dev and its source is on GitHub.

Install

bash
go get github.com/sendclipper/sendclipper-go

Create an API key under Settings › API keys, then make a client. One client is safe to share across goroutines:

go
import "github.com/sendclipper/sendclipper-go"

client := sendclipper.NewClient("sc_live_xxxxxxxxxxxxxxxx")
// or: sendclipper.NewClient("")  reads SENDCLIPPER_API_KEY

Send an email

go
email, err := client.Emails.Send(ctx, &sendclipper.SendEmailParams{
	From:    "hello@send.acme.io",
	To:      "ada@lumen.io",
	Subject: "Your receipt",
	HTML:    "<p>Thanks for your order.</p>",
	Text:    "Thanks for your order.",
})
if err != nil {
	var apiErr *sendclipper.Error
	if errors.As(err, &apiErr) {
		log.Println(apiErr.Name, apiErr.Message) // e.g. domain_not_verified
	}
	return err
}
fmt.Println("Email", email.ID, "accepted")

Every method takes a context.Context and typed params. From must be a bare address on a domain you verified, with one recipient in To; for several, use Batch.Send. To send a published template, pass its id or send name with its Variables:

go
client.Emails.Send(ctx, &sendclipper.SendEmailParams{
	From:       "hello@send.acme.io",
	To:         "ada@lumen.io",
	TemplateID: "tpl_9f2b7c10ad42", // or Template: "welcome"
	Variables:  map[string]any{"first_name": "Ada"},
})

Errors and retries

Every failure is a *sendclipper.Error. Name is the API's own code (see Error codes) and StatusCode is 0 when no answer arrived; a network_error wraps the transport error, so errors.Is(err, context.DeadlineExceeded) works. Lists that span pages have an All method you can range over.

Network errors, 429 and 5xx answers are retried twice with backoff, honouring Retry-After. Every JSON write carries an Idempotency-Key, so a retried send never goes out twice. Pass sendclipper.WithIdempotencyKey(key) to make your own retries safe as well; the context bounds the whole call, retries included.

Verify webhooks

Verify each delivery against the raw body, before you decode it:

go
http.HandleFunc("/hooks/sendclipper", func(w http.ResponseWriter, r *http.Request) {
	payload, _ := io.ReadAll(r.Body) // the raw body, before any json.Decode
	event, err := sendclipper.VerifyWebhook(payload, r.Header, os.Getenv("SENDCLIPPER_WEBHOOK_SECRET"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	log.Println(event.Event, event.Data.To)
})

During a secret rotation either signature is accepted, and a delivery signed more than five minutes away from now is refused (how signing works).

Sandboxes

A sandbox traps mail while you develop, so nothing is delivered. Send to it with its own token, then read the message back in a test:

go
box, _ := client.Sandboxes.Create(ctx, &sendclipper.SandboxParams{Name: "my-app-dev"})
token, _ := client.Sandboxes.ResetCredentials(ctx, box.ID)
client.Sandboxes.Send(ctx, token.APIToken, &sendclipper.SendEmailParams{
	From: "app@example.com", To: "test@example.com", Subject: "Hi", HTML: "<p>Hi</p>",
})

// In a test: wait for the message to arrive (bound it with the context)
wctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
msg, err := client.Sandboxes.Messages.WaitFor(wctx, box.ID, &sendclipper.WaitForOptions{
	Match: func(m sendclipper.SandboxMessageSummary) bool { return m.Subject == "Hi" },
})

What it covers

ServiceMethods
EmailsSend, Get, List, All, Stats
BatchSend
DomainsList, Create, Records, Verify, EnableReceiving, DisableReceiving, SetRegion, DeletionPreview, Remove
TemplatesList, Get, HTML, Create, Update, Remove, Duplicate, Publish, Unpublish, Preview, SetTags, Projects
SandboxesList, Get, Create, Update, Remove, Credentials, ResetCredentials, Send
Sandboxes.MessagesList, Get, Raw, Remove, Clear, MarkAllRead, WaitFor
WebhooksList, Create, Update, Remove, RotateSecret, Deliveries, Verify
Webhooks.DispatchesList, All, Get, Replay, ReplayAll
ContactsList, All, Create, Import, ImportPreview, Export
SuppressionsList, All, Create, Remove, Export
MailboxesList, Get, Create, Update, Archive
Management calls need a key with Full access. A sending-only key can call Emails.Send and Batch.Send. The README and pkg.go.dev list every service, including templates' tags and assets, audiences and automations.

Ruby SDK

sendclipper is the official Ruby library for this API. It runs on Ruby 3.1 or newer and has no runtime dependencies. The gem is on RubyGems and its source is on GitHub.

Install

bash
gem install sendclipper
# or, in a Gemfile
bundle add sendclipper

Create an API key under Settings › API keys, then make a client:

ruby
require "sendclipper"

client = SendClipper::Client.new("sc_live_xxxxxxxxxxxxxxxx")
# or: SendClipper::Client.new  reads SENDCLIPPER_API_KEY

Send an email

ruby
data, error = client.emails.send(
  from: "hello@send.acme.io",
  to: "ada@lumen.io",
  subject: "Your receipt",
  html: "<p>Thanks for your order.</p>",
  text: "Thanks for your order."
)

if error
  puts "#{error.name}: #{error.message}" # e.g. domain_not_verified
else
  puts "Email #{data["id"]} accepted"
end

Fields are keyword arguments. snake_case names such as template_id: become the API's templateId; values such as variables are sent as given, and responses are Hashes with the API's keys. from must be a bare address on a domain you verified, with one recipient in to; for several, use batch.send. To send a published template:

ruby
client.emails.send(
  from: "hello@send.acme.io",
  to: "ada@lumen.io",
  template_id: "tpl_9f2b7c10ad42", # or template: "welcome"
  variables: { first_name: "Ada" }
)

Errors and retries

A call never raises for an API refusal or a network failure. It returns a SendClipper::Result with data, error and headers that destructures to data, error and works in case … in; exactly one of the two is set. error.name is the API's own code (see Error codes) and error.status_code is nil when no answer arrived. .unwrap returns the data or raises SendClipper::ApiError.

Network errors, 429 and 5xx answers are retried twice with backoff, honouring Retry-After. Every JSON write carries an Idempotency-Key, so a retried send never goes out twice. Pass idempotency_key: to make your own retries safe as well; max_retries: and timeout: (seconds) can be set on the client or per call.

Verify webhooks

Verify each delivery against the raw body, before you parse it. In Rack or Sinatra pass request.body.read and request.env:

ruby
class SendClipperWebhooksController < ActionController::API
  def create
    event = SendClipper.verify_webhook(
      request.raw_post, # the raw body, not params
      request.headers,
      ENV.fetch("SENDCLIPPER_WEBHOOK_SECRET")
    )
    Rails.logger.info("#{event["event"]} #{event["data"]["to"]}")
    head :ok
  rescue SendClipper::WebhookVerificationError => e
    render plain: e.reason, status: :bad_request
  end
end

During a secret rotation either signature is accepted, and a delivery signed more than five minutes away from now is refused (how signing works).

Sandboxes

A sandbox traps mail while you develop, so nothing is delivered. Send to it with its own token, then read the message back in a test:

ruby
box = client.sandboxes.create(name: "my-app-dev").unwrap
token = client.sandboxes.reset_credentials(box["id"]).unwrap
client.sandboxes.send(token["apiToken"],
  from: "app@example.com", to: "test@example.com", subject: "Hi", html: "<p>Hi</p>")

# In a test: wait for the message to arrive
msg, error = client.sandboxes.messages.wait_for(box["id"]) { |m| m["subject"] == "Hi" }

What it covers

ResourceMethods
emailssend, get, list, each, stats
batchsend
domainslist, create, records, verify, enable_receiving, disable_receiving, set_region, deletion_preview, remove
templateslist, get, html, create, update, remove, duplicate, publish, unpublish, preview, set_tags, projects
sandboxeslist, get, create, update, remove, credentials, reset_credentials, send
sandboxes.messageslist, get, raw, remove, clear, mark_all_read, wait_for
webhookslist, create, update, remove, rotate_secret, deliveries, verify
webhooks.dispatcheslist, each, get, replay, replay_all
contactslist, each, create, import, import_preview, export
suppressionslist, each, create, remove, export
mailboxeslist, get, create, update, archive
Management calls need a key with Full access. A sending-only key can call emails.send and batch.send. The README lists every resource, including templates' tags and assets, audiences and automations.

Rust SDK

sendclipper is the official Rust library for this API. It is async, on tokio and reqwest, with TLS through rustls by default, and needs Rust 1.85 or newer. The crate is on crates.io, its reference on docs.rs and its source on GitHub.

Install

bash
cargo add sendclipper
cargo add tokio --features macros,rt-multi-thread

Create an API key under Settings › API keys, then make a client. It is cheap to clone and safe to share across tasks:

rust
let client = sendclipper::Client::new("sc_live_xxxxxxxxxxxxxxxx");
// or: sendclipper::Client::from_env()  reads SENDCLIPPER_API_KEY

Send an email

rust
use sendclipper::{Client, SendEmailParams};

#[tokio::main]
async fn main() -> Result<(), sendclipper::Error> {
    let client = Client::from_env();
    let email = client
        .emails()
        .send(&SendEmailParams {
            from: "hello@send.acme.io".into(),
            to: "ada@lumen.io".into(),
            subject: Some("Your receipt".into()),
            html: Some("<p>Thanks for your order.</p>".into()),
            ..Default::default()
        })
        .await?;
    println!("Email {} accepted", email.id);
    Ok(())
}

Params and responses are typed structs. from must be a bare address on a domain you verified, with one recipient in to; for several, use batch().send(). To send a published template, pass its id or send name with its variables:

rust
let mut variables = sendclipper::Object::new();
variables.insert("first_name".into(), "Ada".into());

client
    .emails()
    .send(&SendEmailParams {
        from: "hello@send.acme.io".into(),
        to: "ada@lumen.io".into(),
        template_id: Some("tpl_9f2b7c10ad42".into()), // or template: Some("welcome".into())
        variables: Some(variables),
        ..Default::default()
    })
    .await?;

Errors and retries

Every method returns Result<T, sendclipper::Error>. name is the API's own code (see Error codes) and status_code is None when no answer arrived. Lists that span pages have a list_all method that returns a Stream.

Network errors, 429 and 5xx answers are retried twice with backoff, honouring Retry-After. Every JSON write carries an Idempotency-Key, so a retried send never goes out twice. Call client.with_idempotency_key(key) to make your own retries safe as well; with_timeout and with_max_retries work the same way.

Verify webhooks

Verify each delivery against the raw body, before you parse it. The headers can be an http::HeaderMap (axum, actix-web, hyper):

rust
use axum::{body::Bytes, http::{HeaderMap, StatusCode}};

async fn sendclipper_hook(headers: HeaderMap, body: Bytes) -> StatusCode {
    let secret = std::env::var("SENDCLIPPER_WEBHOOK_SECRET").unwrap_or_default();
    match sendclipper::verify_webhook(&body, &headers, &secret, Default::default()) {
        Ok(event) => {
            println!("{} {}", event.event, event.data["to"]);
            StatusCode::OK
        }
        Err(e) => {
            eprintln!("refused: {}", e.reason);
            StatusCode::BAD_REQUEST
        }
    }
}

During a secret rotation either signature is accepted, and a delivery signed more than five minutes away from now is refused (how signing works).

Sandboxes

A sandbox traps mail while you develop, so nothing is delivered. Send to it with its own token, then read the message back in a test:

rust
let sandbox = client
    .sandboxes()
    .create(&sendclipper::SandboxParams { name: Some("my-app-dev".into()), ..Default::default() })
    .await?;
let token = client.sandboxes().reset_credentials(&sandbox.id).await?;
client.sandboxes().send(&token.api_token, &params).await?;

// In a test: wait for the message to arrive
let msg = client
    .sandboxes()
    .messages()
    .wait_for(&sandbox.id, |m| m.subject == "Hi", Duration::from_secs(30), Duration::from_secs(1))
    .await?;

What it covers

ResourceMethods
emails()send, get, list, list_all, stats
batch()send
domains()list, create, records, verify, enable_receiving, disable_receiving, set_region, deletion_preview, remove
templates()list, get, html, create, update, remove, duplicate, publish, unpublish, preview, set_tags, projects
sandboxes()list, get, create, update, remove, credentials, reset_credentials, send
sandboxes().messages()list, get, raw, remove, clear, mark_all_read, wait_for, wait_for_next
webhooks()list, create, update, remove, rotate_secret, deliveries, verify
webhooks().dispatches()list, list_all, get, replay, replay_all
contacts()list, list_all, create, import, import_preview, export
suppressions()list, list_all, create, remove, export
mailboxes()list, get, create, update, archive
Management calls need a key with Full access. A sending-only key can call emails().send and batch().send. The README and docs.rs list every resource, including templates' tags and assets, audiences and automations.

MCP server

The SendClipper MCP server lets any Model Context Protocol client (Claude Code, Claude Desktop, Cursor, VS Code, Windsurf) set up and run SendClipper for a project: add and verify a domain, design templates and set their merge tags, trap development mail in a sandbox, generate the sending code for your stack, send, and wire webhooks. It is a thin client over this API: every rule, permission and refusal stays in the API, and the server relays its error codes with a hint about what to do next.

You need an API key: create one under Settings › API keys with Full access, workspace-wide. A key limited to one domain is refused on the management tools (which routes take a key); a Sending only or Read only key works for the tools its permission allows.

Connect in one line

SendClipper hosts the server at https://sendclipper.com/mcp, so there is nothing to install. In Claude Code:

bash
claude mcp add --transport http sendclipper https://sendclipper.com/mcp \
  --header "Authorization: Bearer sc_live_…"

Cursor, Windsurf and other clients that connect to a URL take the same two values:

json
{
  "mcpServers": {
    "sendclipper": {
      "url": "https://sendclipper.com/mcp",
      "headers": { "Authorization": "Bearer sc_live_…" }
    }
  }
}

Your key travels in the Authorization header of each request and is not stored: the hosted server holds no key of its own and passes yours to the API, which applies its permission and rate limit. It runs on SendClipper's machine, so it cannot read files on yours. Give template_upload_image the picture as base64 rather than a path. Claude Desktop cannot send a header to a remote server yet; use the file below.

Use it

Check the connection with claude mcp list (it should read Connected), or /mcp inside Claude Code. Then ask in plain language from your project, for example:

You sayWhat happens
“Check my SendClipper setup”Shows your workspace and the next setup step
“Add the domain example.com and give me the DNS records”Adds the domain and lists the records to publish at your DNS provider
“Verify example.com”Checks the DNS (run it again once your records are published)
“Create a welcome email template with first_name and a button to {{app_url}}”Designs the template
“Which params does the welcome template take? Set first_name to default to 'there'”Lists the merge tags and sets the default
“Preview it with first_name Ada, then publish it”Shows the rendered email, then makes it sendable
“Create a sandbox for this project and add its settings to my .env”Development mail gets caught instead of sent
“Send a test of the welcome template into the sandbox and show it to me”Sends it into the sandbox and reads it back
“Write the code to send the welcome email from this Next.js app”Code for your stack
“Install the SendClipper SDK in this project”Adds the official SDK for your language and a shared client
“Create a webhook to https://myapp.com/hooks/email”Delivery events go to your app
“What new mail came in to support@? Summarize the unread ones”Reads the inbox and sums up each message

Your client asks before each action, and during development the server sends into a sandbox. No real mail leaves unless you ask for a real send. Remove it with claude mcp remove sendclipper.

Or run it yourself

The same server is one dependency-free file (Node.js 20+), served by https://sendclipper.com. Run it on your own computer when your client cannot connect to a URL, or when you want it to read local files:

bash
mkdir -p ~/.sendclipper
curl -fsSL https://sendclipper.com/sendclipper-mcp.cjs -o ~/.sendclipper/sendclipper-mcp.cjs

Connect

Claude Code, running the downloaded file:

bash
claude mcp add sendclipper \
  -e SENDCLIPPER_API_KEY=sc_live_… \
  -- node ~/.sendclipper/sendclipper-mcp.cjs

Claude Desktop, Cursor and VS Code read an mcpServers block from their settings file:

json
{
  "mcpServers": {
    "sendclipper": {
      "command": "node",
      "args": ["/Users/you/.sendclipper/sendclipper-mcp.cjs"],
      "env": { "SENDCLIPPER_API_KEY": "sc_live_…" }
    }
  }
}

Streamable HTTP

To host it yourself for clients that connect over HTTP, run the server with --http. It binds 127.0.0.1 (which also turns on DNS-rebinding protection) and serves a stateless endpoint at POST http://127.0.0.1:3333/mcp.

bash
node ~/.sendclipper/sendclipper-mcp.cjs --http --port 3333   # binds 127.0.0.1

A request's own Authorization: Bearer sc_live_… wins over SENDCLIPPER_API_KEY, so one process can serve several workspaces. --host changes the bind address.

Configuration

VariableDefaultMeaning
SENDCLIPPER_API_KEYNoneThe sc_live_… workspace key.
SENDCLIPPER_API_URLhttps://sendclipper.comAPI origin, without /v1.
SENDCLIPPER_APP_URLderivedDashboard origin used in links.
SENDCLIPPER_SANDBOX_TOKENNoneAn sc_sbx_… token; lets sandbox_send_test_email use the sandbox JSON door instead of SMTP.
SENDCLIPPER_PUBLIC_API_URLthe API URLAPI origin shown in generated snippets and .env lines, when the server reaches the API another way.
SENDCLIPPER_MCP_ALLOWED_HOSTSNoneHost names --http accepts besides loopback (same as --allowed-hosts); required to bind anything but loopback.
SENDCLIPPER_MCP_HOSTEDoffon when serving other people: no local files, and only each request's own key counts.

Tools

82 tools, grouped by area. Destructive ones are annotated so a client asks before running them.

AreaToolWhat it does
Accountaccount_whoamiWhich workspace the key belongs to and what it may do.
account_setup_statusThe setup checklist: domain, sandbox, template, key.
Domainsdomain_listList sending domains and their status.
domain_addAdd a sending domain.
domain_get_dns_recordsThe DNS records to publish.
domain_verifyCheck a domain now.
domain_enable_receivingTurn receiving on for a domain.
domain_disable_receivingTurn receiving off.
domain_set_regionChange a domain's region (before it holds mail).
domain_deletion_previewWhat deleting a domain would remove.
domain_deleteDelete a domain.
Templatestemplate_listList templates, optionally by project or tag.
template_projects_listList the Email development projects (one per domain).
template_getRead one template with its blocks.
template_createCreate a template from blocks.
template_updateChange a template's blocks, subject or name.
template_get_variablesList a template's merge tags.
template_set_variable_defaultsSet merge-tag fallbacks.
template_rename_variableRename a merge tag everywhere it is used.
template_previewRender with variables: subject, text, unsafe links.
template_get_htmlExport the rendered HTML.
template_publishMake a template sendable.
template_unpublishTake it back to draft.
template_duplicateCopy a template.
template_deleteDelete a template.
template_tag_listList template tags.
template_tag_createCreate a tag.
template_tag_deleteDelete a tag.
template_set_tagsSet the tags on a template.
template_list_imagesList uploaded template images.
template_upload_imageUpload an image for an image block.
Sendingemail_sendSend one real email, by html or by template.
email_send_batchSend up to a batch of real emails.
email_listList sent emails.
email_getOne sent email with its event timeline.
Sandboxessandbox_listList sandboxes.
sandbox_createCreate a sandbox.
sandbox_getOne sandbox with its inbox address and counts.
sandbox_renameRename (credentials do not move).
sandbox_deleteDelete a sandbox and its messages.
sandbox_get_credentialsSMTP credentials and the inbox address.
sandbox_reset_credentialsReplace the SMTP password and API token.
sandbox_list_messagesList trapped messages.
sandbox_get_messageRead one trapped message.
sandbox_get_rawThe raw MIME source.
sandbox_delete_messageDelete one trapped message.
sandbox_clearDelete every message in a sandbox.
sandbox_mark_readMark messages read.
sandbox_send_test_emailSend a test email into a sandbox.
sandbox_wait_for_messageWait until a message arrives.
Webhookswebhook_event_typesThe event types an endpoint can subscribe to.
webhook_listList endpoints.
webhook_createCreate an endpoint.
webhook_updateChange an endpoint's URL, events or state.
webhook_deleteDelete an endpoint.
webhook_rotate_secretRotate the signing secret.
webhook_verify_examplesSignature-verification snippets.
webhook_list_undeliveredDead-lettered events.
webhook_replayReplay one dead-lettered event.
webhook_replay_allReplay every dead-lettered event in scope.
Inboxmailbox_listList receiving addresses with unread counts.
mailbox_getOne address: can it receive, can it send, counts.
mailbox_list_aliasesThe aliases that deliver into an address.
inbox_list_messagesList an address's mail by folder, search or label.
inbox_searchSearch mail across every address.
inbox_get_messageRead one message: body, attachments, verdicts.
inbox_get_threadRead a whole conversation, oldest first.
inbox_get_attachmentDownload one attachment.
inbox_wait_for_messageWait until a message arrives at an address.
Audienceaudience_listList audiences.
contact_listList contacts.
contact_createAdd a contact.
contact_importImport contacts from CSV text.
suppression_listList suppressed addresses.
suppression_addSuppress an address by hand.
suppression_removeLift a removable suppression.
Integrationintegration_snippetSending code for the project's stack.
integration_envA .env block for a sandbox or the live API.
SDKssdk_listThe six official SDKs, their versions and install commands.
sdk_detectPick the SDK and package manager from the project's files.
sdk_installInstall command, .env lines and a client module for the project.
sdk_snippetSDK code to send, send a template or a batch, verify webhooks, or use a sandbox.

Resources and prompts

Four resources a client can read for context:

ResourceContents
sendclipper://docs/template-blocksEvery template block type and field, limits, link and colour rules, merge-tag syntax and a worked example. Read before writing blocks.
sendclipper://docs/sending-templatesSending a template by id or by name, variables rules and refusal codes.
sendclipper://templates/{id}One template as JSON; templates are also listed.
sendclipper://templates/{id}/htmlThe rendered HTML export of one template.

Four prompts start a guided workflow from your client's prompt menu (in Claude Code, /mcp__sendclipper__setup_project):

PromptWhat it walks through
setup_projectConnect a codebase: domain, DNS, a development sandbox, a first template and the sending code.
design_templateDesign, preview and optionally publish a template from a short brief.
test_in_sandboxPoint the app at a sandbox, trigger a flow and inspect what it sent.
debug_deliveryFind why a message did not arrive: status, events, suppressions, domain health.

Safety

  • Management tools need a Full access key; the server adds no permissions of its own, so a narrower key gets the API's 403 insufficient_role relayed.
  • The server never sends real mail unless you ask. email_send and email_send_batch are marked open-world, and the built-in instructions steer assistants to sandbox_send_test_email while developing.
  • Destructive tools (deletes, sandbox_clear, credential resets) carry the MCP destructive annotation, so clients confirm first.
  • Secrets (signing secrets, SMTP passwords, API tokens) are not printed by default; ask for them explicitly (sandbox_get_credentials, integration_env).
Treat the key like any server credential: put it in the client's env block or your secret manager, never in a repository.

Send email

Send a message

POST/v1/emailsAPI key or session · send:email
FieldTypeDescription
fromrequiredstringA plain address on a verified domain of this workspace, e.g. hello@send.acme.io. A verified domain covers its subdomains. Display-name forms such as Acme <hello@…> are refused with 400.
torequiredstringOne recipient address. Send a batch for more.
subjectstringRequired with html; optional with templateId (the template's own subject is the default, a non-empty value overrides it). Up to 998 characters, no line breaks.
htmlstringThe HTML body. Required unless templateId is set; sending both is 400.
textstringPlain-text alternative, up to 100 000 characters. Generated from the template blocks when omitted on a template send.
templateIdstringA published template, by tpl_… public id or row id. See Sending a template.
templatestringA published template, by its send name ("registration"), looked up in the Email development project of the from domain. Never with templateId. See Sending a template by name.
variablesobjectMerge-tag values for the template, or for the {{tags}} in your own html, subject and text when you send no template. A flat object, ≤ 100 keys, string/number/boolean values ≤ 10 000 characters each. Omit it and your html is sent exactly as written.
sendClass"transactional" | "marketing"Defaults to transactional (receipts, resets, alerts; delivered even to an address that unsubscribed from a newsletter). marketing is refused with 403 for an unsubscribed address and carries a per-recipient one-click unsubscribe link: List-Unsubscribe headers on the wire, and {{unsubscribe_url}} filled in on a template send when you do not supply it. See Unsubscribe. A marketing message also carries your workspace's postal address in its footer (appended when the body does not already show it) and is refused with 400 postal_address_missing while none is on file. See Postal address.
trackOpensbooleanDefaults to true. false leaves the open-tracking pixel out of this message, so it never records an opened event.
trackClicksbooleanDefaults to true. false sends every link exactly as written instead of through the click-tracking redirect, so the message never records a clicked event. Turning tracking off on one-to-one mail from a new domain makes it look more like ordinary mail to spam filters.

201 answers with the message: id, messageId (the msg_… public id, which is also the local part of the message's Message-ID header), from, to, domain, subject, status (queued), createdAt, sesMessageId (the provider's id, filled once sent) and templateId.

RefusalStatusWhy
domain_unknown403from is not on a domain of this workspace.
domain_not_verified403The domain exists but its DNS is not confirmed. Publish the records and press Check now.
domain_failed403A sending record was found broken on the last check. The message names the record; detail.domain names the from domain.
domain_not_verified_for_sending_provider403The domain is verified in SendClipper, but the provider SendClipper currently sends through (Scaleway Transactional Email or Microsoft Azure) is not ready for it yet. Publish the “Sending · Scaleway” or “Sending · Azure” records shown for the domain under Domains, then press Check now. Nothing is stored or queued.
provider_sender_not_registered403SendClipper currently sends through Microsoft Azure, and Azure only sends from addresses registered with it for that domain (by default only donotreply@). The message names the address that can send. Nothing is stored or queued.
api_key_domain_scope403A domain-scoped key sending from another domain.
recipient_suppressed403The recipient is on the suppression list (hard bounce, complaint, unsubscribe or manual). detail carries the reason.
sandbox_cap_reached403A new workspace's daily sending allowance is spent. It resets at midnight UTC. The cap lifts only after a person on our side reviews the workspace: with a verified domain AND a subscription (Settings › Plan & billing), press Request review on the dashboard. Subscribing alone does not lift the cap. The message says where the review stands.
postal_address_missing400sendClass is marketing and the workspace has no postal address on file. Add one under Settings › Workspace. A template send is checked before the template is resolved (so before the domain check); a hand-written body is checked right after the domain check. Nothing is stored or queued. Transactional sends are unaffected. Sending a failed marketing message again hits the same check.
workspace_sending_paused409This workspace's sending is paused: by an operator, automatically for its bounce or complaint rate, or because its subscription payment failed or the subscription ended. The message names the reason and what to do (for a billing pause, resolve it under Settings › Plan & billing and sending resumes on its own). Nothing is stored or queued. The same 409 comes back from the batch door (for the whole request), from a broadcast's send, test and schedule, and from a mailbox reply.
template_*400 / 404 / 409See template sends.

Delivery status

A message moves forward through one status at a time and never back. Every transition is also a row on the message's event timeline and, for the six deliverable ones, a webhook.

StatusMeaning
queuedAccepted and waiting for the send worker.
sentHanded to the provider; sesMessageId is set.
deliveredThe receiving server accepted it.
openedThe tracking pixel loaded.
clickedA tracked link was followed.
bouncedRejected by the receiving server. A hard bounce suppresses the address.
complainedMarked as spam by the recipient. The address is suppressed.
failedCould not be handed to the provider after retries. Send again once the cause is fixed.

List and inspect

GET/v1/emailsAPI key or session · read
FieldTypeDescription
searchstringFull-text over recipient, sender, subject, body and message id, most relevant first (subject, then participants, then body). Quotes, or and -word work; the last word also matches as a prefix. Counts up to 1,000 matches.
statusstringOne of the statuses above. Funnel-cumulative: delivered includes opened and clicked.
domainstringExact sending domain, case-insensitive.
broadcastIduuidOnly messages sent by this broadcast.
from / toISO 8601Created-at window.
page / pageSizeintDefaults 1 / 50, pageSize ≤ 100.

Answers { items, total, totalCapped, page, pageSize }. Each item is the summary shape the send response uses. totalCapped is true when a search stopped counting at 1,000 matches. total then means "at least", and later pages still return the older matches.

GET/v1/emails/:idAPI key or session · read
Response
{
  "id": "0b6c2d4e-…",
  "messageId": "msg_9f2b7c10ad",
  "from": "hello@send.acme.io",
  "to": "ada@lumen.io",
  "domain": "send.acme.io",
  "subject": "Welcome to Acme",
  "status": "clicked",
  "sesMessageId": "0100019…",
  "createdAt": "2026-09-10T09:41:02.518Z",
  "html": "<h1>Hi Ada</h1>…",
  "text": "Hi Ada, …",
  "bodyExpired": false,
  "bodyExpiredAt": null,
  "bodyRetentionDays": 30,
  "template": { "id": "…", "publicId": "tpl_9f2b7c10ad42", "name": "Welcome" },
  "threadId": null,
  "sendClass": "transactional",
  "oneClickUnsubscribe": false,
  "unsubscribeInBody": null,
  "footerPostalAddress": null,
  "resends": [],
  "events": [
    { "id": "…", "type": "accepted",  "meta": null, "createdAt": "2026-09-10T09:41:02.518Z" },
    { "id": "…", "type": "sent",      "meta": null, "createdAt": "2026-09-10T09:41:03.104Z" },
    { "id": "…", "type": "delivered", "meta": null, "createdAt": "2026-09-10T09:41:05.871Z" },
    { "id": "…", "type": "opened",    "meta": null, "createdAt": "2026-09-10T10:02:11.004Z" },
    { "id": "…", "type": "clicked",   "meta": { "url": "https://acme.io/start" }, "createdAt": "2026-09-10T10:02:19.550Z" }
  ]
}
GET/v1/emails/stats/summaryAPI key or session · read

Counts per status for the Emails screen's header.

Send again

POST/v1/emails/:id/resendSession · send:email

Creates a new message with the original's body and recipient; the failed one is never changed. Only a failed message qualifies, because repeating a bounced or complained send damages the reputation the suppression list protects. Four 409s: resend_not_failed, resend_body_expired (retention deleted the body), resend_already_in_flight and resend_attachment_missing (a file the original carried is no longer stored or no longer matches what was attached; the message names the file and nothing is sent). The response is the new message with resendOf set, and the original's detail lists it under resends.

Tracking and retention

  • Every HTML body is instrumented on the way out: a 1×1 open pixel and rewritten, signed https?:// links. Opens and clicks are recorded against the message that carried them and fan out as email.opened / email.clicked.
  • Bodies are kept for 30 days. After that the HTML and text are deleted, bodyExpired flips to true and bodyExpiredAt is set. The row, its status and its whole event timeline stay forever.
  • An In-Reply-To / References pair is set automatically when you reply from a mailbox, so the conversation threads. GET /v1/emails/:id exposes the resulting threadId.

Your allowance, and what a pause stops

A plan includes a number of emails per billing period (the Pro plan, 250,000). The allowance is not a limit. Passing it refuses nothing and pauses nothing. Every email above it costs the plan's overage rate ($2.00 per additional 1,000), counted when the period closes and billed on the invoice for the following period. The live figure, the period's dates and the overage so far are on Settings › Plan & billing and on GET /v1/billing/usage.

You hear about it twice per period, by email to the workspace Owner, and each notice is sent once:

NoticeWhenWhat it says
Approaching the allowanceAt 80% of the allowance by default (emails.warnAtPct / emails.warnAt on the usage response, so you never have to hard-code it).How many you have sent, that sending continues past the allowance, and what every further 1,000 costs.
Overage has startedThe first email past the allowance.How far over you are, the cost so far, and that passing the allowance does not restrict sending, and never will.

A billable email is an outbound message that was accepted: failed messages and our own notices to you are not counted, and received mail never is. Both notices and the usage bar count the same way.

What a billing pause stops. If a subscription payment fails and is not fixed by the date you were told, or a graduated workspace's subscription ends, all sending stops and receiving never does. Every send door answers 409 workspace_sending_paused: the API, batch, a broadcast's send, test and schedule, an automation step (recorded as failed), a mailbox reply and Send again. A scheduled broadcast that comes due is held and goes out once sending resumes. There is no per-kind degradation: transactional and marketing mail stop together. Inbound mail is untouched: messages to your receiving-verified domains are stored and filed as usual, and webhooks keep delivering. The same rule applies whoever placed the pause (billing, an operator, or your bounce and complaint rates; see Deliverability).

Batch sends

POST/v1/emails/batchAPI key or session · send:email

Up to 500 messages in one request, each item exactly the body POST /v1/emails takes (html or templateId + variables per item). Items are validated and gated individually: a bad address, an unverified domain, a suppressed recipient, a draft template or a marketing item in a workspace with no postal address (postal_address_missing) fails that item and its siblings still send. The request itself is refused only when the array is empty (400 batch_empty), over the limit (400 batch_too_large, detail.limit), or when an operator has paused the workspace's sending. Then the whole request answers 409 workspace_sending_paused and no item is stored (unless every item already failed its own domain check, in which case you get those per-item errors instead). The body limit on this route is 10 MiB.

Request
curl -X POST https://sendclipper.com/v1/emails/batch \
  -H "Authorization: Bearer sc_live_<your key>" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: batch-2026-09-10-0001" \
  -d '{
    "emails": [
      { "from": "hello@send.acme.io", "to": "ada@lumen.io",
        "templateId": "tpl_9f2b7c10ad42", "variables": { "first_name": "Ada" } },
      { "from": "hello@send.acme.io", "to": "not-an-address",
        "subject": "Hi", "html": "<p>Hi</p>" },
      { "from": "hello@send.acme.io", "to": "mira@northwind.io",
        "subject": "Your invoice", "html": "<p>Attached below.</p>" }
    ]
  }'
Response (always 200)
HTTP/1.1 200 OK

{
  "data": [
    { "index": 0, "status": "accepted", "id": "0b6c…", "messageId": "msg_9f2b7c10ad" },
    { "index": 1, "status": "failed",
      "error": { "code": "validation_failed", "message": "to must be a valid email address" } },
    { "index": 2, "status": "accepted", "id": "c41a…", "messageId": "msg_1e0d5a7b33" }
  ],
  "summary": { "submitted": 3, "accepted": 2, "failed": 1 }
}
  • data[i].index is the position in your array; results come back in order.
  • A failed item carries the same error.code the single endpoint would have answered with, plus validation_failed for a malformed item.
  • A template named by several items is loaded once per request; a publish between two requests is seen by the second.
  • One Idempotency-Key covers the whole batch. A batch counts as one request against your rate limit.

Templates

Designer and lifecycle

A template is built from blocks on the Templates screen (heading, text, button, image, divider, spacer, columns, social, footer, table, chart and inline SVG) and rendered to email-safe HTML on every save. An optional AI assistant edits the blocks from a prompt and attached pictures; it runs on your own computer through a small local bridge, never on our servers.

A template is a draft until you publish it, and only a published template can be sent. Editing a published template changes what future sends render; messages already sent keep the HTML they were sent with.

Sending a template

Pass templateId and variables to POST /v1/emails, POST /v1/emails/batch (per item) or the sandbox door instead of html:

bash
curl -X POST https://sendclipper.com/v1/emails \
  -H "Authorization: Bearer sc_live_<your key>" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "hello@send.acme.io",
    "to": "ada@lumen.io",
    "templateId": "tpl_9f2b7c10ad42",
    "variables": {
      "first_name": "Ada",
      "order_id": 48213,
      "unsubscribe_url": "https://acme.io/u/abc123"
    }
  }'
  • html and templateId are exclusive; sending both, or neither, is 400.
  • subject is optional: the template's own subject is the default, your non-empty value overrides it, and it is substituted with the same variables. text is generated from the blocks when omitted.
  • Resolution happens before the send, so link tracking signs the real URLs and every refusal comes back synchronously.
  • GET /v1/emails/:id records which template was sent (template, templatePublicId).
  • Your own html can carry tags too: send variables with it (no templateId) and its {{tags}}, the subject's and your text's are filled by the same rules, with the same refusals below. Without variables the body goes out exactly as written, braces and all. Tags in a link work in any quoted href.

Sending a template by name

Every domain is a project under Email development, and each template there has a send name, derived from its name (Registration → registration) and editable in the designer. Your app sends "template": "registration" instead of an id, so the same code works against every environment:

bash
curl -X POST https://sendclipper.com/v1/emails \
  -H "Authorization: Bearer sc_live_<your key>" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "noreply@myapp.com",
    "to": "ada@example.com",
    "template": "registration",
    "variables": { "first_name": "Ada", "verify_url": "https://myapp.com/verify/abc" }
  }'
  • The name is looked up in the project of the from domain (the domain itself or its closest parent, mail.myapp.com → myapp.com), then among the workspace-wide templates (those in no project).
  • Names are lower-case letters, digits, - and _, matched case-insensitively and unique per project. Two projects may each have their own registration.
  • template and templateId are exclusive. Everything else (variables, the refusals below, the batch door, the sandbox door) works exactly as for an id. An unknown name is 404 template_not_found naming the domain it looked in.
RefusalStatusWhy
template_not_found404Unknown id or name, or a template in another workspace.
template_not_published409Still a draft. Publish it first.
template_variables_missing400A tag has neither a value nor a fallback; detail.missing lists them. A blank is never sent.
template_variables_invalid400A value used as a link target is not https://, http:// or mailto: (detail.unsafeLinks).
subject_required400Neither the template nor the request has a subject.
subject_invalid400The resolved subject has a line break or exceeds 998 characters.
http
HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "template_variables_missing",
    "message": "This template needs a value for: order_id",
    "detail": { "missing": ["order_id"] }
  }
}

Merge tags

Write {{tag}} anywhere in a block's text, a button link or the subject, and {{tag|fallback}} to supply a default. Tag names are [a-zA-Z_][a-zA-Z0-9_]* and case-sensitive.

  • Values are HTML-escaped in the body, percent-encoded inside a link, verbatim in the subject and text.
  • A tag at the start of a link ({{cta_url}} or {{app_url}}/login) must be an https://, http:// or mailto: URL; a tag later in the link, like ?e={{email}}, is encoded as a query value.
  • {{unsubscribe_url}} is an ordinary variable on a transactional send: every footer block renders it, so pass one. A sendClass: "marketing" send fills it with the one-click link when you don't, and broadcasts mint it per recipient for you.
  • {{postal_address}} is filled from Settings › Workspace whenever the workspace has an address, so a footer block can show it: put the tag in the block's address field. On a transactional send your own postal_address value wins; on a marketing send the workspace's address is always the one carried.
  • A tag with no value and no fallback refuses the send rather than rendering "Hi ,".

Template routes

GET/v1/templatesAPI key or session · read

?project=<domain id> lists one Email development project's templates.

GET/v1/templates/projectsAPI key or session · read

Every domain as an Email development project, with templateCount and publishedCount.

POST/v1/templatesAPI key or session · manage:templates
FieldTypeDescription
namestring1 to 120 characters.
projectDomainIduuidThe Email development project (a domain of the workspace) it belongs to. With one, the send name is derived from the name when key is omitted.
keystringThe send name (registration): lower-case letters, digits, -, _; unique per project (409 template_key_taken). Also settable with PATCH, where null clears it.
subjectstringDefault subject, up to 300 characters. May contain merge tags.
preheaderstringPreview text, up to 300 characters.
blocksBlock[]The designer's block list. Unknown block types and javascript: links are refused with 400 invalid_blocks.
GET/v1/templates/:idAPI key or session · read
GET/v1/templates/:id/htmlAPI key or session · read

The rendered HTML, unresolved, as text/html.

PATCH/v1/templates/:idAPI key or session · manage:templates

Takes the create fields plus domainIds: the domains whose mailbox app offers the template (the whole set, replaced; see Templates in the mailbox app). Every template answers domains (id and name) and createdByMailbox (set when it was made in the mailbox app).

POST/v1/templates/:id/publishAPI key or session · manage:templates
POST/v1/templates/:id/unpublishAPI key or session · manage:templates
POST/v1/templates/:id/duplicateAPI key or session · manage:templates
DELETE/v1/templates/:idAPI key or session · manage:templates

A deleted template's id stops resolving; messages already sent are unaffected.

POST/v1/templates/assetsAPI key or session · manage:templates
FieldTypeDescription
mimeTyperequiredstringimage/png, image/jpeg, image/webp or image/gif.
base64requiredstringThe file bytes.
namestringDisplay name, up to 120 characters.

Uploads a picture for use in image blocks. The response carries a public URL under /v1/template-assets/:publicId that mail clients load without a session; it is immutable and cached for a year.

Broadcasts can use a published template as their body with a fixed vocabulary (name, first_name, email, unsubscribe_url, broadcast_name, postal_address). See Broadcasts.

Webhooks

A webhook is one HTTP POST per (event × subscribed endpoint), signed with a secret only you and SendClipper hold. Subscribe an endpoint on the Webhooks screen or with the API below, verify the signature on every delivery, answer 2xx quickly and do your work afterwards. Delivery is at-least-once: deduplicate on the event id.

Endpoints

GET/v1/webhook-endpointsAPI key or session · read
POST/v1/webhook-endpointsAPI key or session · manage:webhooks
FieldTypeDescription
urlrequiredstringThe receiver, https:// only. An http:// URL is refused unless allowInsecureHttp is set. Private and internal destinations are refused outright (see below).
eventsrequiredstring[]Unique subset of the six event types. An empty array is a valid endpoint that receives nothing.
allowInsecureHttpbooleanAcknowledge a plain-HTTP receiver. Stored per endpoint and shown as an insecure chip; ignored when the URL is https://.

The 201 response is the endpoint plus its signing secret (whsec_ + 32 hex). It is shown once; every later read returns only a masked secretHint such as whsec_9f2b••••••••a17c.

PATCH/v1/webhook-endpoints/:idAPI key or session · manage:webhooks

Change url, events, allowInsecureHttp or active. Disabling (active: false) keeps everything: URL, events, secret and history. Queued work is held with its retry clock paused and resumes on re-enable; events that occur while it is disabled are not queued at all.

POST/v1/webhook-endpoints/:id/rotate-secretAPI key or session · manage:webhooks
DELETE/v1/webhook-endpoints/:idAPI key or session · manage:webhooks
GET/v1/webhook-endpoints/verify-examplesAPI key or session · read

Serves the two verifier snippets below plus the tolerance, so the product, this page and the test suite state one number.

GET/v1/webhook-deliveries?endpointId=&finalOnly=&limit=API key or session · read

Recent attempts with status code, latency and the receiver's answer.

Events and payload

EventWhen
email.deliveredThe receiving server accepted the message.
email.openedThe tracking pixel loaded in a mail client.
email.clickedA tracked link in the message was followed.
email.bouncedThe receiving server rejected it (hard or soft), or Amazon SES refused it because the address is on its account suppression list. Which one is on the message's timeline.
email.complainedThe recipient marked it as spam. The address is suppressed automatically.
email.failedSendClipper could not hand it to the provider after retries.
Delivery body
{
  "id": "evt_9f2b7c10ad42c3d4e5f60718",
  "event": "email.delivered",
  "createdAt": "2026-09-06T09:41:02.518Z",
  "data": {
    "emailId": "0b6c2d4e-…",
    "messageId": "0100019…",
    "to": "user@acme.io",
    "domain": "send.acme.io",
    "subject": "Your receipt"
  }
}
  • id is stable per (email, event): every retry and every replay of the same event carries the same id. Deduplicate on it, plus whatever identifies the endpoint on your side if two endpoints share one handler.
  • data.emailId is the message id you got back from POST /v1/emails; data.messageId is the provider's id for the same send.
  • Headers: Content-Type: application/json, User-Agent: SendClipper-Webhooks/1.0. Redirects are never followed: a 3xx is a failed attempt.
  • New fields, new event types and new headers may be added without notice. Nothing is removed or retyped inside /v1, so ignore what you do not recognise.

Signature headers

HeaderValueStatus
X-SendClipper-Timestamp1756800000: seconds since the epoch, stamped when this attempt was signedcurrent
X-SendClipper-Signature-V2t=1756800000,v1=<hex>[,v1=<hex>]current
X-SendClipper-Signature<hex>deprecated 2026-09-06, still sent until at least 2027-03-06

The signed string is `${timestamp}.${rawBody}`: the decimal timestamp, one ASCII full stop, then the request body exactly as it arrived. Algorithm HMAC-SHA256 keyed by the UTF-8 bytes of your whsec_… secret (prefix included), encoded as 64 lowercase hex characters.

  • Sign the raw bytes. Parsing the JSON and re-serialising it changes key order and whitespace and the check fails. In Express use express.raw(), in Flask request.get_data(), never the parsed body.
  • Accept any v1= that matches, never only the first: during a secret rotation each delivery carries two.
  • Enforce a tolerance of five minutes (300 s) on t=. The timestamp is inside the signature, so a captured delivery cannot be refreshed without your secret. That is your replay protection, and SendClipper does not enforce it for you. A retry is re-signed with a fresh timestamp, so a late success is not rejected by its own tolerance.
  • The deprecated X-SendClipper-Signature covers the body only, verifies forever, and during a rotation carries the new secret only. Move to X-SendClipper-Signature-V2.

Verifying a delivery

Both snippets are standard-library only, loop over every v1=, enforce the tolerance and compare in constant time. They are the same code the API test suite runs against a real captured delivery, so they cannot rot.

Node.js: verify.node.js
// Node.js 18+, standard library only.
const crypto = require("crypto");

const TOLERANCE_SECONDS = 300; // five minutes

function parseSignatureHeader(header) {
  const result = { timestamp: null, signatures: [] };
  for (const part of String(header || "").split(",")) {
    const eq = part.indexOf("=");
    if (eq < 0) continue;
    const key = part.slice(0, eq).trim();
    const value = part.slice(eq + 1).trim();
    if (key === "t" && value !== "" && Number.isFinite(Number(value))) {
      result.timestamp = Number(value);
    } else if (key === "v1" && value) {
      result.signatures.push(value);
    }
  }
  return result;
}

function constantTimeEquals(a, b) {
  const left = Buffer.from(a, "utf8");
  const right = Buffer.from(b, "utf8");
  if (left.length !== right.length) return false; // timingSafeEqual throws otherwise
  return crypto.timingSafeEqual(left, right);
}

function verifyWebhook(secret, rawBody, header) {
  const { timestamp, signatures } = parseSignatureHeader(header);
  if (timestamp === null || signatures.length === 0) return false;
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");

  // Accept ANY match: during a secret rotation there are two v1= values.
  let ok = false;
  for (const candidate of signatures) {
    if (constantTimeEquals(expected, candidate)) ok = true;
  }
  return ok;
}

// Express: read the RAW body, not express.json()
app.post(
  "/hooks/sendclipper",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifyWebhook(
      process.env.SENDCLIPPER_WEBHOOK_SECRET,
      req.body.toString("utf8"),
      req.get("X-SendClipper-Signature-V2"),
    );
    if (!ok) return res.status(400).send("bad signature");
    res.status(200).send("ok"); // answer 2xx quickly; do the work afterwards
    const event = JSON.parse(req.body.toString("utf8"));
    // ... deduplicate on event.id, then handle event.event / event.data
  },
);
Python: verify.py
# Python 3.8+, standard library only.
import hashlib, hmac, time

TOLERANCE_SECONDS = 300  # five minutes


def parse_signature_header(header):
    timestamp, signatures = None, []
    for part in (header or "").split(","):
        if "=" not in part:
            continue
        key, _, value = part.partition("=")
        key, value = key.strip(), value.strip()
        if key == "t":
            try:
                timestamp = int(value)
            except ValueError:
                timestamp = None
        elif key == "v1" and value:
            signatures.append(value)
    return timestamp, signatures


def verify_webhook(secret, raw_body, header):
    timestamp, signatures = parse_signature_header(header)
    if timestamp is None or not signatures:
        return False
    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        return False
    if isinstance(raw_body, bytes):
        raw_body = raw_body.decode("utf-8")
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.{raw_body}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    # Accept ANY match: during a secret rotation there are two v1= values.
    return any(hmac.compare_digest(expected, s) for s in signatures)


# Flask: use request.get_data(), never request.json
@app.post("/hooks/sendclipper")
def hooks():
    ok = verify_webhook(
        os.environ["SENDCLIPPER_WEBHOOK_SECRET"],
        request.get_data(),
        request.headers.get("X-SendClipper-Signature-V2"),
    )
    if not ok:
        return "bad signature", 400
    return "ok", 200
The Verify signatures panel on the Webhooks screen renders these same two files against your own endpoint, with the tolerance read from the API.

Retries, dead letters and replay

  • Backoff 30 s → 1 m → 2 m → 4 m → 8 m → 16 m → 32 m → hourly until 24 hours from the first attempt, then the event is dead-lettered. Per-request timeout 5 s.
  • Retried: any 5xx, 408, 425, 429, every 3xx, and every transport error or timeout.
  • Not retried, dead-lettered after one attempt because the same bytes would be refused the same way: 400, 401, 403, 404, 405, 410, 413, 415, 422.
  • Deliveries to one endpoint are sent in order and stop at the first failure; nothing behind a retrying event is delivered until it succeeds or dies.
GET/v1/webhook-dispatches?state=dead_lettered&endpointId=&limit=&cursor=API key or session · read

Undelivered work. state is dead_lettered by default; retrying means pending or delivering, all means every state. This is a cursor-paginated list: { data, page: { cursor, hasMore } }. Pass cursor back to get the next page.

GET/v1/webhook-dispatches/:idAPI key or session · read

The payload we sent, every attempt, and your server's last answer.

POST/v1/webhook-dispatches/:id/replayAPI key or session · manage:webhooks
POST/v1/webhook-dispatches/replay-allAPI key or session · manage:webhooks

A replay is one immediate attempt. If it fails again the event goes straight back to the undelivered list with its replay count raised, to be replayed by hand. There is no hidden second retry ladder behind the button. A replayed delivery is signed with whatever secrets are live at that moment. replay-all takes the same state/endpointId filters as the list, replays up to 200 events in one call and answers { queued, remaining, capped, max }. Replay on a disabled endpoint is refused.

Rotating the secret

POST /v1/webhook-endpoints/:id/rotate-secret answers 200 with the endpoint and its new secret. The endpoint keeps its id, URL, events, active state and delivery history.

  • For 24 hours every delivery is signed with both the new secret (first) and the old one (second), so you deploy the new value whenever you like without missing an event. A second rotation inside that window replaces the grace secret: at most two are ever live.
  • Retrying the request with the same Idempotency-Key returns the same secret, not a second one. Two concurrent rotations are a conditional write: one wins, the other gets 409 with no secret in the body.
  • If you still verify the deprecated X-SendClipper-Signature, deploy the new secret the moment you rotate, because that header cannot carry two.

Where we will and will not send

SendClipper resolves every destination itself, validates every address the name answers with, and connects only to a validated address. This happens at registration and again at delivery time, with the socket pinned to the validated address so DNS is never consulted twice.

  • Refused: loopback, RFC 1918, CGNAT, link-local (including the cloud metadata address 169.254.169.254), unique-local, multicast and reserved ranges; the localhost, .local, .internal and .home.arpa suffixes; any URL carrying user:password@.
  • A refusal is terminal: the event is dead-lettered, not retried. Three refusals in a row disable the endpoint and email the workspace Owner once; a successful delivery resets the count. Recover in this order: point the hostname at a public address, enable the endpoint, then replay the dead-lettered events. Events that were merely held behind the refusals resume on their own.
  • A name that resolves to nothing (ENOTFOUND) is an ordinary transport failure: it retries on the ladder and never disables anything.
  • Endpoints must be https://. Plain HTTP needs an explicit allowInsecureHttp: true, which is shown on the endpoint card and never relaxes the address rule.

Domains

Every message is sent from an address on a domain you have proven you own. Sending needs MX, SPF, DKIM and DMARC; receiving needs two more records and is independent: a broken receiving record never blocks a send.

Add a domain

GET/v1/domainsAPI key or session · read
POST/v1/domainsAPI key or session · manage:domains
FieldTypeDescription
namerequiredstringA bare hostname such as send.acme.io, with no scheme or path. Lower-cased on save. Domain names are globally unique across SendClipper: a name another workspace holds cannot be added.
regionrequired"us-east-1" | "eu-west-1" | "us-west-2"Where the domain's identity is registered and its mail is sent from. Pick the one nearest your recipients.

The response is the domain with status: "pending", its region, per-record flags (spfOk, dkimOk, dmarcOk, mxOk) and lastCheckedAt/lastError. A subdomain of a verified domain (billing.acme.io under acme.io) can send without being added on its own.

GET/v1/domains/:id/deletionAPI key or session · manage:domains

What deleting the domain takes with it: mailboxes, aliases, apiKeys scoped to it, portal, plus the messages and templates that stay behind and platformSender.

DELETE/v1/domains/:idAPI key or session · manage:domains

Removes the domain, its addresses and aliases, its mail portal and every API key scoped to it, and withdraws it from the sending providers and from receiving. Messages already sent or received keep their archive. The domain that sends the platform's own notifications is refused with 409 domain_is_platform_sender.

DNS records

GET/v1/domains/:id/recordsAPI key or session · read
Response
[
  { "purpose": "MX",    "host": "mail.send.acme.io", "priority": 10,
    "value": "feedback-smtp.eu-west-1.amazonses.com (priority 10)",
    "copyValue": "feedback-smtp.eu-west-1.amazonses.com", "ok": true },
  { "purpose": "SPF",   "host": "mail.send.acme.io",
    "value": "v=spf1 include:amazonses.com ~all", "ok": true },
  { "purpose": "DKIM",  "host": "a1b2c3._domainkey.send.acme.io",
    "value": "a1b2c3.dkim.amazonses.com", "ok": true },
  { "purpose": "DKIM",  "host": "d4e5f6._domainkey.send.acme.io",
    "value": "d4e5f6.dkim.amazonses.com", "ok": true },
  { "purpose": "DKIM",  "host": "g7h8i9._domainkey.send.acme.io",
    "value": "g7h8i9.dkim.amazonses.com", "ok": false },
  { "purpose": "DMARC", "host": "_dmarc.send.acme.io",
    "value": "v=DMARC1; p=none; rua=mailto:dmarc@send.acme.io", "ok": true,
    "alsoFor": ["scaleway"] },

  { "purpose": "TEM_SPF", "host": "send.acme.io",
    "value": "v=spf1 include:_spf.google.com include:_spf.tem.scaleway.com ~all",
    "note": "send.acme.io already publishes an SPF record (…). Do not add a second one. Edit that record so it reads exactly this merged value. …",
    "ok": false },
  { "purpose": "TEM_DKIM", "host": "<project>._domainkey.send.acme.io",
    "value": "v=DKIM1; h=sha256; k=rsa; p=MIIBIjANBgkq…", "ok": false },

  { "purpose": "RECEIVING_TOKEN", "host": "_sendclipper-receiving.send.acme.io",
    "value": "sendclipper-receiving=9f2b7c10ad42c3d4e5f60718a1b2c3d4", "ok": false },
  { "purpose": "MX_INBOUND", "host": "send.acme.io", "priority": 10,
    "value": "inbound-smtp.eu-west-1.amazonaws.com", "ok": false }
]
PurposeNeeded forWhat it does
MXSendingBounce handling on the custom MAIL FROM subdomain (mail.<domain>).
SPFSendingAuthorises our servers to send for that subdomain.
DKIMSendingThree CNAMEs that let receivers verify our signature. All three must resolve.
DMARCSendingPublishes your policy; p=none to start, tighten later. One _dmarc record serves both providers (alsoFor: ["scaleway"]).
TEM_SPFSending through ScalewayScaleway's include on the domain itself. A name may carry only one SPF record, so when yours already has one the value is your record with Scaleway's include merged in, and note says so. Edit the existing record; never add a second.
TEM_DKIMSending through ScalewayScaleway's DKIM key, a TXT under its own selector. It sits beside the Amazon SES CNAMEs and replaces none of them.
RECEIVING_TOKENReceivingA TXT record with a per-domain secret only SendClipper could have minted. This is what proves you own the zone before mail is routed into your mailboxes.
MX_INBOUNDReceivingThe MX on the domain itself, pointing incoming mail at our receiving endpoint.

Publish each record at your DNS provider exactly as given. Record values differ by region; re-fetch after a region change.

SendClipper can send through Amazon SES or Scaleway Transactional Email. When Scaleway is configured, every domain you add is registered with Scaleway for you, and its two TEM_* records appear in this list. Publish them once, alongside the others, and a later switch of sending provider needs no DNS work and no Scaleway account. The domain view's providers block says which providers it is ready for: ses.ready is its verification here, and scaleway.ready needs that plus Scaleway's own check. Scaleway's MX is never listed: receiving always stays on Amazon SES.

Verification

POST/v1/domains/:id/verifyAPI key or session · manage:domains

Resolves every record now and returns the updated domain. The status is verified when every sending record checks out, failed when one was found broken (lastError names it), and pending while records are still missing. A background job re-checks pending and failed domains on its own, so a record that propagates overnight is picked up without a click. Only a verified domain can send; the refusal codes are listed under Send a message.

Receiving

POST/v1/domains/:id/receivingAPI key or session · manage:domains
DELETE/v1/domains/:id/receivingAPI key or session · manage:domains

Turns receiving on or off. Enabling mints the domain's receiving token and adds the two receiving rows to the records list. Mail is routed into the workspace only once both receivingEnabled and receivingVerified are true. The TXT token is the gate, because the inbound MX value is the same for every customer and proves nothing about who owns the zone. Receiving matches the domain exactly: mail to support@mail.acme.io is not routed by acme.io. What happens to a message once it arrives is under Mailboxes.

Region

PATCH/v1/domains/:id/regionAPI key or session · manage:domains

Moves the domain's identity to another region. This is a re-registration, not a relabel: the DKIM tokens are replaced, the status returns to pending, the receiving proof is cleared, and you publish the new records. Once a domain holds any messages, the region cannot change (409 region_immutable).

Suppressions

The suppression list is every address SendClipper will not send to, and why. It is written for you on a hard bounce, on repeated soft bounces, on a spam complaint, when someone presses Unsubscribe (on the confirm page or in their mail client) and when Amazon SES itself refuses an address, and by hand. A send to a suppressed address is refused with 403 recipient_suppressed before anything leaves: on the API, in a batch item, in a broadcast fan-out.

ReasonScopeWritten by
hard_bounceall mailA permanent bounce from the receiving server.
soft_bounceall mailRepeated temporary bounces (mailbox full, over quota, server busy) for one address: by default the third within 7 days. The entry states the numbers it was written with.
complaintall mailA spam report from the recipient's provider.
unsubscribemarketing only (transactional sends still go)The Unsubscribe button behind a marketing email's unsubscribe link (opening the link alone changes nothing), or the mail client's one-click Unsubscribe.
manualall mailPOST /v1/suppressions or the Suppressions screen.
providerall mailAmazon SES refused the address because it is on the suppression list of the Amazon SES account SendClipper sends through.

There are two lists, and they are not the same thing. Yours is per workspace: it holds the addresses above, and you can remove most entries. Amazon SES keeps its own list for the whole sending account (when on, which is the default: every address that hard-bounced or complained, from any workspace) and refuses to send to it however the message arrives. When it does, the message is bounced (the email.bounced webhook fires) and its timeline reads Refused by Amazon SES: address on the account suppression list. The address is added to your list as provider, which cannot be removed here (removing it would change nothing at Amazon SES). Such a message is left out of the bounce and complaint rates read against the provider's thresholds (the reputation alerts and the automatic pause), because Amazon SES leaves it out of its own. To have one lifted, contact support.

One soft bounce never suppresses: a full mailbox is usually emptied. The count is per address, per workspace, over bounces recorded in the last 7 days by default (the operator can change both numbers); the entry records the ones it was written with (reasonDetail reads 3 soft bounces in 7 days, last: mailbox full). A bounce the provider marks Undetermined is neither hard nor soft and is never counted.

GET/v1/suppressions?q=&reason=&limit=&cursor=API key or session · read

Cursor-paginated: { data, page: { cursor, hasMore }, counts }, 25 per page by default, up to 100. counts is the per-reason total for the header.

GET/v1/suppressions/exportAPI key or session · read

The whole list as a streamed CSV download.

POST/v1/suppressionsAPI key or session · manage:suppressions
FieldTypeDescription
emailrequiredstringThe address to block. Lower-cased on save.
notestringWhy, up to 500 characters. Shown on the row.
DELETE/v1/suppressions/:idAPI key or session · manage:suppressions
FieldTypeDescription
reasonrequiredstringRequired body. 10 to 500 characters explaining why the address may be mailed again; kept in the audit trail.
Removing a hard_bounce entry lets you send to an address the provider has already refused. Do it only when you know the cause is fixed (a mailbox restored). Amazon SES usually lists a hard-bouncing address on its own list too; then the next send is refused there and the address returns as provider. A complaint or provider entry cannot be removed here: the request answers 403 suppression_not_removable. Contact support.

Removing a soft_bounce entry is safe to try: the bounces that put it there are not counted again, and three new soft bounces within the window put it back on its own.

Usage & spend

GET/v1/usage?months=1-12Session · read

Your workspace's billable emails and what they cost: the figures on the Usage & spend screen. A billable email is one that left SendClipper: a failed send and SendClipper's own notices to you are not counted, while a bounced message did leave and is. The response carries your package, its pricePerThousandMicros (micro-dollars per 1,000 emails; 500000 is $0.50) and currencyCode; this calendar month with its emails, spend, a straight-line projection to month end and the average per day; previousMonth; daily (every day of this month so far, zero-filled); months (the last months calendar months, default 6, oldest first, zero-filled); byDomain (this month's top eight sending domains) and storage (inbox storage used against your package's ceiling). Every amount is an integer number of micro-dollars, and earlier months are priced at the current price. Session only; an API key is refused.

/v1/analytics/overview and /v1/analytics/costs are the platform operator's and answer 403 to a workspace member.
GET/v1/dashboard/stats?days=Session · read

The Overview screen's tiles: 14 or 30 days.

Reputation alerts. When a workspace's bounce or complaint rate crosses the provider's review threshold, the workspace Owner is emailed once, with a link to Logs filtered to the messages that bounced or were marked as spam; the alert stands down when the rate falls back under 80% of the line. Keep the rates well under 5% bounces and 0.1% complaints. The bounce rate counts hard bounces only (an address that does not exist), which is how Amazon SES counts it: a soft bounce, like a full mailbox, is recorded on the message but does not move the rate.
Automatic pause. At the provider's critical line (by default 10% bounces or 0.5% complaints over a 30-day window, on at least 50 sends; the operator can change each of these), sending for the workspace is paused automatically, within one evaluation cycle of the event that crossed it. From that moment every send answers 409 workspace_sending_paused with a reason naming the rate, the line and the sample (for example complaint rate 0.6% is at or above 0.5% (SES critical threshold), measured over 30 days on 500 sends) and the support address to write to (support@sendclipper.com by default), the dashboard shows the paused banner, and the Owner gets one "Sending is paused" email carrying the same numbers. The pause is not lifted when the rate falls back. Write to that address, and sending resumes once the list has been reviewed with you.

Reporting abuse

Two addresses reach a person at SendClipper, as RFC 2142 says they should:

AddressWrite to it about
abuse@sendclipper.comMail sent through SendClipper that you never asked for: spam, phishing, a list you never joined.
postmaster@sendclipper.comMailbox providers and mail administrators: delivery problems, feedback about our sending, anything an operator of a mail system needs to raise.

Forward the message you received with its full headers (Gmail: Show original; Outlook: View message source), or attach it. The headers are how we find the workspace that sent it. A report we can confirm is grounds to pause that workspace's sending, as the Acceptable Use Policy says.

Your own domain needs them too. Providers write to abuse@ and postmaster@ on the domain you send from. Create both as mailboxes on your receiving domain and turn on notifyOwner (Email the Owner in the address's editor) so a complaint reaches a person the same day (see Mailboxes). The Owner email is a platform notice and cannot yet be sent for every workspace. Where it cannot, the editor says so and the mail still lands in the address, so check it (and its Spam folder, since a forwarded spam message often scans as spam itself) rather than waiting for the email.

Contacts and audiences

A contact is a name, an address and a status, filed in an audience (a list). Segments slice across audiences, and a broadcast goes to either one. Contacts are only needed for campaigns; transactional sends through the API do not create them.

GET/v1/audiencesAPI key or session · read

Every audience with its live member count.

GET/v1/contacts?audienceId=&status=&page=&pageSize=API key or session · read

{ items, total, page, pageSize }, 25 per page by default and up to 200. status is subscribed, unsubscribed or bounced.

POST/v1/contactsAPI key or session · manage:contacts
FieldTypeDescription
namerequiredstringUp to 120 characters.
emailrequiredstringLower-cased on save; unique per workspace.
audienceIduuidDefaults to the workspace's default audience.
status"subscribed" | "unsubscribed" | "bounced"Defaults to subscribed.
POST/v1/contacts/importAPI key or session · manage:contacts

A multipart upload, field file: a CSV up to 10 MiB or a vCard (.vcf, 3.0 or 4.0) up to 25 MiB. Besides our own header row name,email,audience (and our own backup, below), the importer recognises the exports of Google Contacts, Outlook, Apple Contacts, Mailchimp, Brevo and Resend and maps their columns itself. With no other field, an address already in the workspace and an audience name that does not exist yet are skipped, never changed or created. Optional text fields change that:

FieldTypeDescription
filerequiredfileThe CSV or vCard.
audienceIduuidEvery row goes into this audience; the file's own audience column is ignored.
newAudiencestringEvery row goes into an audience by this name, created when missing (1 to 100 characters).
createAudiences"true" | "false"Create the audiences the file's audience column names (at most 50 per import).
onDuplicate"skip" | "update"An address already in the workspace: leave it (default) or update its name, audience and custom fields. An update never re-subscribes an unsubscribed or bounced contact.
mappingJSON stringOne target per column, overriding detection: "email", "name", "first_name", "last_name", "audience", "status", "unsubscribed", "bounced", "created_at", "property:<key>" or "ignore".

The response reports imported, updated, duplicates, invalid and skipped counts, the detected source, any audiencesCreated, and the first 25 skipped rows with the reason, so the file can be fixed. An import never adds an address to the suppression list.

contacts.csv
name,email,audience
Ada Lovelace,ada@lumen.io,Newsletter
Mira Fabbri,mira@northwind.io,Customers
bash
curl -X POST https://sendclipper.com/v1/contacts/import \
  -H "Authorization: Bearer <session token>" \
  -F "file=@contacts.csv"
POST/v1/contacts/import/previewAPI key or session · manage:contacts

The same upload, read and nothing written: the detected source, each column's proposed target with sample values, the first five rows and the audience names the file uses. Send an edited mapping back to the import.

GET/v1/contacts/export?format=csv|vcf&audience=API key or session · manage:contacts

Every contact, or one audience's, as a download. csv (the default) is the lossless backup: email, name, first and last name, audience, status, created date and one column per custom field, which POST /v1/contacts/import reads back as is. vcf is a vCard 4.0 file for address books. Cells that a spreadsheet would run as a formula are prefixed with ' (the importer removes it again). An unknown audience is 404. With an API key this needs a full key.

An unsubscribed contact and an address on the suppression list are two different things: the first is skipped by broadcasts, the second is refused by every send.

Segments

A segment is a saved set of rules over engagement and contact fields, evaluated live: its count on the Segments screen and in a broadcast picker is computed at request time, never cached.

A broadcast can send to a segment (segmentId on the broadcast, instead of audienceId). Its rules are checked again when the campaign goes out, so it reaches whoever matches then (subscribed contacts only, suppressed addresses excluded). A segment a scheduled broadcast depends on cannot be deleted (409 segment_in_use).

Automations use segments too: a segment can filter a flow's subscribe trigger, an Is in segment? condition checks membership when the contact reaches it, and a whole segment can be enrolled at once (below). A segment an automation uses cannot be deleted either.

GET/v1/segmentsSession · read
POST/v1/segments/previewSession · read
POST/v1/segmentsSession · manage:segments
DELETE/v1/segments/:idSession · manage:segments
FieldTypeDescription
namerequiredstring1 to 80 characters (create only).
rulesrequiredRule[]At least one rule; all rules must match (AND).
rules[].fieldstringopened_count, clicked_count, last_open_after, last_open_before, subscribed_age_days, created_at, audience_id, status.
rules[].op"=" | ">=" | "<=" | ">" | "<"Comparison. Equality only for audience_id and status.
rules[].valuenumber | stringShaped per field: a count, an ISO date, an audience id, a status.
rules[].daysint1 to 3650. Window for the count fields: opened at least N times in the last D days.
Preview a rule set without saving it
POST /v1/segments/preview
{
  "rules": [
    { "field": "opened_count", "op": ">=", "value": 3, "days": 30 },
    { "field": "status", "op": "=", "value": "subscribed" }
  ]
}

→ { "count": 1284, "chips": ["Opened ≥ 3 in 30 days", "Subscribed"] }

Broadcasts

A broadcast is one message to an audience or a segment: a draft you edit, test, then send now or schedule. Every recipient gets their own messages row with the same status lifecycle and webhooks as an API send, filtered on the Emails screen by broadcastId.

Lifecycle

GET/v1/broadcastsSession · read
GET/v1/broadcasts/:idSession · read
POST/v1/broadcastsSession · manage:broadcasts

Every field is optional. An empty body creates an empty draft for the builder to fill in.

PATCH/v1/broadcasts/:idSession · manage:broadcasts
FieldTypeDescription
namestringUp to 200 characters.
audienceIduuid | nullWho receives it: an audience's subscribed contacts.
segmentIduuid | nullOr a segment's matching subscribed contacts, evaluated at send time. Setting one clears the other; both at once is 400 broadcast_two_targets.
fromstringAn address on a verified domain.
subject / previewTextstringUp to 300 characters each. May carry merge tags.
deliveryMode"now" | "schedule" | "recurring"How it goes out.
scheduledAtISO 8601 | nullWhen, for schedule mode.
status"draft" | "scheduled"Set scheduled together with scheduledAt to schedule; there is no separate schedule route. sending and sent are reached only through /send.
bodySource"sample" | "template" | "html"Where the body comes from (below).
templateIdstring | nullA published template, for bodySource template.
bodyHtmlstring | nullPasted HTML, up to 500 000 characters, for bodySource html.
GET/v1/broadcasts/:id/send-previewSession · read

How many contacts will actually receive it (the audience, or the segment's matches, minus unsubscribed contacts and suppressed addresses), plus sampleHtml, the body resolved for a sample recipient.

POST/v1/broadcasts/:id/testSession · manage:broadcasts

Sends one copy to your own address with a test unsubscribe link that suppresses nobody.

POST/v1/broadcasts/:id/sendSession · manage:broadcasts

Starts the fan-out. The broadcast moves to sending and then sent; its card shows delivered, opened and clicked counts as the events arrive.

A scheduled broadcast goes out on the first check after its time (every 30 seconds). It may no longer be sendable by then: its template was unpublished or deleted, its body is empty, a tag has no value, a link starts with a tag that is never a web address, its From is outside a marketing domain nominated since, its audience or segment has no subscribed contacts left, or it has no recipients picked any more. Then it is moved back to draft once and nobody is mailed. The broadcast carries returnedToDraft (at, code and a one-sentence reason) until it is next edited; the list marks it not sent and the builder shows the reason. A broadcast held because sending is paused or no postal address is on file stays scheduled and goes out once that is resolved.

GET/v1/broadcasts/audience-statsSession · read

The recipient picker: every audience with its subscribed count, and every segment with the number of subscribed contacts it matches now.

Body and personalisation

bodySourceRendered from
sampleThe built-in sample email. The default.
templateA published template (templateId). Picking a draft is 409 template_not_published. If the template is unpublished or deleted later, the campaign still saves as a draft (templateUnavailable says why); sending, test-sending and scheduling it answer 409 template_not_published / 404 template_not_found until it is published again or another body is chosen.
htmlYour pasted HTML, sanitised on save: scripts, event handlers, javascript: and data: URLs, iframes and meta-refresh are stripped; nothing else is touched. A non-blank previewText goes in as a hidden preheader right after <body>, so the inbox shows it after the subject.

Personalisation runs per recipient inside the fan-out with a fixed vocabulary: {{name}}, {{first_name}}, {{email}}, {{unsubscribe_url}}, {{broadcast_name}} and {{postal_address}} (your workspace's own, from Settings). Write {{first_name|there}} for a contact with no name. Any other tag without a fallback refuses the send, the test and the schedule with 400 broadcast_tags_unresolved naming the tags, before the first recipient (never three hundred in). So does a link that starts with a tag that can never be a web address (href="{{email}}"): 400 broadcast_link_tags_invalid names it; start the link with https://, or write mailto:{{email}}.

Unsubscribe

GET/v1/unsubscribe/:tokenPublic
POST/v1/unsubscribe/:tokenPublic

Every broadcast carries a real, per-recipient unsubscribe link: a signed token. Opening it shows a page asking “Unsubscribe from this sender?” and writes nothing, so a corporate link scanner that opens every link in a message cannot unsubscribe anyone; pressing its button writes an unsubscribe suppression (marketing scope) and answers a plain confirmation page. A body with no {{unsubscribe_url}} gets one appended rather than being refused; an automation step's email gets the same line. The page always answers 200, is idempotent and never reveals why a token did not work.

The same link also works from inside the mail client. Every marketing message (a broadcast, an automation step, or an API send with sendClass: "marketing") goes out with List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers, so Gmail, Yahoo and Outlook show an “Unsubscribe” control next to the sender. Pressing it POSTs the link with no page and no confirmation (RFC 8058); the POST form writes the same suppression and answers a constant 200. Transactional messages carry neither header, so a receipt never shows an unsubscribe control.

Anti-spam law (CAN-SPAM in the US, the EU's sender-identification rules for commercial email) requires a real postal address on marketing mail. Type your company's address once under Settings › Workspace (or PATCH /v1/workspace with postalAddress) and every marketing message carries it in the footer, beside the unsubscribe link: the sample body shows it where its footer always had an address, a template shows it wherever its footer places {{postal_address}}, and any other body gets one line appended at the end. A body that already shows the address is never given a second one.

With no address on file, every marketing send (a broadcast's send, test and schedule, an automation step, and an API send with sendClass: "marketing") is refused with 400 postal_address_missing before anyone is mailed (SendClipper can mint an unsubscribe link, but it cannot invent an address). A scheduled broadcast that comes due is held and goes out on the first check after the address is added. Transactional mail is never refused for it and never gains an appended line (a transactional template whose footer places the tag shows the address if one is on file). Each sent message keeps the address it went out with, so changing it later does not rewrite what earlier campaigns say. Every broadcast view carries postalAddressMissing, the refusal sentence while no address is on file (null once there is one), which is what the builder's banner shows. Sending a failed marketing message again re-applies the rule with the address on file at that moment.

Automations

An automation is a journey a contact walks step by step: send an email, wait hours or days, and a condition (branch) that lets the contact continue only if they opened or clicked the previous email; otherwise they leave the flow. Every email goes through the same send path as POST /v1/emails, as marketing mail, so suppressions, the postal address and the unsubscribe link all apply.

A flow starts when a contact subscribes (optionally only contacts in one audience), or when you enroll a contact from the Activity view or through the API below. Each contact enters each flow once. Before every email the contact is checked again: someone who unsubscribed or was suppressed while waiting leaves the flow instead of getting it. Pausing a flow holds everyone where they are; resuming picks them up.

POST/v1/automations/:id/enrollmentsAPI key or session · manage:automations
FieldTypeDescription
emailrequiredstringAn existing contact in this workspace. The audience filter does not apply; subscription state and suppressions do.
bash
curl -X POST https://sendclipper.com/v1/automations/$FLOW_ID/enrollments \
  -H "Authorization: Bearer $SENDCLIPPER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "email": "ana@example.com" }'
RefusalStatusWhy
automation_paused409The flow is not Live.
contact_not_found404No contact with that address. Add it first.
contact_not_subscribed409The contact unsubscribed or bounced.
recipient_suppressed409The address is on your suppression list.
already_enrolled409This contact has already been through the flow.
Enrolling takes a full-access API key. Every other automation route is session only.
POST/v1/automations/:id/enrollments/segmentSession · manage:automations
FieldTypeDescription
segmentIdrequireduuidEvery subscribed, unsuppressed contact it matches now enters the flow; contacts already through it are skipped. At most 1,000 at once (409 segment_too_large).
GET/v1/automationsSession · read
POST/v1/automationsSession · manage:automations
FieldTypeDescription
namerequiredstringUp to 80 characters.
recipe"blank" | "welcome" | "nudge" | "drip"Starting steps. Default blank (one email).
trigger"contact.subscribed" | "manual"What starts it. Default contact.subscribed.
PATCH/v1/automations/:idSession · manage:automations
FieldTypeDescription
namestringRename.
trigger"contact.subscribed" | "manual"Change what starts it.
audienceIduuid | nullOnly contacts in this audience (null = all).
segmentIduuid | nullOr only contacts matching this segment when they are added (never both).
activebooleanLive or paused. Going Live is refused (409 automation_incomplete) while a step cannot run, naming the step.
DELETE/v1/automations/:idSession · manage:automations
POST/v1/automations/:id/stepsSession · manage:automations
FieldTypeDescription
typerequired"send" | "wait" | "branch"Inserted before the exit step.
subjectstringRequired for send, up to 160 characters.
bodystringUp to 2,000 characters.
fromstringSender address on one of your verified domains.
days / hoursintFor wait: 0 to 90 days plus 0 to 23 hours, at least one hour in total.
condition"opened" | "not_opened" | "clicked" | "not_clicked" | "in_segment" | "not_in_segment"Required for branch. Opens and clicks are tested against the last email the flow sent; a segment condition against the segment's rules when the contact gets there.
segmentIduuidRequired for in_segment / not_in_segment.
PATCH/v1/automations/:id/steps/:stepIdSession · manage:automations
DELETE/v1/automations/:id/steps/:stepIdSession · manage:automations
POST/v1/automations/:id/steps/:stepId/moveSession · manage:automations
POST/v1/automations/:id/steps/:stepId/testSession · manage:automations
GET/v1/automations/:id/runsSession · read
FieldTypeDescription
status"active" | "waiting" | "completed" | "exited" | "failed"Filter by where the contact is.
limitint1 to 200, default 50.
GET/v1/automations/:id/runs/:runIdSession · read

Mailboxes

A mailbox is one address on a domain that has receiving enabled and verified. Mail sent to it is parsed, threaded with anything you sent, and readable in the dashboard Inbox, through the API, by a person you hand a password to, or inside your own product through an embed.

Create an address

GET/v1/mailboxes?includeArchived=API key or session · read
GET/v1/mailboxes/:idAPI key or session · read
POST/v1/mailboxesAPI key or session · manage:mailboxes
FieldTypeDescription
domainIdrequireduuidA domain of this workspace.
localPartrequiredstringThe part before the @, 1 to 64 characters. Addresses are unique across SendClipper, case-insensitively: 409 mailbox_address_taken.
displayNamestringUp to 120 characters.
color#rrggbbFor the rail.
catchAllbooleanReceive everything on the domain that no other address claims. One per domain (409 mailbox_catch_all_taken).

The response is the mailbox: id, address, domain, catchAll, canReceive (the domain has proven receiving), canSend (the domain is verified for sending), unread and total counts.

PATCH/v1/mailboxes/:idAPI key or session · manage:mailboxes

displayName, color, catchAll, archived, embedOrigins and notifyOwner. The address itself is immutable.

notifyOwner: true emails the workspace Owner, at their sign-in address, once for every message that arrives at the address: the sender, the subject, the first lines and a link to it in the Inbox. Mail filed in Spam sends nothing. Turn it on for addresses nobody watches all day, above all your own abuse@ and postmaster@ (see Reporting abuse). It is a second call after the address exists: POST /v1/mailboxes takes no notifyOwner. The notice is a platform email and appears in /logs; one send is attempted per message, so a notice that ends failed is resent from /logs (Send again), not by the next arrival. At most MAILBOX_NOTIFY_MAX_PER_HOUR notices (default 5) go out per address per rolling hour: the last one says more are arriving and to open the Inbox, and anything past it is filed as usual with no email. Where the platform cannot send notices for a workspace, ownerNoticeAvailable on the mailbox reads false, the address's editor says no email will go out, and the mail still arrives as usual. A domain-scoped API key reads that field from its own domain only, so it sees false unless its domain is the platform sender's. That is a conservative answer, not a verdict on the workspace. Both fields, notifyOwner and ownerNoticeAvailable, ride every mailbox response.

Every mailbox response also carries azureSender: { state, reason, checkedAt }, where state is registered, pending, failed or not_applicable. When SendClipper sends through Microsoft Azure, an address can only send from a domain once Azure has registered it as a sender. SendClipper requests this when the address is created (and withdraws it when the address is archived), so you never call anything for it. Creating an address never waits on Azure.

DELETE/v1/mailboxes/:idAPI key or session · manage:mailboxes

Archives the address rather than deleting it: it stops receiving and signing in, and everything it received stays readable. Retiring an address never destroys its archive.

DELETE/v1/mailboxes/:id/permanentAPI key or session · manage:mailboxes

Deletes the address for good. It needs ?confirm=<the address>, works on an archived address too, and frees the address to be created again. By default its mail is kept and moves to All mail, unfiled; add &deleteMail=true to delete received mail, drafts and attachments with it. A message you sent stays in the delivery log either way. Sign-in and embed tokens for the address stop working at once.

How mail is routed

  1. The recipient's domain must be a receiving-verified domain of exactly that name. Otherwise the message is refused.
  2. An address matching the local part (ignoring a +tag) files it there.
  3. Otherwise an alias with that local part files it in the alias's mailbox (the message keeps the alias as its recipient).
  4. Otherwise the domain's catch-all takes it.
  5. Otherwise it is kept unfiled and shows under "All mail" in the dashboard. An unclaimed address is never bounced.

Replies are threaded by In-Reply-To and References, then by normalised subject plus participants within 90 days, so a customer answering a message you sent through the API lands in the same conversation. Inline images are served through GET /v1/messages/:id/parts/:partId, authenticated and tenant-scoped, never as a data URI.

Read a mailbox from your app

GET/v1/mailboxes/:id/messages?folder=&search=&label=&limit=&cursor=API key or session · read
GET/v1/mailboxes/:id/messages/:messageIdAPI key or session · read

This is the "receive from my app" half. A key with read or sending permission can read; a key scoped to the address's domain is the right one to hand to a service. folder is inbox, starred, sent, drafts, archive, spam, trash or all (every folder at once, Spam, Trash and Drafts included). A message filed under another address is 404, and a search never reaches one.

search is full-text over subject, participants and body, in any script, most relevant first (subject, then participants, then body); quotes, or and -word work, and the last word also matches as a prefix. The answer is { items, total, unread, totalCapped, counts, labels, page }: total and unread count the whole list (a search counts up to 1,000 matches and sets totalCapped), and page: { cursor, hasMore } pages it: pass cursor back for the next page. A page is limit rows, 1 to 200, 200 when omitted.

bash
# List what support@acme.io has received, with a domain-scoped key
curl "https://sendclipper.com/v1/mailboxes/<mailbox id>/messages?folder=inbox&search=invoice" \
  -H "Authorization: Bearer sc_live_<key scoped to acme.io>"

# One message, with its body and parts
curl "https://sendclipper.com/v1/mailboxes/<mailbox id>/messages/<message id>" \
  -H "Authorization: Bearer sc_live_<key scoped to acme.io>"

To poll for new mail, list folder=inbox and remember the newest id you have processed; a message.received webhook is not yet available.

Opening one received message also returns authentication, with Amazon SES's spf, dkim, dmarc and spam verdicts exactly as SES sent them: PASS, FAIL, GRAY (neither a pass nor a failure: no SPF answer, a message not signed by the sender's domain, or a sender with no DMARC policy), PROCESSING_FAILED, or null when nothing was recorded. It also returns the sender's dmarcPolicy when DMARC failed, a summary (passed, partial, failed, unknown), routingReason and spamOverriddenAt once someone moved it out of Spam. routingReason says why it is, or was, in Spam: spam_verdict, dmarc_quarantine, dmarc_reject (kept in Spam, never deleted), virus_verdict (a record with no content; the message itself was never stored) or manual (a person moved it there; the reading pane says so without naming who). authentication is null on mail you sent.

Mailbox users

POST/v1/mailboxes/:id/passwordAPI key or session · manage:mailboxes
DELETE/v1/mailboxes/:id/passwordAPI key or session · manage:mailboxes

Setting a password (10+ characters) lets a person sign in to that one address at /mail with no dashboard account at all (a support agent, a client). Removing it ends every live session within one request.

POST/v1/mailbox-auth/loginPublic
GET/v1/mailbox-auth/sessionMailbox token
GET/v1/mailbox/messagesMailbox token
GET/v1/mailbox/messages/:idMailbox token
PATCH/v1/mailbox/messages/:idMailbox token
POST/v1/mailbox/sendMailbox token
http
POST /v1/mailbox-auth/login
{ "address": "support@acme.io", "password": "…" }
→ { "token": "eyJ…", "expiresIn": "12h" }

GET  /v1/mailbox/messages?folder=inbox      Authorization: Bearer <mailbox token>
GET  /v1/mailbox/messages/:id
PATCH /v1/mailbox/messages/:id              { "unread": false, "starred": true, "folder": "archive" }
POST /v1/mailbox/send                       { "to", "subject", "body", "cc", "bcc", "replyToId", "fromAliasId" }
                                            or { "to", "subject", "templateId", "variables", "fromAliasId" }
DELETE /v1/mailbox/messages/:id             delete forever (the message must be in Trash)
POST /v1/mailbox/trash/empty                delete everything in this address's Trash
  • The mailbox token is not a dashboard session: it opens nothing outside that address.
  • POST /v1/mailbox/send has no from field: the sender is always the signed-in address. A reply from a mailbox is one row that is both the Sent entry and an entry on the Emails screen. While an operator has paused the workspace's sending it answers 409 workspace_sending_paused with the reason (the same refusal POST /v1/inbox/send gives the operator app); the mailbox app shows that message as-is rather than disabling Send ahead of time.
  • The mailbox app shows what the dashboard Inbox shows for that address: a Spam folder, how each message was checked and why it is in Spam, and the HTML body with its inline images. PATCH with { "folder": "spam" } marks a message as spam and { "folder": "inbox" } on a message in Spam marks it not spam, the same record the dashboard's move writes. An inline image is fetched with the mailbox token from the parts route under Attachments.
  • Wrong password, no password, archived and no-such-address are one identical refusal, deliberately.

Aliases and the inbox limit

GET/v1/mailboxes/:id/aliasesAPI key or session · read
POST/v1/mailboxes/:id/aliasesAPI key or session · manage:mailboxes
DELETE/v1/mailboxes/:id/aliases/:aliasIdAPI key or session · manage:mailboxes
GET/v1/mailbox-limitsAPI key or session · read
GET/v1/mailbox/addressesMailbox token
POST/v1/mailbox/aliasesMailbox token
DELETE/v1/mailbox/aliases/:idMailbox token
POST/v1/mailbox/inboxesMailbox token

An alias is an extra address on a mailbox's own domain that delivers into that mailbox. An address belongs to one mailbox or one alias, never both, across every workspace (otherwise 409 mailbox_address_taken). A mailbox user can send from their own aliases with fromAliasId on POST /v1/mailbox/send (another mailbox's alias is 404); there is still no free from. Archiving a mailbox pauses its aliases (they route nothing and do not count), and restoring it brings them back.

A signed-in mailbox user can also manage their own aliases and create a separate inbox on their domain, optionally with a password so its owner can sign in. Creating one does not let them read its mail.

Each domain may hold a limited number of addresses (active mailboxes plus their aliases), set by the platform administrator. Every create past it answers 409 mailbox_limit_reached, for example "acme.io can have at most 10 inboxes and aliases. Ask your administrator to raise the limit." Restoring an archived mailbox counts as a create.

http
# An alias: hello@acme.io delivers into info@acme.io's mailbox
POST /v1/mailboxes/<info's id>/aliases
{ "localPart": "hello" }
→ { "id": "…", "address": "hello@acme.io", "mailboxId": "<info's id>", "active": true }

# How many addresses each domain holds, and its limit
GET /v1/mailbox-limits
→ { "domains": [{ "domain": "acme.io", "used": 4, "max": 10 }] }

# A mailbox user, signed in at /mail
GET  /v1/mailbox/addresses              → aliases, inboxes on the domain, usage
POST /v1/mailbox/aliases                { "localPart": "hello" }
POST /v1/mailbox/inboxes                { "localPart": "sales", "displayName": "Sales", "password": "…" }

Attachments

POST/v1/mailbox/attachments?attached=Mailbox token
GET/v1/mailbox/messages/:id/parts/:partIdMailbox token
GET/v1/mailboxes/:id/messages/:messageId/parts/:partIdAPI key or session · read

Upload one file per request as multipart/form-data in a field named file, the moment it is chosen; the answer is { id, filename, contentType, sizeBytes }. Name the ids in attachmentIds on POST /v1/mailbox/send. Pass the ids already on the message in attached so the message total is checked while the person is still writing. A file that is never sent is deleted 24 hours after upload. Received files are listed under attachments on a message and download through the part routes; ?download=1 forces a download.

  • At most 10 MiB per file, 25 MiB per message and 20 files. Every limit is counted by the files' own size, the number a file dialog shows, not their size once encoded for email. Encoding makes a message about 37% larger in transit, so 25 MiB of files is roughly 34 MiB on the wire, and some receiving servers refuse anything over 20 to 25 MB: a message at the top of the limit can pass here and still bounce there. Refusals are 413 attachment_too_large and 413 attachments_too_large, each naming the file and the limit.
  • One address (or one dashboard user) may hold at most 50 uploaded files, 250 MiB in all, that are not yet on a sent message or a saved draft; past that an upload is 409 attachment_staging_limit. Sending or saving the message the files are on frees the room.
  • Uploads are limited to 30 a minute per address, 20 at once, and 300 a minute per workspace; past that the answer is 429 rate_limited with a Retry-After header and detail.scope attachment_upload.

Templates in the mailbox app

PATCH/v1/templates/:idAPI key or session · manage:templates
GET/v1/mailbox/templatesMailbox token
GET/v1/mailbox/templates/:idMailbox token
GET/v1/mailbox/templates/:id/htmlMailbox token
POST/v1/mailbox/templatesMailbox token
PATCH/v1/mailbox/templates/:idMailbox token
POST/v1/mailbox/templates/:id/publishMailbox token
POST/v1/mailbox/templates/:id/unpublishMailbox token
DELETE/v1/mailbox/templates/:idMailbox token
POST/v1/mailbox/templates/assetsMailbox token

A template can be offered to the people who sign in to addresses on one or more domains: in the designer, Available in inboxes on, or PATCH /v1/templates/:id with { "domainIds": ["<domain id>"] } (the whole set, replaced). An id that is not one of the workspace's domains is 400 template_domain_not_found. The mailbox app then shows it under Templates as a rendered preview, and a new message can send it: POST /v1/mailbox/send takes templateId and variables instead of body, with exactly the refusals of a template send: 400 template_variables_missing names a missing tag, and a draft is 409 template_not_published.

  • An address sees a template only when it is offered on that address's domain and is published, or was made from the mailbox app on that domain. Anything else, another domain's included, is 404 template_not_found, never a 403.
  • A mailbox user can design templates of their own with the same drag-and-drop designer, without the AI assistant. A template made there is offered on that address's domain and can be changed, published or deleted by any address on the same domain. One made in the dashboard is send-only in the mailbox app: 403 template_read_only. Which domains offer a template is never writable from the mailbox app.
  • Pictures upload through POST /v1/mailbox/templates/assets, the same store, types and 6 MiB limit as the dashboard's POST /v1/templates/assets.

Embed a mailbox

POST/v1/mailboxes/:id/embed-tokenAPI key or session · manage:mailboxes
http
# 1. Allow your page to frame the mailbox (once)
PATCH /v1/mailboxes/<id>
{ "embedOrigins": ["https://app.acme.io"] }

# 2. On every page render, from your backend
POST /v1/mailboxes/<id>/embed-token
→ { "token": "eyJ…", "expiresIn": "1h" }

# 3. In the page
<iframe src="https://sendclipper.com/embed/mail?token=<token>"
        style="width:100%;height:640px;border:0"></iframe>
  • embedOrigins empty means not embeddable. Exact origins only, no wildcards, no paths; http:// is accepted for localhost only. The frame's frame-ancestors policy is set from the token.
  • The token lives one hour and travels in the URL, so mint one per page render from your backend. Clearing the origins ends live embeds on their next request.

Back up and restore

GET/v1/backups/mail.mbox?mailbox=<id|all>&folder=Session · manage:backups
POST/v1/backups/mail/importsSession · manage:backups
GET/v1/backups/mail/importsSession · manage:backups
GET/v1/backups/mail/imports/:idSession · manage:backups

The dashboard's Backup & restore page. The download is one mbox file (mboxrd) of every message one address (or the whole workspace) shows, oldest first: received mail byte for byte, our own sends and drafts rebuilt with their attachments. Each message carries X-SendClipper-Folder and Gmail's X-Gmail-Labels, so a restore puts it back in the same folder. folder limits it to one of inbox, sent, drafts, archive, spam, trash.

A restore is a multipart/form-data upload: file (an .mbox from Google Takeout, Thunderbird or Apple Mail, one .eml, or a .zip of either, up to 1 GiB), mailboxId (the address to restore into) and an optional folder for mail that names none (default inbox). The answer is 202 and the restore runs in the background; poll it by id for status and the imported, duplicates, skipped and failed counts.

  • Owner and Admin only, session only: no API key reaches these routes. Where two-factor is required for the inbox, it is required here too.
  • A message already in the address (same Message-ID) is counted as a duplicate and not written again, so restoring a file twice, or restoring an export into the address it came from, adds nothing.
  • Restored mail is filed as received mail, even in Sent. It never appears in email logs, analytics or your usage, and sends no webhook or notification.
  • Restores in one workspace run one at a time: a file uploaded while another restore is running is accepted and waits its turn (status queued), up to five waiting (409 mail_import_queue_full beyond that). A file over the limit is 413 mail_import_too_large; another workspace's address or restore id is 404.
Operators signed in to the dashboard read the same mail under /v1/inbox/** and /v1/messages/** (two-factor required). Those routes serve the dashboard; integrate against /v1/mailboxes/**.

Sandboxes

A sandbox is a trap inbox for a project: point your staging app at it and every message it sends is caught, parsed and shown (HTML, text, headers, raw source), and nothing is delivered. Trapped mail is not on the Emails screen, sends no webhooks and touches no reputation number. Each sandbox keeps its last 500 messages for 30 days; at the cap the oldest is dropped, never the newest refused.

GET/v1/sandboxesAPI key or session · read
POST/v1/sandboxesAPI key or session · manage:sandboxes
FieldTypeDescription
namerequiredstring1 to 64 characters; becomes the slug. Unique per workspace (409 sandbox_name_taken).
projectLabelstringWhat it is for, up to 80 characters.
color#rrggbbFor the list.
GET/v1/sandboxes/:id/credentialsAPI key or session · manage:sandboxes

The SMTP host and port, username and password, the API token hint and the inbox address. The SMTP password can be read again any time; the API token is shown once, on creation and on reset.

POST/v1/sandboxes/:id/credentialsAPI key or session · manage:sandboxes

Resets both credentials at once, with no grace period.

PATCH/v1/sandboxes/:idAPI key or session · manage:sandboxes

Renames; the slug, address and credentials never move.

DELETE/v1/sandboxes/:idAPI key or session · manage:sandboxes

Really deletes the sandbox, its messages and their archived source.

SMTP

The main door: a config change, no code change, reachable from any machine. Use the host and port from the Integration tab with AUTH PLAIN or LOGIN and the sandbox's username and password. Port 2525 speaks STARTTLS, and login is refused until the connection has upgraded. In nodemailer that is secure: false, requireTLS: true, in cURL --ssl-reqd. Every RCPT TO is accepted (up to 50 per message) and nothing is ever delivered onward; messages up to 25 MiB.

.env (staging)
MAIL_HOST=<smtp host from the Integration tab>
MAIL_PORT=2525
MAIL_USERNAME=<sandbox username>
MAIL_PASSWORD=<sandbox password>
MAIL_ENCRYPTION=tls

The JSON door

POST/v1/sandbox/emailsSandbox token

Exactly POST /v1/emails with a different token and path: the same body field for field (from, to, subject, html, text, templateId, variables), the same 201 { id }. A project switches between the sandbox in staging and the real endpoint in production by changing two environment variables and no code. A marketing send (sendClass "marketing") gets the workspace's postal address in the footer exactly as the real door gives it (a template's {{postal_address}} filled, or one line appended), so what you see trapped is what production would send. One difference: with no address on file the sandbox never answers postal_address_missing; the message is trapped without the line, while the real door refuses. Add the address under Settings › Workspace before go-live. A marketing send also carries the one-click unsubscribe link the real door mints ({{unsubscribe_url}} filled on a template send, and the List-Unsubscribe headers in the trapped message's raw source), but clicking a trapped message's link answers “This was a test send” and suppresses nobody.

bash
curl -X POST https://sendclipper.com/v1/sandbox/emails \
  -H "Authorization: Bearer sc_sbx_<sandbox token>" \
  -H "Content-Type: application/json" \
  -d '{ "from": "hello@send.acme.io", "to": "anyone@example.com",
        "subject": "Test", "html": "<p>Caught.</p>" }'

The address

Every sandbox has an address of the form <name>@inbox.sendclipper.com, shown on the Integration tab. Anything delivered there is trapped, so a third-party service you are integrating can send its real notifications to it. A name another workspace already holds gets a short suffix. The tab says whether the address is live yet.

Reading trapped mail

GET/v1/sandboxes/:id/messages?search=&limit=API key or session · read
GET/v1/sandboxes/:id/messages/:messageIdAPI key or session · read
GET/v1/sandboxes/:id/messages/:messageId/rawAPI key or session · read

A summary is id, subject, fromAddress, fromName, toAddresses, snippet, sizeBytes, read, trappedAt; the detail adds ccAddresses, the SMTP envelope (envelopeFrom, envelopeTo, where a Bcc shows up), bodyHtml, bodyText (only when the message really carried a text part), headers, transport and clientIp. /raw is the original MIME as text/plain.

POST/v1/sandboxes/:id/read-allAPI key or session · manage:sandboxes
DELETE/v1/sandboxes/:id/messages/:messageIdAPI key or session · manage:sandboxes
DELETE/v1/sandboxes/:id/messagesAPI key or session · manage:sandboxes
Not thisThat
A sandbox (this section)A trap inbox. Mail goes nowhere.
The sending cap on a new workspaceA daily limit on real sends until a person on our side has reviewed the workspace (verify a domain, subscribe to a plan, have the Owner accept the terms, which a sign-up already does, then Request review on the dashboard). Past it, the send routes answer 403 sandbox_cap_reached.

Workspace and team

GET/v1/workspaceAPI key or session · read
PATCH/v1/workspaceSession · manage:workspace

Renames the workspace (name, 1 to 80 characters) and/or sets its postalAddress: free text, one address line per line, 10 to 500 characters, at most 8 lines. null or blank clears it. Both fields are also on GET. Every marketing message carries this address in its footer, and a marketing send with none on file is refused (Postal address). A bad address is 400 postal_address_invalid naming what was wrong, and nothing in the request is saved.

GET/v1/workspace/usageSession · read

Messages sent this month against the plan, and the retention window.

GET/v1/workspace/sandboxSession · read

A new workspace's daily sending allowance and how much of it is used today (the same figure the send routes enforce), plus conditions (domainVerified, billingReady, and termsAccepted: whether the workspace Owner has accepted the Terms of Service and the Acceptable Use Policy) and graduation: { state, requestedAt, reviewedAt, note }, where state is not_requested, requested, declined (with the reviewer's note) or approved.

POST/v1/workspace/graduation-requestSession · manage:workspace

Asks a person on our side to lift the cap. Allowed once every condition holds and sending is not paused; answers the status above with graduation.state = requested. The cap keeps counting until an operator approves; there is no self-serve way past it. 409 graduation_not_ready names what is missing in detail.missing; 409 workspace_already_graduated once the workspace is out. Asking again after a decline is accepted.

POST/v1/auth/terms-acceptanceSession · read

The signed-in person accepts the current Terms of Service and Acceptable Use Policy for their own account: { "acceptedTermsVersion": "2026-09-23" }, the version shown at the top of both pages. A sign-up accepts them on the register form; this route is for an account created before that. The workspace Owner's acceptance is one of the review conditions above. Answers { termsAcceptedAt, termsVersion, recorded } with 200, and recorded: false when the account already holds an acceptance (of any version), which is never overwritten; the answer then names what is on record. 400 terms_not_accepted for any other version.

GET/v1/teamSession · read
POST/v1/teamSession · manage:team

Invite a member as Admin, Developer or Viewer. Owner is not invitable.

GET/v1/sessionsSession · read
DELETE/v1/sessions/:idSession · read
GET/v1/rate-limitsSession · read
GET/v1/healthPublic

{ status: "ok", service: "mailplatform-api" }: a liveness check for your monitoring.

Error codes

Every named refusal is { error: { code, message, detail? } }. Codes are stable; messages may be reworded.

Authentication and access

CodeStatusMeaning
insufficient_role403Your role lacks the capability the route needs; detail names the roles that have it.
api_key_scope_insufficient403The key's permission (read / sending / full) does not cover this route.
api_key_domain_scope403A domain-scoped key used outside its domain.
mfa_required403Only on deployments that require two-factor for the Inbox (INBOX_MFA_REQUIRED=on): reading mailbox mail from the dashboard without having set up two-factor.
session_revoked401The session was signed out from another device or expired.
invalid_credentials401Mailbox sign-in refused.
mailbox_session_ended401A mailbox token whose address lost its password, was archived or was deleted.

Sending

CodeStatusMeaning
domain_unknown403from is not on a domain of this workspace.
domain_not_verified403DNS not yet confirmed for that domain.
domain_failed403A sending record was found broken on the last check.
domain_not_verified_for_sending_provider403Verified here, but not yet ready at the provider carrying SendClipper's outbound mail right now (Scaleway Transactional Email or Microsoft Azure). Publish that provider's records from the domain's DNS records (TEM_* or ACS_*), then run Check now.
provider_sender_not_registered403Only while SendClipper sends through Microsoft Azure: the from address is not one Azure can send from for that domain yet (by default only donotreply@). The message names the address that can send.
provider_recipient_limit400Only while SendClipper sends through Scaleway Transactional Email: more than 10 recipients (To, Cc and Bcc together) on one message.
provider_attachment_count400Same provider: more than 10 attachments.
provider_attachment_type400Same provider: an attachment of a type it does not accept (for example a zip); the message names the file.
provider_message_too_large413Same provider: the message and its attachments exceed 2 MB.
recipient_suppressed403The address is on the suppression list; detail.reason says why.
sandbox_cap_reached403A new workspace's daily sending allowance is spent.
postal_address_missing400A marketing send (sendClass marketing on POST /v1/emails or a batch item, a broadcast's send/test/schedule, an automation step) while the workspace has no postal address. Add one under Settings › Workspace (PATCH /v1/workspace postalAddress). In a batch it fails only the marketing items; a scheduled broadcast that comes due is held until an address is added; an automation step is recorded as failed with this message. Transactional mail never asks.
postal_address_invalid400PATCH /v1/workspace with a postalAddress that is too short, too long, over 8 lines or contains control characters; the message says which.
graduation_not_ready409POST /v1/workspace/graduation-request while a condition is outstanding or sending is paused; detail.missing lists domainVerified, billingReady, termsAccepted (the workspace Owner has not accepted the terms) and sendingPaused.
registration_closed403POST /v1/auth/register while sign-up is by invitation only and the request carries no valid inviteCode. Checked before anything else, so nothing is created.
terms_not_accepted400POST /v1/auth/register (or POST /v1/auth/terms-acceptance) without acceptedTermsVersion set to the current version of the Terms of Service and Acceptable Use Policy (missing, stale or not a string). detail.version is the value to send. On register it is checked before anything else, so nothing is created.
workspace_already_graduated409A review request, approval or decline aimed at a workspace that has already left the sandbox. Leaving the sandbox is permanent.
workspace_sending_paused409This workspace's sending is paused: by an operator, automatically when its bounce or complaint rate crossed the provider's critical line (see Usage & spend › Automatic pause), because a subscription payment failed and was not fixed by the date the Owner was told, or because a graduated workspace's subscription ended. The message names the reason and what to do (for a billing pause, resolve it under Settings › Plan & billing and sending resumes on its own). Answered by POST /v1/emails, the batch door (whole request), a broadcast's send/test/schedule and a mailbox reply. A scheduled broadcast that comes due is held until resume; an automation step that tries to send is recorded as failed with this reason.
batch_empty / batch_too_large400An empty array, or more than 500 items (detail.limit).
validation_failedNonePer-item, inside a batch response: the item's body failed validation.
header_not_allowed / header_value_invalid400Only In-Reply-To and References may be set, and never with a line break.
resend_not_failed / resend_body_expired / resend_already_in_flight / resend_attachment_missing409Send again refused; see Send again.

Templates

CodeStatusMeaning
template_not_found404Unknown id or another workspace's template.
template_not_published409A draft cannot be sent.
template_variables_missing400detail.missing lists tags with no value and no fallback.
template_variables_invalid400detail.unsafeLinks lists values used as a link that are not https, http or mailto.
subject_required / subject_invalid400No subject anywhere, or one with a line break / over 998 characters.
template_empty409Publishing a template with no blocks.
invalid_blocks400An unknown block type or a refused link.
broadcast_tags_unresolved400A broadcast body uses a tag outside the fixed vocabulary with no fallback.
broadcast_audience_empty400A broadcast's audience has no subscribed contacts, so nothing would be sent. A scheduled one is moved back to draft.

Requests

CodeStatusMeaning
rate_limited429Honour Retry-After; detail carries the limit that fired.
idempotency_key_invalid400Empty, too long or non-ASCII Idempotency-Key.
idempotency_key_in_flight409The first request with this key is still running. Retry the same key after Retry-After.
idempotency_key_reused409Same key, different body.
invalid_cursor400A tampered or expired pagination cursor.

Domains, mailboxes and sandboxes

CodeStatusMeaning
region_immutable409The domain already holds messages; its region cannot change.
mailbox_address_taken409That address exists somewhere on SendClipper.
mailbox_catch_all_taken409The domain already has a catch-all.
mailbox_archived409The address is archived; unarchive it first.
mailbox_not_embeddable409No embedOrigins are set, so no embed token can be minted.
mailbox_password_too_short400A mailbox password needs 10 characters.
sandbox_name_taken409A sandbox with that name exists in this workspace.
Anything that belongs to another workspace (a message, a template, a domain, a mailbox) answers 404, never 403. The API does not confirm that an object exists for someone else.