API documentation
Overview
- Base URL
https://api.snd.email
Send transactional and broadcast e-mail from your verified domains. Every request needs an API key in the Authorization header. Errors are RFC 9457 problem details.
The API sends e-mail from the domains your organisation has verified in the panel, and reports what happened to each message. Requests and responses are JSON over HTTPS; a request with a body must send it as application/json.
The Quick start sends a first message. Every operation, with each field it takes and returns, is in the Reference.
When an organisation is closed, its API keys stop working at once: every request with one of them gets 401 (unauthorized).
Authentication
Every request carries an API key in the Authorization header:
Authorization: Bearer <key>
A key is snd_live_ followed by 49 letters and digits, 58 characters in all. Keys are created in the panel, at app.snd.email, and shown only once. A missing, unknown, revoked, expired or malformed key gets the same 401 response (unauthorized).
Scopes
Each key holds one or more scopes; an operation needs its scope, or the answer is 403 (forbidden_scope).
| Scope | Allows |
|---|---|
emails:send | POST /v1/emails |
emails:read | GET /v1/emails/{id} |
domains:read | GET /v1/domains |
recipients:erase | POST /v1/recipients/erase: Delete what is stored about one recipient address (keys with all domains only). |
Domains of a key
A key is limited to the domains it lists, or covers all domains of the organisation. Sending from a domain outside the key's domains is refused with 403 (domain_out_of_scope), a message of such a domain is answered with 404, and GET /v1/domains lists only the key's domains.
Quick start
Send one message with curl. The from address must be at a verified domain of your organisation that the key may send from; text, html or both carry the body.
curl https://api.snd.email/v1/emails \
-H "Authorization: Bearer $SND_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-1042" \
-d '{
"from": "Acme <hello@acme.example>",
"to": "you@acme.example",
"subject": "Welcome",
"text": "Thanks for signing up."
}'
The answer is 202 Accepted with the id of the request and one message per recipient:
{
"id": "6d0f9c2e-8b1a-4c5d-9e3f-2a7b4c6d8e01",
"messages": [
{ "id": "1b9e4f7a-3c2d-4e5f-8a6b-7c9d0e1f2a3b", "status": "queued" }
]
}
A recipient on your suppression list gets the status suppressed and is not sent anything. An address whose mailbox provider reports your email as spam is added to your suppression list for every stream. GET /v1/emails/{id} with a message id returns its status and its events.
Idempotency
POST /v1/emails takes an optional Idempotency-Key header: 1 to 256 printable ASCII characters, without spaces. A key is kept for 24 hours after its first request, separately for each API key.
- The same key with the same request body again: the first answer is returned, with the header
Idempotent-Replayed: true, and nothing is sent a second time. - The same key with a different body:
409(idempotency_key_reused). - The same key while its first request is still being processed:
409(idempotency_key_in_progress). - A request that was not accepted does not keep its key, so it can be retried with the same key.
Rate limits
| Limit | Requests per second | Burst |
|---|---|---|
| Per API key | 20 | 40 |
| Per organisation | 50 | 100 |
| Failed authentication, per client IP address | 10 | 20 |
Each limit is a token bucket: it allows the burst at once and refills at the rate per second. The organisation's limit counts the requests of all its keys together; the failed-authentication limit counts requests whose API key was refused.
Responses to authenticated requests carry the RateLimit-Policy and RateLimit headers, which name each limit and the requests it has left. After the first request of a new key, for example:
RateLimit-Policy: "key";q=20;w=1, "org";q=50;w=1
RateLimit: "key";r=39;t=1, "org";r=99;t=1
Over a limit the answer is 429 (rate_limited) with a Retry-After header: the number of seconds to wait before the next request.
Open and click tracking
Open tracking and link tracking are set per domain, in the domain's Tracking card in the panel. Both are off by default. An Owner of the organisation, or a Manager of the domain, turns them on and off. A change applies to mail accepted after it: a message keeps the settings its domain had when the API accepted it.
Where the law requires it, such as the EU ePrivacy rules, you need your recipients' consent before tracking opens or clicks. You are responsible for that consent; the panel asks you to confirm it before either is turned on.
What changes in a message
- Open tracking adds a transparent image of one pixel to the HTML body, just before its closing
</body>tag, or at its end without one; no image is added when the HTML ends inside an unclosed tag or comment. - Link tracking rewrites the address of most
httpandhttpslinks (<a href>) in the HTML body to one onsndlink.com, which records the click and redirects to the original address. At most 200 links of a message are rewritten. - A link with the attribute
data-snd-no-trackkeeps its address, for example<a href="..." data-snd-no-track>. - Other links are never changed:
mailto:andtel:links, relative links, links within the page (#), image maps, forms, and an address that cannot be read as a URL. Links that cannot be rewritten safely are left as they are too: an address with a user name or password, over 2,048 bytes or already onsndlink.com, a link with twohrefs, links inside comments,<script>,<style>,<textarea>,<title>, SVG or MathML, and links after a tag that is never closed. - The plain-text body and a message without an HTML body are never changed.
What is recorded
An open records its time. A click records its time, the number of the link among the message's rewritten links (counted from 0 in document order; the panel shows it from 1) and the host of its address; never its path or query. With each open or click we also keep the country it came from (as Cloudflare reports it) and the browser and operating system family (for example "Chrome" and "Windows"); no IP address, user agent or referrer is stored, and the rewritten addresses carry only an encrypted token, from which no address, name or link can be read.
GET /v1/emails/{id} returns the first open and the first click of a message as opened_at and clicked_at, and each open and click among its events, with the types opened and clicked. A click counts as an open too. The panel's Overview shows the open and click rates: the share of delivered messages with open (link) tracking that were opened (clicked).
Automatic opens and clicks
Some mail clients fetch every image of a message when it arrives, and some mail providers' security checks open a message and follow its links. Such requests are recorded with automatic set to true and never count as an open or a click: they do not set opened_at or clicked_at and are left out of the rates. automatic_kind says which: link_scanner for a security check (the panel's "Link scanner" tag) and proxy for any other automatic request, such as Apple Mail Privacy Protection (the "Automatic" tag). Microsoft 365 checks links with an ordinary browser, so every open and click in the first 60 seconds after a message is delivered to a Microsoft 365 mailbox counts as a link scanner's, even when the recipient was that quick. The detection is best effort.
How long links work
A rewritten link keeps redirecting after the message's 30 days, since the original address is carried in its token; only the click is no longer recorded. snd.email can disable the links of a domain or to a host that is reported for abuse.
Broadcast mail
Each domain has two message streams. Transactional is for mail a recipient expects because of something they did, such as a password reset or a receipt. Broadcast is for mail you send to many recipients at once, such as a newsletter or an announcement. A recipient can unsubscribe from a domain's broadcast mail with one click: snd.email adds the unsubscribe link and keeps the list for you. Transactional mail never gets an unsubscribe link, and an unsubscribe never stops it.
Choosing the stream
Broadcast mail uses the same request, API key and host as any other mail. Set stream to broadcast in the body:
curl https://api.snd.email/v1/emails \
-H "Authorization: Bearer $SND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <news@acme.example>",
"to": "you@acme.example",
"subject": "Our autumn news",
"html": "<p>Hello!</p><p><a href=\"{{{snd_unsubscribe_url}}}\">Unsubscribe</a></p>",
"stream": "broadcast"
}'
Without stream, or with transactional, the message is transactional. GET /v1/emails/{id} returns each message's stream, and the panel's Overview and Activity Logs can show one stream at a time.
The headers we add
Every broadcast message gets these two headers, which mail clients use for their own unsubscribe button (RFC 8058):
List-Unsubscribe: <link>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
Here link is the recipient's own unsubscribe link, an address on sndlink.com. It carries only an encrypted token, from which no address, name or domain can be read, and it keeps working after the message's 30 days. You cannot set these two headers yourself on broadcast mail.
The link in the body
Mailbox providers also want a visible unsubscribe link in the message, so every broadcast message gets a short line at the end of each part:
- HTML: "Unsubscribe from these emails.", with the link on "Unsubscribe", just before the closing
</body>tag, or at the end without one. - Text: a line
--, thenUnsubscribe:and the link.
You can also put the recipient's link in your own design: write {{{snd_unsubscribe_url}}} where you want it, for example <a href="{{{snd_unsubscribe_url}}}">Unsubscribe</a>, and we replace every occurrence with the link. The line at the end is added either way. Link tracking never rewrites a link that holds the placeholder, nor the line we add.
Some requests are refused with 400 (invalid_request), the reason in the problem's detail:
- A broadcast request whose HTML ends inside an unclosed comment, tag or attribute, with no closing
</body>tag: there is no safe place for the line. Close it. - A transactional request with the placeholder, so it never reaches a recipient as text.
- A broadcast request that sets
List-UnsubscribeorList-Unsubscribe-Postitself.
The headers, the expanded placeholders and the added line count toward the message's size; a request they would take past the limit is refused with 413 (payload_too_large).
When a recipient unsubscribes
The link opens a page on sndlink.com that asks the recipient to confirm; a mail client's unsubscribe button confirms at once. From then on, broadcast mail from that domain to that address is not sent: the API answers suppressed for that recipient, and GET /v1/emails/{id} shows the status suppressed with the code unsubscribed. Broadcast mail to the address that is still waiting to be sent is not sent either. Transactional mail to the address, and broadcast mail from your other domains, are sent as before.
The message whose link was used gets an unsubscribed event, in its timeline and in the Activity Logs. The panel's Suppressions page lists each unsubscribe with the reason Unsubscribed, applying to Broadcasts, and a domain's Overview counts them in its Streams table. As for bounce suppressions, the address is kept for 30 days, then only a keyed hash of it. An Owner, or a Manager of the domain, can remove an unsubscribe on the Suppressions page; only do this if the recipient asked to subscribe again.
Webhooks
A webhook belongs to one stream of its domain, chosen when it is added. A broadcast webhook can also send email.unsubscribed (see Events), and a new one starts with it chosen.
Bounces
Bounce suppressions apply to both streams: an address that bounced is not sent to again from either. The bounce limit counts both streams of a domain together, and when it stops the domain, both streams stop. To keep broadcast mail apart, send it from a subdomain of its own, such as news.acme.example: it has its own bounce limit, its own unsubscribe list and its own webhooks.
The list stays with your organisation
Unsubscribes are kept by domain name within your organisation, so removing a domain and adding it again keeps its list. If the domain is added to another organisation, it starts there with an empty list: a list is never moved between organisations.
Deleting recipient data
POST /v1/recipients/erase deletes what we store about one recipient address in your organisation. The address goes in the request body, never in the URL:
POST /v1/recipients/erase
Content-Type: application/json
{"email": "you@acme.example"}
The API key needs the scope recipients:erase and must cover all domains of the organisation; a key without the scope gets 403 (forbidden_scope), a key limited to some domains 403 (not_all_domains). The scope can be given only to a key with access to all domains. Owners can also delete recipient data in the panel, on the Settings page.
The answer is 200 with counts, the same for an address with nothing stored:
{"messages_deleted": 12, "contents_deleted": 3, "messages_in_flight": 1, "complete": true}
messages_deleted: the messages to the address deleted by this call, with their log entries and events.contents_deleted: the stored contents deleted by this call, one for each send request that went to the address.messages_in_flight: messages to the address that the mail server still has. They are still delivered; their log entries are deleted automatically when delivery ends, and no webhooks are sent for them. It counts at most 10,000 messages; at that number there may be more. It isnullwhencompleteisfalse.complete:falsewhen the call stopped before it had handled everything; call again to continue. Calling again is always safe: what is gone stays gone.
A call covers the send requests already answered when it starts. A send request to the address that is still being processed at that moment may be delivered and logged; call again once it has been answered to delete it. Mail you send to the address during the call or after it is kept; the deletion does not block the address. Up to 5 calls a second per organisation are accepted, with bursts of 10, and one deletion per organisation runs at a time: over these limits the answer is 429 (rate_limited) with a Retry-After header.
What is deleted
- The log entries of every message sent to the address, with their events (deliveries, bounces, opens and clicks).
- The stored content of every send request that went to the address. For a send request that also went to other recipients, the content is deleted for them too; their log entries stay, and their message page says the content was deleted on request.
- Messages to the address still waiting to be sent: they are not sent.
- If the address is on your suppression list, it stays there without the address shown.
What is not deleted
- Mail the mail server already has is still delivered (for at most 7 days); its log entries are deleted when delivery ends.
- The address where it is not the recipient: in other messages' To, Cc or Reply-To, subjects, tags and metadata, and in replies of remote mail servers stored with other messages (kept at most 30 days).
- The send queue of a send request while other recipients of it are still waiting to be sent.
- Exports, downloads and webhooks you already received.
- Backups, kept at most 7 days. If we ever have to restore one, data deleted since the backup comes back; we then ask you to delete it again.
Webhooks
A webhook sends each chosen event of a domain's mail to an HTTPS endpoint of yours, as a signed POST in the Standard Webhooks format, so your systems learn of a delivery, a bounce or a click without asking the API.
Setting one up
Webhooks are set per domain, in the domain's Webhooks card on its settings page in the panel. An Owner of the organisation, or a Manager of the domain, adds an endpoint: its URL, its stream (Transactional or Broadcast, see Broadcast mail) and the events to send. A webhook sends the events of its stream's mail only, and its stream cannot be changed. A domain can have up to 10 webhooks, of both streams together.
When the webhook is added, the panel shows its signing secret, a text starting with whsec_, once. Copy it and keep it safe: it cannot be shown again, only replaced (see Rotating the secret).
Everyone who can see the domain in the panel sees its webhooks: each endpoint's host name (never its path or query, which may hold a token of yours), its events, who added it, its state and its recent deliveries. Only Owners and the domain's Managers see the whole URL and change it.
Events
Each event of the message timeline (as GET /v1/emails/{id} returns it) can be sent, with its type prefixed by email.:
| Type | Sent when |
|---|---|
email.accepted | Our mail server accepted the message for delivery. |
email.delivered | The recipient's mail server accepted the message. |
email.deferred | The recipient's mail server asked us to try again later; we keep trying. |
email.bounced | The recipient's mail server refused the message. |
email.oob_bounced | A bounce arrived later, after the recipient's mail server had first accepted the message. |
email.expired | We stopped trying before the message could be delivered. |
email.rejected | Our mail server did not take the message. |
email.admin_bounced | snd.email returned the message without delivering it. |
email.suppressed | The message was not sent: the address is on the organisation's suppression list, or, for broadcast mail, on the domain's unsubscribe list. |
email.opened | The message was opened (with open tracking on; see Open and click tracking). |
email.clicked | A tracked link of the message was clicked (with link tracking on). |
email.unsubscribed | The recipient unsubscribed from the domain's broadcast mail with the message's link (broadcast webhooks only; see When a recipient unsubscribes). |
email.complained | The recipient's mailbox provider reported the email as spam. We add the address to your suppression list. Currently we receive these reports from Outlook.com, Hotmail and Live mailboxes. Webhooks created before this event existed do not receive it until you add it. |
A new webhook starts with every type of its stream chosen except email.accepted. Opens and clicks are what the timeline records: the first open, the first automatic open of each kind, and the first click and the first link-scanner click of each link (at most 20 clicks of each kind), each with automatic (and automatic_kind when it is true); never every image request.
The request
The body is JSON: type, timestamp (when the event occurred: ISO 8601 in UTC with milliseconds, written below as YYYY-MM-DDTHH:MM:SS.sssZ) and data. For a mail event, data.message is the message — its id, the id of the send request, the domain's id and name, the stream, the recipient's address, and the request's tags and metadata — and data.event is the event as the API returns it, with its id. A delivery:
{
"type": "email.delivered",
"timestamp": "YYYY-MM-DDTHH:MM:SS.sssZ",
"data": {
"message": {
"id": "0b7e5f4c-1f3e-4a8e-9a39-3c1f6d2a8e51",
"request_id": "5d2c8a1e-7b6f-4c3d-8e9a-1f2b3c4d5e6f",
"domain_id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"domain": "acme.example",
"stream": "transactional",
"recipient": "you@acme.example",
"tags": ["welcome"],
"metadata": { "user_id": "42" }
},
"event": {
"id": "c4d5e6f7-a8b9-4c0d-9e1f-2a3b4c5d6e7f",
"type": "delivered",
"occurred_at": "YYYY-MM-DDTHH:MM:SS.sssZ",
"smtp_code": 250,
"enhanced_status": "2.0.0",
"class": null,
"remote_mx": "mx.acme.example",
"diagnostic": "250 2.0.0 OK"
}
}
}
An open or a click adds automatic and, when it is true, automatic_kind (link_scanner or proxy); a click also link_index and link_host (the link's number and host, never its path or query):
{
"type": "email.clicked",
"timestamp": "YYYY-MM-DDTHH:MM:SS.sssZ",
"data": {
"message": {
"id": "0b7e5f4c-1f3e-4a8e-9a39-3c1f6d2a8e51",
"request_id": "5d2c8a1e-7b6f-4c3d-8e9a-1f2b3c4d5e6f",
"domain_id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"domain": "acme.example",
"stream": "transactional",
"recipient": "you@acme.example",
"tags": ["welcome"],
"metadata": { "user_id": "42" }
},
"event": {
"id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
"type": "clicked",
"occurred_at": "YYYY-MM-DDTHH:MM:SS.sssZ",
"smtp_code": null,
"enhanced_status": null,
"class": null,
"remote_mx": null,
"diagnostic": null,
"automatic": false,
"link_index": 0,
"link_host": "www.acme.example"
}
}
}
An open by a link scanner:
{
"type": "email.opened",
"timestamp": "YYYY-MM-DDTHH:MM:SS.sssZ",
"data": {
"message": {
"id": "0b7e5f4c-1f3e-4a8e-9a39-3c1f6d2a8e51",
"request_id": "5d2c8a1e-7b6f-4c3d-8e9a-1f2b3c4d5e6f",
"domain_id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"domain": "acme.example",
"stream": "transactional",
"recipient": "you@acme.example",
"tags": ["welcome"],
"metadata": { "user_id": "42" }
},
"event": {
"id": "a7b8c9d0-e1f2-4a3b-8c4d-5e6f7a8b9c0d",
"type": "opened",
"occurred_at": "YYYY-MM-DDTHH:MM:SS.sssZ",
"smtp_code": null,
"enhanced_status": null,
"class": null,
"remote_mx": null,
"diagnostic": null,
"automatic": true,
"automatic_kind": "link_scanner"
}
}
}
A broadcast message's events carry "stream": "broadcast". An unsubscribe:
{
"type": "email.unsubscribed",
"timestamp": "YYYY-MM-DDTHH:MM:SS.sssZ",
"data": {
"message": {
"id": "2c8d4e6f-0a1b-4c3d-8e5f-7a9b1c3d5e7f",
"request_id": "8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a1b",
"domain_id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"domain": "acme.example",
"stream": "broadcast",
"recipient": "you@acme.example",
"tags": ["newsletter"],
"metadata": {}
},
"event": {
"id": "d3e4f5a6-b7c8-4d9e-8f0a-1b2c3d4e5f6a",
"type": "unsubscribed",
"occurred_at": "YYYY-MM-DDTHH:MM:SS.sssZ",
"smtp_code": null,
"enhanced_status": null,
"class": null,
"remote_mx": null,
"diagnostic": null
}
}
}
A spam complaint:
{
"type": "email.complained",
"timestamp": "YYYY-MM-DDTHH:MM:SS.sssZ",
"data": {
"message": {
"id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8",
"request_id": "8f7e6d5c-4b3a-4291-8a7b-6c5d4e3f2a1b",
"domain_id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d",
"domain": "acme.example",
"stream": "transactional",
"recipient": "you@acme.example",
"tags": ["receipt"],
"metadata": {}
},
"event": {
"id": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a8b",
"type": "complained",
"occurred_at": "YYYY-MM-DDTHH:MM:SS.sssZ",
"smtp_code": null,
"enhanced_status": null,
"class": null,
"remote_mx": null,
"diagnostic": null
}
}
}
A test (see Retries and tests) has the type webhook.test and names the endpoint:
{
"type": "webhook.test",
"timestamp": "YYYY-MM-DDTHH:MM:SS.sssZ",
"data": { "endpoint_id": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918", "domain": "acme.example", "stream": "transactional" }
}
domain is the name of the domain the webhook belongs to, not the endpoint's host.
Every attempt of an event sends the same bytes. A body may gain fields later; ignore those you do not know.
Headers
webhook-id: the event's id, the same in every attempt and for every endpoint; a test has an id of its own.webhook-timestamp: the time of this attempt, in whole seconds since 1970-01-01 UTC.webhook-signature:v1,followed by the signature; during a rotation two signatures, separated by a space, the new secret's first.content-type: application/jsonanduser-agent: snd.email-webhooks/1.
Verifying a request
The signature is the HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, over the body's exact bytes, in standard Base64. The key is the secret's part after whsec_, decoded from Base64. Verify every request before you trust it, with the raw body as received (before any JSON parsing). The Standard Webhooks libraries do all of it: standardwebhooks on npm, PyPI, RubyGems and crates.io, com.standardwebhooks:standardwebhooks on Maven, StandardWebhooks.StandardWebhooks on NuGet and github.com/standard-webhooks/standard-webhooks/libraries/go for Go. Without one, in Node.js:
import { createHmac, timingSafeEqual } from 'node:crypto';
// secret: the whsec_ text; headers: the request's headers (lower-case names); body: the raw body (Buffer);
// nowSeconds: your clock in whole seconds, Math.floor(Date.now() / 1000).
export function verifyWebhook(secret, headers, body, nowSeconds) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !/^\d{1,12}$/.test(timestamp ?? '') || !signatures) return false;
// Refuse a request more than 5 minutes from your clock (a replay).
if (Math.abs(nowSeconds - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(body).digest();
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
if (version !== 'v1' || !value) return false;
const given = Buffer.from(value, 'base64');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
Answer with a status of 200 to 299 within 15 seconds, and do slow work afterwards. Keep the webhook-ids you have handled for a few days and skip a repeat (see At least once, in any order).
Retries and tests
Any status from 200 to 299 is a success. Anything else is a failed attempt: another status (redirects are not followed), no answer within 15 seconds, or no connection. We try each event up to 8 times: at once, then 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours after each failed attempt, each wait varied by up to 20 % either way — about a day in all; never later than 35 hours after the event. After a 429, 502, 503 or 504 with a Retry-After header, the next attempt waits at least that long (at most 10 hours). An answer of 410 Gone disables the webhook at once.
A webhook that has had no successful delivery for 5 days is disabled, and the domain's Owners and Managers get an e-mail. Events after that are not sent to it. Once the endpoint works again, an Owner or Manager enables the webhook on the domain's settings page; the events of the time it was disabled are not sent again.
Send test, in the panel, sends one webhook.test request at once and shows its result. A test is tried once, never retried, and never counts toward disabling the webhook; a successful test of a failing endpoint clears its failing state. One test per webhook every 10 seconds, and 30 per organisation an hour.
The panel lists each webhook's 20 newest deliveries with their result. Delivered ones are kept for 24 hours, failed ones for 7 days.
Rotating the secret
Rotate secret, in the panel, shows a new secret once. For 24 hours after your first rotation we sign with both the new and the old secret, so you can switch without losing events. Rotating again within those 24 hours replaces only the new secret; the old one keeps its 24 hours.
Endpoint URLs
- The scheme is
https, on the standard port 443; no other port. - A host name, not an IP address; no user name or password (every request is signed instead); no
#part; at most 2,048 characters. - Special-use names (such as
localhost,.local,.internal,.test,.example,.invalid,.onion,.arpa) and snd.email's own hosts are refused. - Each attempt looks up the host again and connects only when every address it finds is a public one; an address in a private, loopback, link-local or other special range fails the attempt.
- The certificate must be valid for the host name, with TLS 1.2 or later.
At least once, in any order
An event can arrive more than once, with the same webhook-id, and events can arrive in any order, for example a delivery before its acceptance. Use webhook-id to skip a repeat and the event's occurred_at to order them.
What is never sent
A webhook never carries the message's subject or body, its custom headers, its Message-ID, the API key that sent it, an IP address or user agent, or a clicked link's path or query.
Errors
Every error is answered with an HTTP status of 400 or above and a body of type application/problem+json (RFC 9457), with the fields below. type is the address of the error's own page, https://snd.email/errors/ followed by its code; title and detail are fixed texts for each code and never repeat a value from the request.
{
"type": "https://snd.email/errors/invalid_request",
"title": "Invalid request",
"status": 400,
"code": "invalid_request",
"detail": "The request does not meet the rules of this API; errors names the field.",
"errors": [{ "pointer": "/subject", "code": "invalid_request" }]
}
Problem details (application/problem+json)
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
type | string | yes | A URL naming the error: https://snd.email/errors/ followed by the code. | |
title | string | yes | A short, fixed summary of the error. | |
status | integer | yes | The HTTP status code. | |
code | string | yes | one of invalid_request, unauthorized, forbidden_scope, not_all_domains, domain_out_of_scope, domain_suspended, not_found, method_not_allowed, idempotency_key_reused, idempotency_key_in_progress, payload_too_large, unsupported_media_type, domain_not_verified, domain_not_ready, rate_limited, internal | The error code. |
detail | string | yes | A fixed explanation of the error. | |
errors | array of object | no | no other fields in an item | For invalid_request, the field that was refused. |
errors[].pointer | string | yes | A JSON pointer (RFC 6901) to the refused field of the request body. | |
errors[].code | string | yes | always invalid_request | The error code of the field. |
suspension_id | string or null | no | format uuid | For domain_suspended, the reference to quote to support. |
| Code | Status | Title | Detail |
|---|---|---|---|
invalid_request | 400 | Invalid request | The request does not meet the rules of this API; errors names the field. |
unauthorized | 401 | Unauthorized | A valid API key is required. |
forbidden_scope | 403 | Forbidden scope | The API key does not hold the scope this operation needs. |
not_all_domains | 403 | Key not valid for all domains | This operation needs an API key that covers all domains of the organisation. |
domain_out_of_scope | 403 | Domain out of scope | The API key may not send from this domain. |
domain_suspended | 403 | Domain suspended | Sending from this domain is suspended. |
not_found | 404 | Not found | The resource does not exist or is not visible to this API key. |
method_not_allowed | 405 | Method not allowed | This method is not allowed on this resource. |
idempotency_key_reused | 409 | Idempotency key reused | The Idempotency-Key was already used with a different request. |
idempotency_key_in_progress | 409 | Idempotency key in progress | A request with this Idempotency-Key is in progress. |
payload_too_large | 413 | Payload too large | The request, or the message it describes, is too large. |
unsupported_media_type | 415 | Unsupported media type | The request body must be application/json. |
domain_not_verified | 422 | Domain not verified | The From domain is not a verified domain of this organisation. |
domain_not_ready | 422 | Domain not ready | The From domain is not ready to send yet. |
rate_limited | 429 | Rate limited | Too many requests; retry after the time in Retry-After. |
internal | 500 | Internal error | The request could not be completed. |
Reference
POST /v1/emails
Send an email
- Required scope
emails:send
Parameters
| Name | In | Type | Required | Constraints |
|---|---|---|---|---|
Idempotency-Key | header | string | no | matches ^[\x21-\x7e]{1,256}$ |
Request body (application/json)
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
from | string | yes | 1 to 512 characters | The sender, as an address or as "Name <address>", at a verified domain the API key may send from. |
to | string or array of string | yes | string: 1 to 512 characters; array of string: 1 to 50 items, each: 1 to 512 characters | One recipient, or a list of them, each an address or "Name <address>"; to, cc and bcc together hold at most 50 distinct addresses. |
cc | string or array of string | no | string: 1 to 512 characters; array of string: at most 50 items, each: 1 to 512 characters | Copy recipients, in the same forms as to. |
bcc | string or array of string | no | string: 1 to 512 characters; array of string: at most 50 items, each: 1 to 512 characters | Blind copy recipients, in the same forms as to; they are not written into the message headers. |
reply_to | string | no | 1 to 512 characters | The address replies go to, as an address or as "Name <address>". |
subject | string | yes | 1 to 998 characters | The subject line, at most 998 octets in UTF-8, without control characters. |
text | string | no | The plain-text body; text, html or both must be given. | |
html | string | no | The HTML body; text, html or both must be given. | |
headers | object | no | at most 50 fields | Custom headers by name; a name the API sets itself or does not allow is refused, as is a name given twice. |
headers.* | string | no | at most 998 characters | The value of one custom header. |
tags | array of string | no | at most 5 items; each: matches ^[a-z0-9_-]{1,64}$ | Distinct tags (a repeated tag is refused). |
metadata | object | no | at most 10 fields; no other fields | Your own key-value pairs, stored with each message and returned by GET /v1/emails/{id}. |
metadata.<key> | string | no | key matches ^[a-z0-9_]{1,40}$; at most 256 characters | One metadata value. |
stream | string | no | one of transactional, broadcast | The message stream: transactional (default) or broadcast. |
Other fields are refused.
Responses
- 202
- Accepted
- 400
- invalid_request
- 401
- unauthorized
- 403
- forbidden_scope, domain_out_of_scope, domain_suspended
- 409
- idempotency_key_reused, idempotency_key_in_progress
- 413
- payload_too_large
- 415
- unsupported_media_type
- 422
- domain_not_verified, domain_not_ready
- 429
- rate_limited
- 500
- internal
Response body (202)
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
id | string | yes | format uuid | The id of this send request. |
messages | array of object | yes | no other fields in an item | One message per recipient, in the order of to, cc and bcc. |
messages[].id | string | yes | format uuid | The id of the message to this recipient, for GET /v1/emails/{id}. |
messages[].status | string | yes | one of queued, suppressed | queued when the message will be sent, suppressed when the address is on the suppression list and nothing is sent to it. |
GET /v1/emails/{id}
Get an email
- Required scope
emails:read
Parameters
| Name | In | Type | Required | Constraints |
|---|---|---|---|---|
id | path | string | yes | format uuid |
Responses
- 200
- OK
- 401
- unauthorized
- 403
- forbidden_scope
- 404
- not_found
- 429
- rate_limited
- 500
- internal
Response body (200)
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
id | string | yes | format uuid | The id of the message. |
request_id | string | yes | format uuid | The id of the send request that created it. |
domain_id | string | yes | format uuid | The id of the sending domain. |
stream | string | yes | one of transactional, broadcast | The message stream: transactional (default) or broadcast. |
status | string | yes | one of queued, held, inject_unknown, injected, accepted, deferred, delivered, bounced, expired, rejected, admin_bounced, suppressed | The current state of the message. |
status_code | string or null | yes | A code that explains the status, when there is one. | |
created_at | string | yes | format date-time | When the message was accepted by the API. |
injected_at | string or null | yes | format date-time | When the message was handed to the mail server, or null before that. |
tags | array of string | yes | The tags of the send request. | |
metadata | object | yes | The metadata of the send request. | |
metadata.* | string | no | One metadata value. | |
outcome | string or null | yes | one of bounced, expired, admin_bounced, oob_bounced | The first final failure of the message, or null when there is none. |
opened_at | string or null | yes | format date-time | When the message was first opened (open or link tracking), or null. |
clicked_at | string or null | yes | format date-time | When a link of the message was first clicked (link tracking), or null. |
events | array of object | yes | at most 200 items; no other fields in an item | The newest 200 events of the message, oldest first (occurred_at, then id). |
events[].type | string | yes | one of accepted, delivered, deferred, bounced, expired, oob_bounced, admin_bounced, rejected, suppressed, opened, clicked, unsubscribed, complained | What happened to the message. |
events[].occurred_at | string | yes | format date-time | When it happened. |
events[].smtp_code | integer or null | yes | from 100 to 599 | The SMTP reply code, when there was one. |
events[].enhanced_status | string or null | yes | The enhanced status code (RFC 3463), when there was one. | |
events[].class | string or null | yes | one of hard, mailbox_full, blocked, auth, platform, transient_other, other_permanent | The class of the failure, when the event is one. |
events[].remote_mx | string or null | yes | The receiving mail server, when known. | |
events[].diagnostic | string or null | yes | The remote server's own text, cut to 512 octets with control characters replaced: data, never markup. It may quote the recipient's address. | |
events[].automatic | boolean | no | Opened and clicked events only: whether the event looked automatic — a mail client or a link scanner fetching it, or any open or click in the first 60 seconds after delivery to a Microsoft 365 mailbox; automatic events never count as opens or clicks. | |
events[].automatic_kind | string | no | one of link_scanner, proxy | Automatic opened and clicked events only: link_scanner (a mail provider's security check, such as Microsoft 365's in the first 60 seconds after delivery) or proxy (any other automatic request, such as Apple Mail Privacy Protection). |
events[].link_index | integer | no | from 0 to 199 | Clicked events only: the number of the clicked link among the message's rewritten links, counted from 0 in document order. |
events[].link_host | string | no | Clicked events only: the host of the clicked link, in its ASCII (A-label) form; never the link's path or query. |
GET /v1/domains
List the domains of the API key
- Required scope
domains:read
Responses
- 200
- OK
- 401
- unauthorized
- 403
- forbidden_scope
- 429
- rate_limited
- 500
- internal
Response body (200)
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
domains | array of object | yes | no other fields in an item | The domains the API key may use. |
domains[].id | string | yes | format uuid | The id of the domain. |
domains[].name | string | yes | The domain name, in its ASCII (A-label) form. | |
domains[].status | string | yes | one of pending_key, pending_dns, verified, dns_lost, failed | The verification state of the domain. |
domains[].mta_ready | boolean | yes | Whether the mail server is ready to send from the domain. | |
domains[].created_at | string | yes | format date-time | When the domain was added. |
POST /v1/recipients/erase
Delete recipient data
- Required scope
recipients:erase
Request body (application/json)
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
email | string | yes | 1 to 320 characters | The recipient address whose stored data is deleted: the address alone, without a display name. |
Other fields are refused.
Responses
- 200
- OK
- 400
- invalid_request
- 401
- unauthorized
- 403
- forbidden_scope, not_all_domains
- 413
- payload_too_large
- 415
- unsupported_media_type
- 429
- rate_limited
- 500
- internal
Response body (200)
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
messages_deleted | integer | yes | at least 0 | The messages to the address deleted by this call, with their events. |
contents_deleted | integer | yes | at least 0 | The stored message contents deleted by this call, one per send request to the address. |
messages_in_flight | integer or null | yes | at least 0 | The messages to the address still being delivered, whose log entries are deleted automatically when delivery ends; null when complete is false. |
complete | boolean | yes | Whether everything sent to the address before the call began was handled; when false, call again. |