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.
curl https://api.outsend.dev/v1/emails \
-H "Authorization: Bearer outsend_xxx" \
-H "Content-Type: application/json" \
-d '{ ... }'Quickstart
- Create an account and a workspace.
- Open Developer settings and create an API key. Copy it โ you only see it once.
- Add and verify a sending domain under Domains.
- Send your first email:
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:
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:
{ "email": "sam@example.com", "name": "Sam" }Attachment โ base64-encoded content:
{
"filename": "invoice.pdf",
"content": "<base64>",
"contentType": "application/pdf"
}Event โ delivered in webhooks and the message timeline:
{ "id": "evt_...", "type": "delivered", "timestamp": "2026-07-08T...", "payload": {} }Send an email
/v1/emailsSends a single email. Synchronous unless scheduledAt is in the future.
| Field | Type | Required | Notes |
|---|---|---|---|
| from | string | Address | yes | |
| to | string | Address | Address[] | yes | |
| cc | same as to | no | |
| bcc | same as to | no | |
| replyTo | string | Address | no | |
| subject | string | yes | |
| html | string | one of html/text | |
| text | string | no | plain fallback |
| headers | Record<string,string> | no | List-Unsubscribe injected |
| attachments | Attachment[] | no | max 10 |
| templateId | string | no | {{variable}} interpolated |
| variables | Record<string,string|number> | no | |
| scheduledAt | ISO 8601 | no | future โ status scheduled |
Response 200 (or 502 on send failure):
{ "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
/v1/emailsLists messages for the workspace, newest first (cursor pagination).
| Param | Type | Notes |
|---|---|---|
| limit | int | default 50, max 100 |
| cursor | string | id from nextCursor |
| status | string | sent | pending | scheduled | failed | bounced |
{
"data": [
{ "id": "...", "from": "...", "subject": "...", "status": "sent",
"createdAt": "...", "sentAt": "..." }
],
"hasMore": true,
"nextCursor": "<id>"
}Retrieve a message
/v1/emails/:idFull message + event timeline + attachment metadata.
{
"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
/v1/emails/batchSend up to 100 emails in one request. Each item has the same shape as a single send; sends run concurrently.
{ "emails": [ { ...sendParams }, { ...sendParams } ] }
# โ { "data": [ { "id": "...", "status": "sent" }, { "status": "failed", "error": "..." } ] }Validate an address
/v1/emails/validate?email=...RFC syntax + DNS MX lookup.
{ "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.
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.
/v1/suppressions/v1/suppressions/v1/suppressions?email=...# 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.
/v1/lists/v1/lists/v1/lists/:id# 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.
/v1/contacts/v1/contacts/v1/contacts/:id# 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.
/v1/campaigns/v1/campaigns/v1/campaigns/:id# 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.
/v1/analytics?days=30# โ { "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.
/v1/webhooks/v1/webhooks/v1/webhooks?id=...# 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:
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)
/v1/webhooks/:providerDelivery 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):
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 eventType | Outsend event |
|---|---|
| Delivery | delivered |
| Bounce | bounced |
| Complaint | complained |
| Open | opened |
| Click | clicked |
| Reject / RenderingFailure | failed |
| 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):
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
/v1/inboundReceive 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.
{
"from": "user@example.com",
"to": "reply@yourdomain.com",
"subject": "Re: ...",
"text": "...",
"html": "...",
"organizationId": "<only for shared-secret mode>"
}DMARC reports
/v1/dmarcParse a DMARC aggregate report (RFC 7489 XML), plain or base64-gzipped.
{ "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
/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 anopenedevent.GET /t/c/:id/:urlโ click redirect, recordsclicked, then 302.GET /u/:tokenยทPOST /u/:tokenโ hosted one-click unsubscribe (RFC 8058).
Errors
| Status | Meaning |
|---|---|
| 400 | Malformed body |
| 401 | Missing / invalid API key |
| 404 | Resource not found |
| 422 | Validation failed (issues included) |
| 429 | Rate limit / quota exceeded |
| 502 | Provider send failed |
Errors follow { "error": "message" } with the matching status.
Rate limits & plans
Limits are per workspace, driven by your plan.
| Free | Pro | |
|---|---|---|
| Recipients / month | 3,000 | 20,000 |
| Recipients / day | 100 | unlimited |
| Recipients / message | 10 | 50 |
| Attachments / message | 1MB | 2MB |
| Domains | 1 | unlimited |
| Team members | 1 | unlimited |
| Recipient burst | 2 / s | 10 / s |
When a plan limit is hit, sends return failed with an explanatory error. Manage your plan in the dashboard under Billing.