Sently API Documentation
The Sently API is plain HTTP with JSON. Everything below works with any HTTP client.
Quick start
- Add your domain in the dashboard and add the DNS records it shows you.
- Create an API key. Copy it once, it is not shown again.
- Send your first email with the call below.
const response = await fetch('https://app.yourdomain.com/api/v1/emails', {
method: 'POST',
headers: {
'authorization': `Bearer ${process.env.SENTLY_API_KEY}`,
'content-type': 'application/json'
},
body: JSON.stringify({
from: 'Orders <[email protected]>',
to: '[email protected]',
subject: 'Your order is on the way',
html: '<p>It left the warehouse this morning.</p>'
})
});
const result = await response.json();
console.log(result.id, result.status);Every language talks to the same plain HTTP API, as the tabs show. A sk_test_ key routes to a mock that records the send but never delivers, so you can wire things up safely.
Authentication
Every request carries your API key as a Bearer token. Keys belong to one client, so the email is sent and recorded under that client.
authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxx- A "Send only" key can call the send endpoint.
- A "Send and read" key can also list and read emails.
- Keep keys on your server. A key in browser code can be read by anyone.
Send an email
POST /api/v1/emails queues one email. The response comes back as soon as it is accepted, and delivery events follow after that.
| Field | Type | Required | Notes |
|---|---|---|---|
from | string | Required | "Name <[email protected]>" or "[email protected]". The domain must be verified. |
to | string or string[] | Required | One address or a list of addresses. |
cc | string or string[] | Optional | Copied recipients. They count towards your monthly total. |
bcc | string or string[] | Optional | Hidden recipients. They also count towards your monthly total. |
reply_to | string or string[] | Optional | Where replies should go if it is not the from address. |
subject | string | Required | Plain text, one line. |
html | string | Optional | HTML body. Send html, text, or both. At least one is required. |
text | string | Optional | Plain text body, used by clients that do not show HTML. |
headers | object | Optional | Extra headers as name and value pairs. Headers Sently controls, such as from and subject, are rejected. |
attachments | object[] | Optional | Each item has filename, content as base64, and an optional content_type. |
tags | object[] | Optional | Each item has name and value, using letters, numbers, _ and -. Useful for grouping in your dashboard. |
category | string | Optional | "transactional" (default) or "marketing". Marketing adds one-click unsubscribe and skips people who unsubscribed. |
metadata | object | Optional | Up to 10 string key-value pairs, echoed back on the email and on webhook events. Never used by Sently. |
POST /api/v1/emails
authorization: Bearer YOUR_API_KEY
content-type: application/json
idempotency-key: order-1042-shipped
{
"from": "Orders <[email protected]>",
"to": ["[email protected]"],
"cc": ["[email protected]"],
"bcc": ["[email protected]"],
"reply_to": "[email protected]",
"subject": "Your order is on the way",
"html": "<p>It left the warehouse this morning.</p>",
"text": "It left the warehouse this morning.",
"headers": { "X-Order-Id": "1042" },
"attachments": [
{
"filename": "invoice.pdf",
"content": "JVBERi0xLjQK...",
"content_type": "application/pdf"
}
],
"tags": [{ "name": "kind", "value": "shipping" }]
}HTTP/1.1 200 OK
content-type: application/json
{
"id": "01J9Z4T7QW8S6R5M3K2P1N0ABC",
"status": "queued"
}The status starts as queued and moves through sent, delivered and, when tracking is on, opened and clicked. Recipients on your blocked list are skipped and listed in the email record.
Batch and templates
Send up to 100 at once
POST /api/v1/emails/batch takes up to 100 emails. Each is validated and queued on its own, so one bad email does not fail the rest. The response has a results array in the same order, each entry either { id, status } or { error }.
POST /api/v1/emails/batch
authorization: Bearer YOUR_API_KEY
content-type: application/json
{
"emails": [
{ "from": "[email protected]", "to": "[email protected]", "subject": "Hi A", "text": "Hello A" },
{ "from": "[email protected]", "to": "[email protected]", "subject": "Hi B", "text": "Hello B" }
]
}HTTP/1.1 200 OK
content-type: application/json
{
"results": [
{ "id": "01J9Z4T7QW8S6R5M3K2P1N0ABC", "status": "queued" },
{ "error": { "type": "invalid_request", "code": "forbidden", "message": "You can only send from verified domains." } }
]
}Send a saved template
Save a subject and body with {{placeholders}} in the dashboard, then send it with POST /api/v1/emails/template. The variables fill the placeholders; anything missing becomes an empty string. Substitution is plain text, so nothing in a template is ever run.
POST /api/v1/emails/template
authorization: Bearer YOUR_API_KEY
content-type: application/json
{
"template_id": "01J9ZTEMPLATE000000000000",
"from": "Orders <[email protected]>",
"to": "[email protected]",
"variables": { "name": "Maria", "company": "Acme" }
}Marketing and unsubscribe
Set "category": "marketing" on a send and Sently adds the one-click List-Unsubscribe headers that Gmail and Yahoo require of bulk senders, and skips anyone who has unsubscribed from this client. Transactional email (the default) ignores the unsubscribe list, so a receipt or a password reset always goes out.
POST /api/v1/emails
authorization: Bearer YOUR_API_KEY
content-type: application/json
{
"from": "News <[email protected]>",
"to": "[email protected]",
"subject": "This week at Acme",
"html": "<p>Here is what is new...</p>",
"category": "marketing",
"metadata": { "campaign": "weekly-digest" }
}- The unsubscribe list is per client, kept separate from the blocked list, which is about bounces and spam complaints.
- A recipient unsubscribes through the link in the email; no sign-in and no third party can unsubscribe someone else.
- Use
metadatato tag a send with your own campaign id. It is echoed back on the email and every webhook.
Idempotency
Send an Idempotency-Key header with a value that is unique to the thing you are emailing about. If the same key arrives again within 24 hours, Sently returns the first email instead of sending a second one.
curl https://app.yourdomain.com/api/v1/emails \
-H "authorization: Bearer $SENTLY_API_KEY" \
-H "content-type: application/json" \
-H "idempotency-key: order-1042-shipped" \
-d '{ "from": "[email protected]", "to": "[email protected]", "subject": "Your order is on the way", "text": "On its way." }'You should only automatically retry a send programmatically when an Idempotency-Key is present to avoid duplicate emails.
Read emails
List emails
GET /api/v1/emails returns the newest emails first. Pass the nextCursor value back as ?cursor= to get the next page. Needs a key with read access.
GET /api/v1/emails?cursor=CURSOR
authorization: Bearer YOUR_API_KEY
{
"items": [
{
"id": "01J9Z4T7QW8S6R5M3K2P1N0ABC",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Your order is on the way",
"status": "delivered",
"createdAt": "2025-01-14T09:31:22.000Z"
}
],
"nextCursor": "eyJrIjoiLi4uIn0"
}Get one email
GET /api/v1/emails/:id returns one email, including copied recipients and any addresses that were skipped.
GET /api/v1/emails/01J9Z4T7QW8S6R5M3K2P1N0ABC
authorization: Bearer YOUR_API_KEY
{
"id": "01J9Z4T7QW8S6R5M3K2P1N0ABC",
"from": "[email protected]",
"to": ["[email protected]"],
"cc": [],
"bcc": [],
"skipped": ["[email protected]"],
"subject": "Your order is on the way",
"status": "delivered",
"createdAt": "2025-01-14T09:31:22.000Z"
}More Examples
Here are a few more examples of how to interact with the API using standard HTTP requests.
// Safe to retry: the same key within 24 hours returns the first email.
await fetch('https://app.yourdomain.com/api/v1/emails', {
method: 'POST',
headers: {
'authorization': `Bearer ${process.env.SENTLY_API_KEY}`,
'content-type': 'application/json',
'idempotency-key': 'order-1042-receipt'
},
body: JSON.stringify({
from: '[email protected]',
to: '[email protected]',
subject: 'Receipt',
text: 'Thanks.'
})
});
// Reading needs a key with "Send and read" access.
const email = await fetch('https://app.yourdomain.com/api/v1/emails/01J9Z4T7QW8S6R5M3K2P1N0ABC', {
headers: { 'authorization': `Bearer ${process.env.SENTLY_API_KEY}` }
}).then(r => r.json());
const page = await fetch('https://app.yourdomain.com/api/v1/emails', {
headers: { 'authorization': `Bearer ${process.env.SENTLY_API_KEY}` }
}).then(r => r.json());Webhooks
Add a webhook in the dashboard, pick the events you want, and keep the signing secret. Each request carries a sently-signature header. Verify it before you trust the body.
import express from 'express';
import crypto from 'crypto';
const app = express();
const handled = new Set();
// The raw body is required, so do not use a JSON body parser on this route.
app.post('/webhooks/sently', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
const signature = req.headers['sently-signature'];
const hash = crypto
.createHmac('sha256', process.env.SENTLY_WEBHOOK_SECRET)
.update(rawBody)
.digest('hex');
if (hash !== signature) {
return res.sendStatus(401);
}
const event = JSON.parse(rawBody);
// A retry can deliver the same event twice: skip ids you have already handled.
// Use your database in production; a Set is lost when the process restarts.
if (event.id && handled.has(event.id)) return res.sendStatus(200);
if (event.id) handled.add(event.id);
console.log(event.type, event.data.email_id);
res.sendStatus(200);
});{
"id": "evt_01JA2B3C4D5E6F7G8H9J0K1M2N",
"type": "email.delivered",
"created_at": "2025-01-14T09:31:40.000Z",
"data": {
"email_id": "01J9Z4T7QW8S6R5M3K2P1N0ABC",
"recipient": "[email protected]"
}
}- Pass the raw request body as a string. A body that has been parsed and re-serialized will not match.
- Requests older than five minutes are rejected. Pass a fourth argument to change that window.
- Reply with a 2xx status quickly and do the slow work afterwards. Failed deliveries are retried.
- A retried delivery can reach you twice. Every event has an
id(evt_...), also sent as thesently-webhook-idheader, which stays the same on every retry. Store the ids you have handled and skip repeats.sently-delivery-idis different on every attempt and matches the delivery log in your dashboard.
Errors
Errors use normal HTTP status codes and a JSON body with a code, a message written for people, and per-field messages when a field is at fault.
HTTP/1.1 422 Unprocessable Entity
content-type: application/json
{
"error": {
"code": "invalid_request",
"message": "Use \"Name <[email protected]>\" or \"[email protected]\"",
"fields": { "from": "Use a verified domain" }
}
}