Outsend API reference

Base URL: https://api.outsend.dev. Send transactional and marketing email with a single API. All requests accept JSON and authenticate with an API key.

Introduction

Create API keys in the dashboard under Developer settings. Keys are scoped to one workspace (organization); the raw key is shown only once at creation.

bash
curl https://api.outsend.dev/v1/emails \
  -H "Authorization: Bearer outsend_xxx" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

Quickstart

  1. Create an account and a workspace.
  2. Open Developer settings and create an API key. Copy it โ€” you only see it once.
  3. Add and verify a sending domain under Domains.
  4. Send your first email:
bash
curl -X POST https://api.outsend.dev/v1/emails \
  -H "Authorization: Bearer outsend_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "you@yourdomain.com",
    "to": "customer@example.com",
    "subject": "Hello",
    "html": "<p>It works ๐ŸŽ‰</p>"
  }'

# โ†’ { "id": "msg_...", "status": "sent" }

Authentication

Every request sends an API key as a Bearer token:

header
Authorization: Bearer outsend_xxxxxxxxxxxxxxxx

A 401 means the key is missing or unknown. Keys are hashed at rest; the visible prefix is shown in the dashboard for identification only.

Schemas

Address โ€” a string email, or an object:

json
{ "email": "sam@example.com", "name": "Sam" }

Attachment โ€” base64-encoded content:

json
{
  "filename": "invoice.pdf",
  "content": "<base64>",
  "contentType": "application/pdf"
}

Event โ€” delivered in webhooks and the message timeline:

json
{ "id": "evt_...", "type": "delivered", "timestamp": "2026-07-08T...", "payload": {} }

Send an email

POST/v1/emails

Sends a single email. Synchronous unless scheduledAt is in the future.

FieldTypeRequiredNotes
fromstring | Addressyes
tostring | Address | Address[]yes
ccsame as tono
bccsame as tono
replyTostring | Addressno
subjectstringyes
htmlstringone of html/text
textstringnoplain fallback
headersRecord<string,string>noList-Unsubscribe injected
attachmentsAttachment[]nomax 10
templateIdstringno{{variable}} interpolated
variablesRecord<string,string|number>no
scheduledAtISO 8601nofuture โ†’ status scheduled

Response 200 (or 502 on send failure):

json
{ "id": "msg_...", "status": "sent" }

# status โˆˆ sent | pending | scheduled | failed
# On failed, an "error" field is present.

Recipients on the suppression list are dropped; if all are suppressed the response is failed with an explanatory error.

List messages

GET/v1/emails

Lists messages for the workspace, newest first (cursor pagination).

ParamTypeNotes
limitintdefault 50, max 100
cursorstringid from nextCursor
statusstringsent | pending | scheduled | failed | bounced
json
{
  "data": [
    { "id": "...", "from": "...", "subject": "...", "status": "sent",
      "createdAt": "...", "sentAt": "..." }
  ],
  "hasMore": true,
  "nextCursor": "<id>"
}

Retrieve a message

GET/v1/emails/:id

Full message + event timeline + attachment metadata.

json
{
  "data": {
    "id": "...", "from": "...", "to": [...], "subject": "...",
    "html": "...", "text": "...", "status": "sent",
    "createdAt": "...", "sentAt": "...",
    "events": [
      { "id": "...", "type": "sent", "timestamp": "..." },
      { "type": "delivered" }, { "type": "opened" }, { "type": "clicked" }
    ],
    "attachments": [ { "id": "...", "filename": "...", "contentType": "..." } ]
  }
}

Batch send

POST/v1/emails/batch

Send up to 100 emails in one request. Each item has the same shape as a single send; sends run concurrently.

json
{ "emails": [ { ...sendParams }, { ...sendParams } ] }

# โ†’ { "data": [ { "id": "...", "status": "sent" }, { "status": "failed", "error": "..." } ] }

Validate an address

GET/v1/emails/validate?email=...

RFC syntax + DNS MX lookup.

json
{ "valid": true, "domain": "example.com", "mx": ["mx1.example.com"] }
{ "valid": false, "reason": "No MX records found" }

Scheduling & idempotency

Set scheduledAt to a future ISO time โ†’ message is held as scheduled and released by the worker at send time.

Send an Idempotency-Key header to safely retry within 24h โ€” the same key returns the original response without resending.

bash
curl -X POST .../v1/emails \
  -H "Idempotency-Key: order-123" ...

Suppressions

Addresses Outsend will not send to. Auto-populated by hard bounces, complaints, and one-click unsubscribes.

GET/v1/suppressions
POST/v1/suppressions
DELETE/v1/suppressions?email=...
json
# POST
{ "email": "spam@bad.com", "reason": "manual" }
# reason โˆˆ manual | unsubscribe | bounce | complaint
# โ†’ { "ok": true, "email": "spam@bad.com" }

# GET โ†’ { "data": [ { "email": "...", "reason": "bounce", "source": "webhook", "createdAt": "..." } ] }
# Filter a single address with ?email=...

# DELETE โ†’ { "ok": true }

Lists

Contact lists group contacts for campaigns. List IDs are needed for POST /v1/contacts (listIds) and campaign creation (listId). Create lists here or in the dashboard Contacts page.

GET/v1/lists
POST/v1/lists
GET/v1/lists/:id
json
# POST
{ "name": "Newsletter", "description": "Weekly product updates" }
# โ†’ 201 { "data": { "id": "cl...", "name": "Newsletter", "description": "...", "createdAt": "..." } }

# GET /v1/lists?limit=100&cursor=...
# โ†’ { "data": [ { "id": "cl...", "name": "Newsletter", "contactCount": 432, ... } ], "hasMore": false, "nextCursor": null }

# GET /v1/lists/:id โ†’ { "data": { ..., "contactCount": 432 } }

Contacts

Manage audience contacts. A contact is a unique email per workspace; creating an existing email updates its name. Optional listIds attach it to lists.

GET/v1/contacts
POST/v1/contacts
GET/v1/contacts/:id
json
# POST
{ "email": "jane@acme.com", "name": "Jane", "listIds": ["cl..."] }
# โ†’ 201 { "data": { "id": "...", "email": "jane@acme.com", "name": "Jane", "unsubscribed": false, ... } }

# GET /v1/contacts?limit=100&listId=...&unsubscribed=false&cursor=...
# โ†’ { "data": [...], "hasMore": true, "nextCursor": "..." }
# Filters: email, listId, unsubscribed (true|false). limit โ‰ค 1000.

# GET /v1/contacts/:id โ†’ { "data": { ..., "lists": [{ "id": "...", "name": "..." }] } }

Campaigns

A campaign broadcasts a template to every contact on a list. Create it as a draft (or scheduled with scheduledAt); send it from the dashboard or a server action. Template and list must exist in the workspace.

GET/v1/campaigns
POST/v1/campaigns
GET/v1/campaigns/:id
json
# POST
{ "name": "July launch", "templateId": "cl...", "listId": "cl...", "from": "you@yourdomain.com", "scheduledAt": "2026-08-01T09:00:00Z" }
# โ†’ 201 { "data": { "id": "...", "status": "scheduled", "sentCount": 0, "failedCount": 0, ... } }
# status โˆˆ draft | scheduled | sending | sent | paused

# GET /v1/campaigns?status=sent&limit=50&cursor=...
# โ†’ { "data": [...], "hasMore": true, "nextCursor": "..." }

# GET /v1/campaigns/:id
# โ†’ { "data": { ..., "messageCount": 1234, "template": {...}, "list": {...} } }

Analytics

Delivery metrics over a trailing window (1โ€“90 days, default 30). Same data as the dashboard Analytics page.

GET/v1/analytics?days=30
json
# โ†’ { "data": {
#   "total": 5210, "sent": 5000, "failed": 210, "delivered": 4980,
#   "opened": 1200, "clicked": 300, "bounced": 18, "complained": 2,
#   "deliveryRate": 99.6, "bounceRate": 0.4, "complaintRate": 0,
#   "openRate": 24, "clickRate": 6,
#   "series": [ { "day": "2026-07-01", "sent": 100, "failed": 2, "bounced": 0, "delivered": 99 }, ... ],
#   "statuses": [ { "status": "sent", "count": 5000 }, ... ],
#   "topFrom": [ { "from": "you@yourdomain.com", "count": 5210 } ]
# }}

Webhooks (outbound)

Register endpoints to receive signed event deliveries. Events are POSTed with an HMAC-SHA256 signature in x-outsend-signature (hex) over the raw body.

GET/v1/webhooks
POST/v1/webhooks
DELETE/v1/webhooks?id=...
json
# POST to register
{ "url": "https://your.app/hooks/outsend", "events": ["delivered","bounced"], "active": true }
# events empty = all events. Returns the webhook incl. its signing "secret" (shown once).

# Delivered event body
{ "type": "delivered", "data": { "messageId": "..." }, "timestamp": 1751980000000 }

Verify on your side:

node.js
import crypto from "node:crypto";
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
// compare to req.headers["x-outsend-signature"]

Event types: sent ยท delivered ยท failed ยท bounced ยท complained ยท opened ยท clicked.

Webhooks (inbound / provider)

POST/v1/webhooks/:provider

Delivery events from your email provider. For SES, events arrive via SNS, so the /v1/webhooks/ses endpoint verifies the SNS signature (no shared secret needed) and auto-confirms the SNS HTTPS subscription. For other providers (or a forwarding Lambda), authorize with the x-outsend-webhook-secret header matching OUTSEND_WEBHOOK_SECRET. Hard bounces and complaints auto-suppress the recipient.

SES setup (one-time):

aws console
1. Verify your sending domain in SES (Easy DKIM + custom MAIL FROM).
2. Create a Configuration Set โ†’ add an SNS destination with event types:
   Send, Delivery, Bounce, Complaint, Open, Click.
3. Create an SNS topic; subscribe your endpoint as HTTPS:
   https://api.outsend.dev/v1/webhooks/ses
   (Outsend auto-confirms the SubscriptionConfirmation the first time it arrives.)
4. Set OUTSEND_WEBHOOK_SECRET for the manual-test / Lambda-forwarding path.

SES event โ†’ Outsend event:

SES eventTypeOutsend event
Deliverydelivered
Bouncebounced
Complaintcomplained
Openopened
Clickclicked
Reject / RenderingFailurefailed
Send(ignored โ€” already recorded at send time)

Matching: Outsend sets the message Message-ID header to <messageId@yourdomain>; SES reports it back as mail.commonHeaders.messageId, which the webhook maps back to the message.

Manual test (SES-shaped):

bash
curl -X POST https://api.outsend.dev/v1/webhooks/ses \
  -H "x-outsend-webhook-secret: $OUTSEND_WEBHOOK_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"eventType":"Delivery","mail":{"commonHeaders":{"messageId":"<your-message-id@yourdomain>"}},"delivery":{"timestamp":"2026-07-09T00:00:00Z"}}'

Inbound email

POST/v1/inbound

Receive replies / forwarded mail. Auth via Bearer key or x-outsend-inbound-secret with organizationId in the body. Stores an inbound message; hard bounces auto-suppress the sender.

json
{
  "from": "user@example.com",
  "to": "reply@yourdomain.com",
  "subject": "Re: ...",
  "text": "...",
  "html": "...",
  "organizationId": "<only for shared-secret mode>"
}

DMARC reports

POST/v1/dmarc

Parse a DMARC aggregate report (RFC 7489 XML), plain or base64-gzipped.

json
{ "report": "<base64 gzip xml>", "gzipped": true }

# โ†’
{
  "data": {
    "orgName": "google.com",
    "email": "noreply-dmarc-support@google.com",
    "domain": "yourdomain.com",
    "rangeStart": "...", "rangeEnd": "...",
    "records": [
      { "sourceIp": "192.0.2.1", "count": 5, "disposition": "none", "dkim": "pass", "spf": "pass" }
    ]
  }
}

Worker / scheduler

GET/v1/worker?secret=...

Drains the delivery queue and releases scheduled messages / due retries. Call on a schedule (cron). Returns { ok, queued, scheduled }. The secret query param must match OUTSEND_WORKER_SECRET.

Tracking endpoints (no auth)

  • GET /t/o/:id โ€” open pixel (1ร—1 GIF), records an opened event.
  • GET /t/c/:id/:url โ€” click redirect, records clicked, then 302.
  • GET /u/:token ยท POST /u/:token โ€” hosted one-click unsubscribe (RFC 8058).

Errors

StatusMeaning
400Malformed body
401Missing / invalid API key
404Resource not found
422Validation failed (issues included)
429Rate limit / quota exceeded
502Provider send failed

Errors follow { "error": "message" } with the matching status.

Rate limits & plans

Limits are per workspace, driven by your plan.

FreePro
Recipients / month3,00020,000
Recipients / day100unlimited
Recipients / message1050
Attachments / message1MB2MB
Domains1unlimited
Team members1unlimited
Recipient burst2 / s10 / s

When a plan limit is hit, sends return failed with an explanatory error. Manage your plan in the dashboard under Billing.