Skip to content

Sently API Documentation

The Sently API is plain HTTP with JSON. Everything below works with any HTTP client.

Quick start

  1. Add your domain in the dashboard and add the DNS records it shows you.
  2. Create an API key. Copy it once, it is not shown again.
  3. Send your first email with the call below.
Node
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 header
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.

Request body fields
FieldTypeRequiredNotes
fromstringRequired"Name <[email protected]>" or "[email protected]". The domain must be verified.
tostring or string[]RequiredOne address or a list of addresses.
ccstring or string[]OptionalCopied recipients. They count towards your monthly total.
bccstring or string[]OptionalHidden recipients. They also count towards your monthly total.
reply_tostring or string[]OptionalWhere replies should go if it is not the from address.
subjectstringRequiredPlain text, one line.
htmlstringOptionalHTML body. Send html, text, or both. At least one is required.
textstringOptionalPlain text body, used by clients that do not show HTML.
headersobjectOptionalExtra headers as name and value pairs. Headers Sently controls, such as from and subject, are rejected.
attachmentsobject[]OptionalEach item has filename, content as base64, and an optional content_type.
tagsobject[]OptionalEach item has name and value, using letters, numbers, _ and -. Useful for grouping in your dashboard.
categorystringOptional"transactional" (default) or "marketing". Marketing adds one-click unsubscribe and skips people who unsubscribed.
metadataobjectOptionalUp to 10 string key-value pairs, echoed back on the email and on webhook events. Never used by Sently.
Request
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" }]
}
Response
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 }.

Batch request
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" }
  ]
}
Batch response
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.

Template send
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.

Marketing send
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 metadata to 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.

Idempotency-Key header
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.

List emails
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 one email
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.

Send and read
// 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.

Verify a webhook
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);
});
Event body
{
  "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 the sently-webhook-id header, which stays the same on every retry. Store the ids you have handled and skip repeats. sently-delivery-id is 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.

Error response
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" }
  }
}