snd.email

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).

ScopeAllows
emails:sendPOST /v1/emails
emails:readGET /v1/emails/{id}
domains:readGET /v1/domains
recipients:erasePOST /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.

Rate limits

LimitRequests per secondBurst
Per API key2040
Per organisation50100
Failed authentication, per client IP address1020

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

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.

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.

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:

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:

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}

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

What is not deleted

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.:

TypeSent when
email.acceptedOur mail server accepted the message for delivery.
email.deliveredThe recipient's mail server accepted the message.
email.deferredThe recipient's mail server asked us to try again later; we keep trying.
email.bouncedThe recipient's mail server refused the message.
email.oob_bouncedA bounce arrived later, after the recipient's mail server had first accepted the message.
email.expiredWe stopped trying before the message could be delivered.
email.rejectedOur mail server did not take the message.
email.admin_bouncedsnd.email returned the message without delivering it.
email.suppressedThe message was not sent: the address is on the organisation's suppression list, or, for broadcast mail, on the domain's unsubscribe list.
email.openedThe message was opened (with open tracking on; see Open and click tracking).
email.clickedA tracked link of the message was clicked (with link tracking on).
email.unsubscribedThe recipient unsubscribed from the domain's broadcast mail with the message's link (broadcast webhooks only; see When a recipient unsubscribes).
email.complainedThe 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

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

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)

FieldTypeRequiredConstraintsDescription
typestringyesA URL naming the error: https://snd.email/errors/ followed by the code.
titlestringyesA short, fixed summary of the error.
statusintegeryesThe HTTP status code.
codestringyesone 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, internalThe error code.
detailstringyesA fixed explanation of the error.
errorsarray of objectnono other fields in an itemFor invalid_request, the field that was refused.
errors[].pointerstringyesA JSON pointer (RFC 6901) to the refused field of the request body.
errors[].codestringyesalways invalid_requestThe error code of the field.
suspension_idstring or nullnoformat uuidFor domain_suspended, the reference to quote to support.
CodeStatusTitleDetail
invalid_request400Invalid requestThe request does not meet the rules of this API; errors names the field.
unauthorized401UnauthorizedA valid API key is required.
forbidden_scope403Forbidden scopeThe API key does not hold the scope this operation needs.
not_all_domains403Key not valid for all domainsThis operation needs an API key that covers all domains of the organisation.
domain_out_of_scope403Domain out of scopeThe API key may not send from this domain.
domain_suspended403Domain suspendedSending from this domain is suspended.
not_found404Not foundThe resource does not exist or is not visible to this API key.
method_not_allowed405Method not allowedThis method is not allowed on this resource.
idempotency_key_reused409Idempotency key reusedThe Idempotency-Key was already used with a different request.
idempotency_key_in_progress409Idempotency key in progressA request with this Idempotency-Key is in progress.
payload_too_large413Payload too largeThe request, or the message it describes, is too large.
unsupported_media_type415Unsupported media typeThe request body must be application/json.
domain_not_verified422Domain not verifiedThe From domain is not a verified domain of this organisation.
domain_not_ready422Domain not readyThe From domain is not ready to send yet.
rate_limited429Rate limitedToo many requests; retry after the time in Retry-After.
internal500Internal errorThe request could not be completed.

Reference

POST /v1/emails

Send an email

Required scope
emails:send

Parameters

NameInTypeRequiredConstraints
Idempotency-Keyheaderstringnomatches ^[\x21-\x7e]{1,256}$

Request body (application/json)

FieldTypeRequiredConstraintsDescription
fromstringyes1 to 512 charactersThe sender, as an address or as "Name <address>", at a verified domain the API key may send from.
tostring or array of stringyesstring: 1 to 512 characters; array of string: 1 to 50 items, each: 1 to 512 charactersOne recipient, or a list of them, each an address or "Name <address>"; to, cc and bcc together hold at most 50 distinct addresses.
ccstring or array of stringnostring: 1 to 512 characters; array of string: at most 50 items, each: 1 to 512 charactersCopy recipients, in the same forms as to.
bccstring or array of stringnostring: 1 to 512 characters; array of string: at most 50 items, each: 1 to 512 charactersBlind copy recipients, in the same forms as to; they are not written into the message headers.
reply_tostringno1 to 512 charactersThe address replies go to, as an address or as "Name <address>".
subjectstringyes1 to 998 charactersThe subject line, at most 998 octets in UTF-8, without control characters.
textstringnoThe plain-text body; text, html or both must be given.
htmlstringnoThe HTML body; text, html or both must be given.
headersobjectnoat most 50 fieldsCustom headers by name; a name the API sets itself or does not allow is refused, as is a name given twice.
headers.*stringnoat most 998 charactersThe value of one custom header.
tagsarray of stringnoat most 5 items; each: matches ^[a-z0-9_-]{1,64}$Distinct tags (a repeated tag is refused).
metadataobjectnoat most 10 fields; no other fieldsYour own key-value pairs, stored with each message and returned by GET /v1/emails/{id}.
metadata.<key>stringnokey matches ^[a-z0-9_]{1,40}$; at most 256 charactersOne metadata value.
streamstringnoone of transactional, broadcastThe 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)

FieldTypeRequiredConstraintsDescription
idstringyesformat uuidThe id of this send request.
messagesarray of objectyesno other fields in an itemOne message per recipient, in the order of to, cc and bcc.
messages[].idstringyesformat uuidThe id of the message to this recipient, for GET /v1/emails/{id}.
messages[].statusstringyesone of queued, suppressedqueued 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

NameInTypeRequiredConstraints
idpathstringyesformat uuid

Responses

200
OK
401
unauthorized
403
forbidden_scope
404
not_found
429
rate_limited
500
internal

Response body (200)

FieldTypeRequiredConstraintsDescription
idstringyesformat uuidThe id of the message.
request_idstringyesformat uuidThe id of the send request that created it.
domain_idstringyesformat uuidThe id of the sending domain.
streamstringyesone of transactional, broadcastThe message stream: transactional (default) or broadcast.
statusstringyesone of queued, held, inject_unknown, injected, accepted, deferred, delivered, bounced, expired, rejected, admin_bounced, suppressedThe current state of the message.
status_codestring or nullyesA code that explains the status, when there is one.
created_atstringyesformat date-timeWhen the message was accepted by the API.
injected_atstring or nullyesformat date-timeWhen the message was handed to the mail server, or null before that.
tagsarray of stringyesThe tags of the send request.
metadataobjectyesThe metadata of the send request.
metadata.*stringnoOne metadata value.
outcomestring or nullyesone of bounced, expired, admin_bounced, oob_bouncedThe first final failure of the message, or null when there is none.
opened_atstring or nullyesformat date-timeWhen the message was first opened (open or link tracking), or null.
clicked_atstring or nullyesformat date-timeWhen a link of the message was first clicked (link tracking), or null.
eventsarray of objectyesat most 200 items; no other fields in an itemThe newest 200 events of the message, oldest first (occurred_at, then id).
events[].typestringyesone of accepted, delivered, deferred, bounced, expired, oob_bounced, admin_bounced, rejected, suppressed, opened, clicked, unsubscribed, complainedWhat happened to the message.
events[].occurred_atstringyesformat date-timeWhen it happened.
events[].smtp_codeinteger or nullyesfrom 100 to 599The SMTP reply code, when there was one.
events[].enhanced_statusstring or nullyesThe enhanced status code (RFC 3463), when there was one.
events[].classstring or nullyesone of hard, mailbox_full, blocked, auth, platform, transient_other, other_permanentThe class of the failure, when the event is one.
events[].remote_mxstring or nullyesThe receiving mail server, when known.
events[].diagnosticstring or nullyesThe remote server's own text, cut to 512 octets with control characters replaced: data, never markup. It may quote the recipient's address.
events[].automaticbooleannoOpened 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_kindstringnoone of link_scanner, proxyAutomatic 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_indexintegernofrom 0 to 199Clicked events only: the number of the clicked link among the message's rewritten links, counted from 0 in document order.
events[].link_hoststringnoClicked 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)

FieldTypeRequiredConstraintsDescription
domainsarray of objectyesno other fields in an itemThe domains the API key may use.
domains[].idstringyesformat uuidThe id of the domain.
domains[].namestringyesThe domain name, in its ASCII (A-label) form.
domains[].statusstringyesone of pending_key, pending_dns, verified, dns_lost, failedThe verification state of the domain.
domains[].mta_readybooleanyesWhether the mail server is ready to send from the domain.
domains[].created_atstringyesformat date-timeWhen the domain was added.

POST /v1/recipients/erase

Delete recipient data

Required scope
recipients:erase

Request body (application/json)

FieldTypeRequiredConstraintsDescription
emailstringyes1 to 320 charactersThe 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)

FieldTypeRequiredConstraintsDescription
messages_deletedintegeryesat least 0The messages to the address deleted by this call, with their events.
contents_deletedintegeryesat least 0The stored message contents deleted by this call, one per send request to the address.
messages_in_flightinteger or nullyesat least 0The messages to the address still being delivered, whose log entries are deleted automatically when delivery ends; null when complete is false.
completebooleanyesWhether everything sent to the address before the call began was handled; when false, call again.