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 URL | https://sendclipper.com/v1 |
| Format | JSON request and response bodies, UTF-8. Timestamps are ISO 8601 in UTC. Ids are UUIDs unless a public id is shown (msg_…, tpl_…, evt_…). |
| Auth | API key in an Authorization: Bearer header for your servers; a session for the dashboard. |
| Versioning | Everything 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.
- Sign in at /login. Every account has a workspace, and the person it was created for is its Owner.
- 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
verifiedbefore anything can be sent from it. - Create an API key on the API Keys screen with the
sendingpermission. The full key (sc_live_…) is shown once; store it in your secret manager. - Send:
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 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"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"]) # queuedHTTP/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.
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:
Authorization: Bearer sc_live_9f2b7c10ad42c3d4e5f60718a1b2c3d4Keys 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).
| Field | Type | Description |
|---|---|---|
namerequired | string | 1 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. |
domainId | uuid | Restrict 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.
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/templatesand 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-dispatchesandGET /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/summaryand/:id. - Mailboxes:
/v1/mailboxes/**. - Who am I:
GET /v1/workspaceand the maskedGET /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:
| Status | Code | When |
|---|---|---|
| 401 | api_key_not_accepted | The route needs a signed-in dashboard session. |
| 403 | api_key_domain_scoped | The key is limited to one domain; management routes need a workspace-wide key. |
| 403 | insufficient_role | The key's permission lacks what the route needs (see below). |
Permissions and roles
| Key permission | Can call |
|---|---|
sending | Send (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. |
read | Read routes only. Cannot send or change anything. |
full | Everything 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
froman address on exactly that domain:acme.iodoes not coverbilling.acme.io. Anything else is403 api_key_domain_scope. - It may manage and read only the mailboxes on that domain. Another domain's mailbox answers
404, never403. - 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).
{
"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.
{
"statusCode": 400,
"message": [
"to must be a valid email address",
"subject should not be empty"
],
"error": "Bad Request"
}| Status | Meaning |
|---|---|
| 400 | Validation failed, or a named refusal such as a missing merge tag. |
| 401 | No credential, an invalid or revoked key, or an expired session. |
| 403 | The credential is valid but may not do this (role, key permission, domain scope, unverified domain). |
| 404 | Not found, including anything that belongs to another workspace. Nothing on this API answers 403 for another tenant's object. |
| 409 | A state conflict: a template that is not published, a resend already in flight, a reused Idempotency-Key with a different body. |
| 429 | Rate limited. Honour Retry-After. |
| 5xx | Our 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_flightwithRetry-After: 1. Wait and retry with the same key; do not mint a new one. - An empty or malformed key is
400 idempotency_key_invalidrather 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 answerIdempotency-Status: skipped-unparseable-body.
Rate limits
| Limit | Default | Applies to |
|---|---|---|
| Per key | 10 requests/second, bursts to 50 | Each API key, independently. |
| Per workspace | 100 requests/second | Every 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/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=50answering{ items, total, page, pageSize }. Used byGET /v1/emails(max 100) andGET /v1/contacts(max 200). - Cursor:
?limit=50&cursor=…answering{ data, page: { cursor, hasMore } }. Passpage.cursorback to continue; a tampered cursor is400 invalid_cursor. Used byGET /v1/suppressionsandGET /v1/webhook-dispatches, and by the mailbox lists (GET /v1/mailboxes/:id/messages,/v1/mailbox/messages), which keep theiritemsand addpage.
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:
npm install @sendclipper/node
# or
pnpm add @sendclipper/node
# or
yarn add @sendclipper/node
# or
bun add @sendclipper/nodeCreate an API key under Settings › API keys, then make a client:
import { SendClipper } from '@sendclipper/node';
const sc = new SendClipper('sc_live_xxxxxxxxxxxxxxxx');
// or: new SendClipper() reads SENDCLIPPER_API_KEYSend an email
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:
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:
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:
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
| Resource | Methods |
|---|---|
emails | send, get, list, iterate, stats |
batch | send |
domains | list, create, records, verify, enableReceiving, disableReceiving, setRegion, deletionPreview, remove |
templates | list, get, html, create, update, remove, duplicate, publish, unpublish, preview, setTags, projects |
sandboxes | list, get, create, update, remove, credentials, resetCredentials, send |
sandboxes.messages | list, get, raw, remove, clear, markAllRead, waitFor |
webhooks | list, create, update, remove, rotateSecret, deliveries, verify |
webhooks.dispatches | list, iterate, get, replay, replayAll |
contacts | list, iterate, create, import, importPreview, export |
suppressions | list, iterate, create, remove, export |
mailboxes | list, get, create, update, archive |
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:
pip install sendclipper
# or
uv add sendclipper
# or
poetry add sendclipperCreate an API key under Settings › API keys, then make a client:
from sendclipper import SendClipper
sc = SendClipper("sc_live_xxxxxxxxxxxxxxxx")
# or: SendClipper() reads SENDCLIPPER_API_KEYSend an email
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:
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()):
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 "", 200During 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:
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
| Resource | Methods |
|---|---|
emails | send, get, list, iterate, 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 |
webhooks | list, create, update, remove, rotate_secret, deliveries, verify |
webhooks.dispatches | list, iterate, get, replay, replay_all |
contacts | list, iterate, create, import_, import_preview, export |
suppressions | list, iterate, create, remove, export |
mailboxes | list, get, create, update, archive |
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
composer require sendclipper/sendclipper-phpCreate an API key under Settings › API keys, then make a client:
use SendClipper\SendClipper;
$sc = new SendClipper('sc_live_xxxxxxxxxxxxxxxx');
// or: new SendClipper() reads SENDCLIPPER_API_KEYSend an email
[$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:
$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:
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:
$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
| Resource | Methods |
|---|---|
emails | send, get, list, iterate, stats |
batch | send |
domains | list, create, records, verify, enableReceiving, disableReceiving, setRegion, deletionPreview, remove |
templates | list, get, html, create, update, remove, duplicate, publish, unpublish, preview, setTags, projects |
sandboxes | list, get, create, update, remove, credentials, resetCredentials, send |
sandboxes->messages | list, get, raw, remove, clear, markAllRead, waitFor |
webhooks | list, create, update, remove, rotateSecret, deliveries, verify |
webhooks->dispatches | list, iterate, get, replay, replayAll |
contacts | list, iterate, create, import, importPreview, export |
suppressions | list, iterate, create, remove, export |
mailboxes | list, get, create, update, archive |
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
go get github.com/sendclipper/sendclipper-goCreate an API key under Settings › API keys, then make a client. One client is safe to share across goroutines:
import "github.com/sendclipper/sendclipper-go"
client := sendclipper.NewClient("sc_live_xxxxxxxxxxxxxxxx")
// or: sendclipper.NewClient("") reads SENDCLIPPER_API_KEYSend an email
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:
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:
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:
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
| Service | Methods |
|---|---|
Emails | Send, Get, List, All, Stats |
Batch | Send |
Domains | List, Create, Records, Verify, EnableReceiving, DisableReceiving, SetRegion, DeletionPreview, Remove |
Templates | List, Get, HTML, Create, Update, Remove, Duplicate, Publish, Unpublish, Preview, SetTags, Projects |
Sandboxes | List, Get, Create, Update, Remove, Credentials, ResetCredentials, Send |
Sandboxes.Messages | List, Get, Raw, Remove, Clear, MarkAllRead, WaitFor |
Webhooks | List, Create, Update, Remove, RotateSecret, Deliveries, Verify |
Webhooks.Dispatches | List, All, Get, Replay, ReplayAll |
Contacts | List, All, Create, Import, ImportPreview, Export |
Suppressions | List, All, Create, Remove, Export |
Mailboxes | List, Get, Create, Update, Archive |
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
gem install sendclipper
# or, in a Gemfile
bundle add sendclipperCreate an API key under Settings › API keys, then make a client:
require "sendclipper"
client = SendClipper::Client.new("sc_live_xxxxxxxxxxxxxxxx")
# or: SendClipper::Client.new reads SENDCLIPPER_API_KEYSend an email
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"
endFields 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:
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:
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
endDuring 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:
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
| Resource | Methods |
|---|---|
emails | send, get, list, each, 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 |
webhooks | list, create, update, remove, rotate_secret, deliveries, verify |
webhooks.dispatches | list, each, get, replay, replay_all |
contacts | list, each, create, import, import_preview, export |
suppressions | list, each, create, remove, export |
mailboxes | list, get, create, update, archive |
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
cargo add sendclipper
cargo add tokio --features macros,rt-multi-threadCreate an API key under Settings › API keys, then make a client. It is cheap to clone and safe to share across tasks:
let client = sendclipper::Client::new("sc_live_xxxxxxxxxxxxxxxx");
// or: sendclipper::Client::from_env() reads SENDCLIPPER_API_KEYSend an email
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:
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):
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:
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, ¶ms).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
| Resource | Methods |
|---|---|
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 |
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:
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:
{
"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 say | What 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:
mkdir -p ~/.sendclipper
curl -fsSL https://sendclipper.com/sendclipper-mcp.cjs -o ~/.sendclipper/sendclipper-mcp.cjsConnect
Claude Code, running the downloaded file:
claude mcp add sendclipper \
-e SENDCLIPPER_API_KEY=sc_live_… \
-- node ~/.sendclipper/sendclipper-mcp.cjsClaude Desktop, Cursor and VS Code read an mcpServers block from their settings file:
{
"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.
node ~/.sendclipper/sendclipper-mcp.cjs --http --port 3333 # binds 127.0.0.1A 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
| Variable | Default | Meaning |
|---|---|---|
SENDCLIPPER_API_KEY | None | The sc_live_… workspace key. |
SENDCLIPPER_API_URL | https://sendclipper.com | API origin, without /v1. |
SENDCLIPPER_APP_URL | derived | Dashboard origin used in links. |
SENDCLIPPER_SANDBOX_TOKEN | None | An sc_sbx_… token; lets sandbox_send_test_email use the sandbox JSON door instead of SMTP. |
SENDCLIPPER_PUBLIC_API_URL | the API URL | API origin shown in generated snippets and .env lines, when the server reaches the API another way. |
SENDCLIPPER_MCP_ALLOWED_HOSTS | None | Host names --http accepts besides loopback (same as --allowed-hosts); required to bind anything but loopback. |
SENDCLIPPER_MCP_HOSTED | off | on 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.
| Area | Tool | What it does |
|---|---|---|
| Account | account_whoami | Which workspace the key belongs to and what it may do. |
account_setup_status | The setup checklist: domain, sandbox, template, key. | |
| Domains | domain_list | List sending domains and their status. |
domain_add | Add a sending domain. | |
domain_get_dns_records | The DNS records to publish. | |
domain_verify | Check a domain now. | |
domain_enable_receiving | Turn receiving on for a domain. | |
domain_disable_receiving | Turn receiving off. | |
domain_set_region | Change a domain's region (before it holds mail). | |
domain_deletion_preview | What deleting a domain would remove. | |
domain_delete | Delete a domain. | |
| Templates | template_list | List templates, optionally by project or tag. |
template_projects_list | List the Email development projects (one per domain). | |
template_get | Read one template with its blocks. | |
template_create | Create a template from blocks. | |
template_update | Change a template's blocks, subject or name. | |
template_get_variables | List a template's merge tags. | |
template_set_variable_defaults | Set merge-tag fallbacks. | |
template_rename_variable | Rename a merge tag everywhere it is used. | |
template_preview | Render with variables: subject, text, unsafe links. | |
template_get_html | Export the rendered HTML. | |
template_publish | Make a template sendable. | |
template_unpublish | Take it back to draft. | |
template_duplicate | Copy a template. | |
template_delete | Delete a template. | |
template_tag_list | List template tags. | |
template_tag_create | Create a tag. | |
template_tag_delete | Delete a tag. | |
template_set_tags | Set the tags on a template. | |
template_list_images | List uploaded template images. | |
template_upload_image | Upload an image for an image block. | |
| Sending | email_send | Send one real email, by html or by template. |
email_send_batch | Send up to a batch of real emails. | |
email_list | List sent emails. | |
email_get | One sent email with its event timeline. | |
| Sandboxes | sandbox_list | List sandboxes. |
sandbox_create | Create a sandbox. | |
sandbox_get | One sandbox with its inbox address and counts. | |
sandbox_rename | Rename (credentials do not move). | |
sandbox_delete | Delete a sandbox and its messages. | |
sandbox_get_credentials | SMTP credentials and the inbox address. | |
sandbox_reset_credentials | Replace the SMTP password and API token. | |
sandbox_list_messages | List trapped messages. | |
sandbox_get_message | Read one trapped message. | |
sandbox_get_raw | The raw MIME source. | |
sandbox_delete_message | Delete one trapped message. | |
sandbox_clear | Delete every message in a sandbox. | |
sandbox_mark_read | Mark messages read. | |
sandbox_send_test_email | Send a test email into a sandbox. | |
sandbox_wait_for_message | Wait until a message arrives. | |
| Webhooks | webhook_event_types | The event types an endpoint can subscribe to. |
webhook_list | List endpoints. | |
webhook_create | Create an endpoint. | |
webhook_update | Change an endpoint's URL, events or state. | |
webhook_delete | Delete an endpoint. | |
webhook_rotate_secret | Rotate the signing secret. | |
webhook_verify_examples | Signature-verification snippets. | |
webhook_list_undelivered | Dead-lettered events. | |
webhook_replay | Replay one dead-lettered event. | |
webhook_replay_all | Replay every dead-lettered event in scope. | |
| Inbox | mailbox_list | List receiving addresses with unread counts. |
mailbox_get | One address: can it receive, can it send, counts. | |
mailbox_list_aliases | The aliases that deliver into an address. | |
inbox_list_messages | List an address's mail by folder, search or label. | |
inbox_search | Search mail across every address. | |
inbox_get_message | Read one message: body, attachments, verdicts. | |
inbox_get_thread | Read a whole conversation, oldest first. | |
inbox_get_attachment | Download one attachment. | |
inbox_wait_for_message | Wait until a message arrives at an address. | |
| Audience | audience_list | List audiences. |
contact_list | List contacts. | |
contact_create | Add a contact. | |
contact_import | Import contacts from CSV text. | |
suppression_list | List suppressed addresses. | |
suppression_add | Suppress an address by hand. | |
suppression_remove | Lift a removable suppression. | |
| Integration | integration_snippet | Sending code for the project's stack. |
integration_env | A .env block for a sandbox or the live API. | |
| SDKs | sdk_list | The six official SDKs, their versions and install commands. |
sdk_detect | Pick the SDK and package manager from the project's files. | |
sdk_install | Install command, .env lines and a client module for the project. | |
sdk_snippet | SDK 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:
| Resource | Contents |
|---|---|
sendclipper://docs/template-blocks | Every template block type and field, limits, link and colour rules, merge-tag syntax and a worked example. Read before writing blocks. |
sendclipper://docs/sending-templates | Sending 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}/html | The 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):
| Prompt | What it walks through |
|---|---|
setup_project | Connect a codebase: domain, DNS, a development sandbox, a first template and the sending code. |
design_template | Design, preview and optionally publish a template from a short brief. |
test_in_sandbox | Point the app at a sandbox, trigger a flow and inspect what it sent. |
debug_delivery | Find 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_rolerelayed. - The server never sends real mail unless you ask.
email_sendandemail_send_batchare marked open-world, and the built-in instructions steer assistants tosandbox_send_test_emailwhile 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).
Send email
Send a message
| Field | Type | Description |
|---|---|---|
fromrequired | string | A 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. |
torequired | string | One recipient address. Send a batch for more. |
subject | string | Required 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. |
html | string | The HTML body. Required unless templateId is set; sending both is 400. |
text | string | Plain-text alternative, up to 100 000 characters. Generated from the template blocks when omitted on a template send. |
templateId | string | A published template, by tpl_… public id or row id. See Sending a template. |
template | string | A 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. |
variables | object | Merge-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. |
trackOpens | boolean | Defaults to true. false leaves the open-tracking pixel out of this message, so it never records an opened event. |
trackClicks | boolean | Defaults 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.
| Refusal | Status | Why |
|---|---|---|
domain_unknown | 403 | from is not on a domain of this workspace. |
domain_not_verified | 403 | The domain exists but its DNS is not confirmed. Publish the records and press Check now. |
domain_failed | 403 | A 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_provider | 403 | The 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_registered | 403 | SendClipper 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_scope | 403 | A domain-scoped key sending from another domain. |
recipient_suppressed | 403 | The recipient is on the suppression list (hard bounce, complaint, unsubscribe or manual). detail carries the reason. |
sandbox_cap_reached | 403 | A 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_missing | 400 | sendClass 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_paused | 409 | This 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 / 409 | See 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.
| Status | Meaning |
|---|---|
queued | Accepted and waiting for the send worker. |
sent | Handed to the provider; sesMessageId is set. |
delivered | The receiving server accepted it. |
opened | The tracking pixel loaded. |
clicked | A tracked link was followed. |
bounced | Rejected by the receiving server. A hard bounce suppresses the address. |
complained | Marked as spam by the recipient. The address is suppressed. |
failed | Could not be handed to the provider after retries. Send again once the cause is fixed. |
List and inspect
| Field | Type | Description |
|---|---|---|
search | string | Full-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. |
status | string | One of the statuses above. Funnel-cumulative: delivered includes opened and clicked. |
domain | string | Exact sending domain, case-insensitive. |
broadcastId | uuid | Only messages sent by this broadcast. |
from / to | ISO 8601 | Created-at window. |
page / pageSize | int | Defaults 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.
{
"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" }
]
}Counts per status for the Emails screen's header.
Send again
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 asemail.opened/email.clicked. - Bodies are kept for 30 days. After that the HTML and text are deleted,
bodyExpiredflips to true andbodyExpiredAtis set. The row, its status and its whole event timeline stay forever. - An
In-Reply-To/Referencespair is set automatically when you reply from a mailbox, so the conversation threads.GET /v1/emails/:idexposes the resultingthreadId.
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:
| Notice | When | What it says |
|---|---|---|
| Approaching the allowance | At 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 started | The 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.
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
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.
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>" }
]
}'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].indexis the position in your array; results come back in order.- A failed item carries the same
error.codethe single endpoint would have answered with, plusvalidation_failedfor 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-Keycovers 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:
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"
}
}'htmlandtemplateIdare exclusive; sending both, or neither, is400.subjectis optional: the template's own subject is the default, your non-empty value overrides it, and it is substituted with the same variables.textis 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/:idrecords which template was sent (template,templatePublicId).- Your own
htmlcan carry tags too: sendvariableswith it (notemplateId) and its{{tags}}, the subject's and yourtext's are filled by the same rules, with the same refusals below. Withoutvariablesthe body goes out exactly as written, braces and all. Tags in a link work in any quotedhref.
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:
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
fromdomain (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 ownregistration. templateandtemplateIdare exclusive. Everything else (variables, the refusals below, the batch door, the sandbox door) works exactly as for an id. An unknown name is404 template_not_foundnaming the domain it looked in.
| Refusal | Status | Why |
|---|---|---|
template_not_found | 404 | Unknown id or name, or a template in another workspace. |
template_not_published | 409 | Still a draft. Publish it first. |
template_variables_missing | 400 | A tag has neither a value nor a fallback; detail.missing lists them. A blank is never sent. |
template_variables_invalid | 400 | A value used as a link target is not https://, http:// or mailto: (detail.unsafeLinks). |
subject_required | 400 | Neither the template nor the request has a subject. |
subject_invalid | 400 | The resolved subject has a line break or exceeds 998 characters. |
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. AsendClass: "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 ownpostal_addressvalue 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
?project=<domain id> lists one Email development project's templates.
Every domain as an Email development project, with templateCount and publishedCount.
| Field | Type | Description |
|---|---|---|
name | string | 1 to 120 characters. |
projectDomainId | uuid | The 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. |
key | string | The send name (registration): lower-case letters, digits, -, _; unique per project (409 template_key_taken). Also settable with PATCH, where null clears it. |
subject | string | Default subject, up to 300 characters. May contain merge tags. |
preheader | string | Preview text, up to 300 characters. |
blocks | Block[] | The designer's block list. Unknown block types and javascript: links are refused with 400 invalid_blocks. |
The rendered HTML, unresolved, as text/html.
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).
A deleted template's id stops resolving; messages already sent are unaffected.
| Field | Type | Description |
|---|---|---|
mimeTyperequired | string | image/png, image/jpeg, image/webp or image/gif. |
base64required | string | The file bytes. |
name | string | Display 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.
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
| Field | Type | Description |
|---|---|---|
urlrequired | string | The receiver, https:// only. An http:// URL is refused unless allowInsecureHttp is set. Private and internal destinations are refused outright (see below). |
eventsrequired | string[] | Unique subset of the six event types. An empty array is a valid endpoint that receives nothing. |
allowInsecureHttp | boolean | Acknowledge 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.
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.
Serves the two verifier snippets below plus the tolerance, so the product, this page and the test suite state one number.
Recent attempts with status code, latency and the receiver's answer.
Events and payload
| Event | When |
|---|---|
email.delivered | The receiving server accepted the message. |
email.opened | The tracking pixel loaded in a mail client. |
email.clicked | A tracked link in the message was followed. |
email.bounced | The 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.complained | The recipient marked it as spam. The address is suppressed automatically. |
email.failed | SendClipper could not hand it to the provider after retries. |
{
"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"
}
}idis 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.emailIdis the message id you got back from POST /v1/emails;data.messageIdis the provider's id for the same send.- Headers:
Content-Type: application/json,User-Agent: SendClipper-Webhooks/1.0. Redirects are never followed: a3xxis 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
| Header | Value | Status |
|---|---|---|
X-SendClipper-Timestamp | 1756800000: seconds since the epoch, stamped when this attempt was signed | current |
X-SendClipper-Signature-V2 | t=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 Flaskrequest.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-Signaturecovers the body only, verifies forever, and during a rotation carries the new secret only. Move toX-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 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 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", 200Retries, 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, every3xx, 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.
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.
The payload we sent, every attempt, and your server's last answer.
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-Keyreturns the same secret, not a second one. Two concurrent rotations are a conditional write: one wins, the other gets409with 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; thelocalhost,.local,.internaland.home.arpasuffixes; any URL carryinguser: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 explicitallowInsecureHttp: 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
| Field | Type | Description |
|---|---|---|
namerequired | string | A 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.
What deleting the domain takes with it: mailboxes, aliases, apiKeys scoped to it, portal, plus the messages and templates that stay behind and platformSender.
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
[
{ "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 }
]| Purpose | Needed for | What it does |
|---|---|---|
MX | Sending | Bounce handling on the custom MAIL FROM subdomain (mail.<domain>). |
SPF | Sending | Authorises our servers to send for that subdomain. |
DKIM | Sending | Three CNAMEs that let receivers verify our signature. All three must resolve. |
DMARC | Sending | Publishes your policy; p=none to start, tighten later. One _dmarc record serves both providers (alsoFor: ["scaleway"]). |
TEM_SPF | Sending through Scaleway | Scaleway'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_DKIM | Sending through Scaleway | Scaleway's DKIM key, a TXT under its own selector. It sits beside the Amazon SES CNAMEs and replaces none of them. |
RECEIVING_TOKEN | Receiving | A 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_INBOUND | Receiving | The 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
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
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
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.
| Reason | Scope | Written by |
|---|---|---|
hard_bounce | all mail | A permanent bounce from the receiving server. |
soft_bounce | all mail | Repeated 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. |
complaint | all mail | A spam report from the recipient's provider. |
unsubscribe | marketing 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. |
manual | all mail | POST /v1/suppressions or the Suppressions screen. |
provider | all mail | Amazon 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.
Cursor-paginated: { data, page: { cursor, hasMore }, counts }, 25 per page by default, up to 100. counts is the per-reason total for the header.
The whole list as a streamed CSV download.
| Field | Type | Description |
|---|---|---|
emailrequired | string | The address to block. Lower-cased on save. |
note | string | Why, up to 500 characters. Shown on the row. |
| Field | Type | Description |
|---|---|---|
reasonrequired | string | Required body. 10 to 500 characters explaining why the address may be mailed again; kept in the audit trail. |
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
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.The Overview screen's tiles: 14 or 30 days.
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:
| Address | Write to it about |
|---|---|
| abuse@sendclipper.com | Mail sent through SendClipper that you never asked for: spam, phishing, a list you never joined. |
| postmaster@sendclipper.com | Mailbox 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.
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.
Every audience with its live member count.
{ items, total, page, pageSize }, 25 per page by default and up to 200. status is subscribed, unsubscribed or bounced.
| Field | Type | Description |
|---|---|---|
namerequired | string | Up to 120 characters. |
emailrequired | string | Lower-cased on save; unique per workspace. |
audienceId | uuid | Defaults to the workspace's default audience. |
status | "subscribed" | "unsubscribed" | "bounced" | Defaults to subscribed. |
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:
| Field | Type | Description |
|---|---|---|
filerequired | file | The CSV or vCard. |
audienceId | uuid | Every row goes into this audience; the file's own audience column is ignored. |
newAudience | string | Every 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. |
mapping | JSON string | One 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.
name,email,audience
Ada Lovelace,ada@lumen.io,Newsletter
Mira Fabbri,mira@northwind.io,Customerscurl -X POST https://sendclipper.com/v1/contacts/import \
-H "Authorization: Bearer <session token>" \
-F "file=@contacts.csv"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.
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.
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.
| Field | Type | Description |
|---|---|---|
namerequired | string | 1 to 80 characters (create only). |
rulesrequired | Rule[] | At least one rule; all rules must match (AND). |
rules[].field | string | opened_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[].value | number | string | Shaped per field: a count, an ISO date, an audience id, a status. |
rules[].days | int | 1 to 3650. Window for the count fields: opened at least N times in the last D days. |
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
Every field is optional. An empty body creates an empty draft for the builder to fill in.
| Field | Type | Description |
|---|---|---|
name | string | Up to 200 characters. |
audienceId | uuid | null | Who receives it: an audience's subscribed contacts. |
segmentId | uuid | null | Or a segment's matching subscribed contacts, evaluated at send time. Setting one clears the other; both at once is 400 broadcast_two_targets. |
from | string | An address on a verified domain. |
subject / previewText | string | Up to 300 characters each. May carry merge tags. |
deliveryMode | "now" | "schedule" | "recurring" | How it goes out. |
scheduledAt | ISO 8601 | null | When, 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). |
templateId | string | null | A published template, for bodySource template. |
bodyHtml | string | null | Pasted HTML, up to 500 000 characters, for bodySource html. |
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.
Sends one copy to your own address with a test unsubscribe link that suppresses nobody.
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.
The recipient picker: every audience with its subscribed count, and every segment with the number of subscribed contacts it matches now.
Body and personalisation
| bodySource | Rendered from |
|---|---|
sample | The built-in sample email. The default. |
template | A 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. |
html | Your 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
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.
Postal address
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.
| Field | Type | Description |
|---|---|---|
emailrequired | string | An existing contact in this workspace. The audience filter does not apply; subscription state and suppressions do. |
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" }'| Refusal | Status | Why |
|---|---|---|
automation_paused | 409 | The flow is not Live. |
contact_not_found | 404 | No contact with that address. Add it first. |
contact_not_subscribed | 409 | The contact unsubscribed or bounced. |
recipient_suppressed | 409 | The address is on your suppression list. |
already_enrolled | 409 | This contact has already been through the flow. |
| Field | Type | Description |
|---|---|---|
segmentIdrequired | uuid | Every 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). |
| Field | Type | Description |
|---|---|---|
namerequired | string | Up to 80 characters. |
recipe | "blank" | "welcome" | "nudge" | "drip" | Starting steps. Default blank (one email). |
trigger | "contact.subscribed" | "manual" | What starts it. Default contact.subscribed. |
| Field | Type | Description |
|---|---|---|
name | string | Rename. |
trigger | "contact.subscribed" | "manual" | Change what starts it. |
audienceId | uuid | null | Only contacts in this audience (null = all). |
segmentId | uuid | null | Or only contacts matching this segment when they are added (never both). |
active | boolean | Live or paused. Going Live is refused (409 automation_incomplete) while a step cannot run, naming the step. |
| Field | Type | Description |
|---|---|---|
typerequired | "send" | "wait" | "branch" | Inserted before the exit step. |
subject | string | Required for send, up to 160 characters. |
body | string | Up to 2,000 characters. |
from | string | Sender address on one of your verified domains. |
days / hours | int | For 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. |
segmentId | uuid | Required for in_segment / not_in_segment. |
| Field | Type | Description |
|---|---|---|
status | "active" | "waiting" | "completed" | "exited" | "failed" | Filter by where the contact is. |
limit | int | 1 to 200, default 50. |
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
| Field | Type | Description |
|---|---|---|
domainIdrequired | uuid | A domain of this workspace. |
localPartrequired | string | The part before the @, 1 to 64 characters. Addresses are unique across SendClipper, case-insensitively: 409 mailbox_address_taken. |
displayName | string | Up to 120 characters. |
color | #rrggbb | For the rail. |
catchAll | boolean | Receive 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.
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.
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.
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
- The recipient's domain must be a receiving-verified domain of exactly that name. Otherwise the message is refused.
- An address matching the local part (ignoring a
+tag) files it there. - Otherwise an alias with that local part files it in the alias's mailbox (the message keeps the alias as its recipient).
- Otherwise the domain's catch-all takes it.
- 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
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.
# 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
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/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/sendhas nofromfield: 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 answers409 workspace_sending_pausedwith the reason (the same refusalPOST /v1/inbox/sendgives 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.
PATCHwith{ "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
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.
# 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
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_largeand413 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_limitedwith aRetry-Afterheader anddetail.scopeattachment_upload.
Templates in the mailbox app
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'sPOST /v1/templates/assets.
Embed a mailbox
# 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>embedOriginsempty means not embeddable. Exact origins only, no wildcards, no paths;http://is accepted forlocalhostonly. The frame'sframe-ancestorspolicy 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
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_fullbeyond that). A file over the limit is413 mail_import_too_large; another workspace's address or restore id is404.
/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.
| Field | Type | Description |
|---|---|---|
namerequired | string | 1 to 64 characters; becomes the slug. Unique per workspace (409 sandbox_name_taken). |
projectLabel | string | What it is for, up to 80 characters. |
color | #rrggbb | For the list. |
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.
Resets both credentials at once, with no grace period.
Renames; the slug, address and credentials never move.
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.
MAIL_HOST=<smtp host from the Integration tab>
MAIL_PORT=2525
MAIL_USERNAME=<sandbox username>
MAIL_PASSWORD=<sandbox password>
MAIL_ENCRYPTION=tlsThe JSON door
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.
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
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.
| Not this | That |
|---|---|
| A sandbox (this section) | A trap inbox. Mail goes nowhere. |
| The sending cap on a new workspace | A 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
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.
Messages sent this month against the plan, and the retention window.
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.
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.
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.
Invite a member as Admin, Developer or Viewer. Owner is not invitable.
{ 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
| Code | Status | Meaning |
|---|---|---|
insufficient_role | 403 | Your role lacks the capability the route needs; detail names the roles that have it. |
api_key_scope_insufficient | 403 | The key's permission (read / sending / full) does not cover this route. |
api_key_domain_scope | 403 | A domain-scoped key used outside its domain. |
mfa_required | 403 | Only 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_revoked | 401 | The session was signed out from another device or expired. |
invalid_credentials | 401 | Mailbox sign-in refused. |
mailbox_session_ended | 401 | A mailbox token whose address lost its password, was archived or was deleted. |
Sending
| Code | Status | Meaning |
|---|---|---|
domain_unknown | 403 | from is not on a domain of this workspace. |
domain_not_verified | 403 | DNS not yet confirmed for that domain. |
domain_failed | 403 | A sending record was found broken on the last check. |
domain_not_verified_for_sending_provider | 403 | Verified 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_registered | 403 | Only 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_limit | 400 | Only while SendClipper sends through Scaleway Transactional Email: more than 10 recipients (To, Cc and Bcc together) on one message. |
provider_attachment_count | 400 | Same provider: more than 10 attachments. |
provider_attachment_type | 400 | Same provider: an attachment of a type it does not accept (for example a zip); the message names the file. |
provider_message_too_large | 413 | Same provider: the message and its attachments exceed 2 MB. |
recipient_suppressed | 403 | The address is on the suppression list; detail.reason says why. |
sandbox_cap_reached | 403 | A new workspace's daily sending allowance is spent. |
postal_address_missing | 400 | A 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_invalid | 400 | PATCH /v1/workspace with a postalAddress that is too short, too long, over 8 lines or contains control characters; the message says which. |
graduation_not_ready | 409 | POST /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_closed | 403 | POST /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_accepted | 400 | POST /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_graduated | 409 | A review request, approval or decline aimed at a workspace that has already left the sandbox. Leaving the sandbox is permanent. |
workspace_sending_paused | 409 | This 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_large | 400 | An empty array, or more than 500 items (detail.limit). |
validation_failed | None | Per-item, inside a batch response: the item's body failed validation. |
header_not_allowed / header_value_invalid | 400 | Only 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_missing | 409 | Send again refused; see Send again. |
Templates
| Code | Status | Meaning |
|---|---|---|
template_not_found | 404 | Unknown id or another workspace's template. |
template_not_published | 409 | A draft cannot be sent. |
template_variables_missing | 400 | detail.missing lists tags with no value and no fallback. |
template_variables_invalid | 400 | detail.unsafeLinks lists values used as a link that are not https, http or mailto. |
subject_required / subject_invalid | 400 | No subject anywhere, or one with a line break / over 998 characters. |
template_empty | 409 | Publishing a template with no blocks. |
invalid_blocks | 400 | An unknown block type or a refused link. |
broadcast_tags_unresolved | 400 | A broadcast body uses a tag outside the fixed vocabulary with no fallback. |
broadcast_audience_empty | 400 | A broadcast's audience has no subscribed contacts, so nothing would be sent. A scheduled one is moved back to draft. |
Requests
| Code | Status | Meaning |
|---|---|---|
rate_limited | 429 | Honour Retry-After; detail carries the limit that fired. |
idempotency_key_invalid | 400 | Empty, too long or non-ASCII Idempotency-Key. |
idempotency_key_in_flight | 409 | The first request with this key is still running. Retry the same key after Retry-After. |
idempotency_key_reused | 409 | Same key, different body. |
invalid_cursor | 400 | A tampered or expired pagination cursor. |
Domains, mailboxes and sandboxes
| Code | Status | Meaning |
|---|---|---|
region_immutable | 409 | The domain already holds messages; its region cannot change. |
mailbox_address_taken | 409 | That address exists somewhere on SendClipper. |
mailbox_catch_all_taken | 409 | The domain already has a catch-all. |
mailbox_archived | 409 | The address is archived; unarchive it first. |
mailbox_not_embeddable | 409 | No embedOrigins are set, so no embed token can be minted. |
mailbox_password_too_short | 400 | A mailbox password needs 10 characters. |
sandbox_name_taken | 409 | A sandbox with that name exists in this workspace. |
404, never 403. The API does not confirm that an object exists for someone else.