API reference
The postie handbook
postie is a JSON REST API. Every request goes to the base URL below with your API key. No SDK needed: anything that can make an HTTP request can send email. Base URL: https://api.postie.sh
No. 01
Quickstart
1. Sign up, add your domain and set its DNS records. 2. Create an API key. 3. Send your first email:
const res = await fetch("https://api.postie.sh/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Shop <orders@yourshop.com>",
to: "customer@example.com",
subject: "Order #1042 confirmed",
html: "<p>Thanks for your order!</p>",
tags: [{ name: "kind", value: "order_confirmation" }],
}),
});
const { id } = await res.json();Or from a terminal:
curl -X POST https://api.postie.sh/emails \
-H "Authorization: Bearer po_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"from":"orders@yourshop.com","to":"customer@example.com","subject":"Hi","text":"Hello"}'A successful send returns { "id": "…" }. Use that ID with GET /emails/:id to check its status.
No. 02
Authentication
Every request needs an Authorization: Bearer po_… header. Keys are shown once when you create them, and only a SHA-256 hash is stored.
| Permission | Can do |
|---|---|
full_access | Every endpoint: send, list, domains and API keys |
sending_access | POST /emails and POST /emails/batch only. Can be limited to one domain with domain_id |
No. 03
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /emails | Send an email |
| POST | /emails/batch | Send up to 100 emails. Set x-batch-validation to strict (default) or permissive |
| GET | /emails | List emails (limit, after) |
| GET | /emails/:id | Get an email and its last event |
| PATCH | /emails/:id | Reschedule a scheduled email |
| POST | /emails/:id/cancel | Cancel a scheduled email |
| POST | /domains | Add a domain |
| GET | /domains | List domains |
| GET | /domains/:id | Get a domain and its DNS records |
| DELETE | /domains/:id | Remove a domain |
| POST | /domains/:id/verify | Re-check DNS verification |
| POST | /api-keys | Create an API key (token returned once) |
| GET | /api-keys | List API keys |
| DELETE | /api-keys/:id | Delete an API key |
POST /emails accepts from, to, subject, html and/or text, cc, bcc, reply_to, headers, attachments (base64 content or a path URL), tags and scheduled_at. Batch sends don’t support attachments or scheduling.
No. 04
Idempotency
Send an Idempotency-Key header, e.g. your order ID. Retrying with the same key returns the original email ID instead of sending again. In a batch, each email gets key:index.
await fetch("https://api.postie.sh/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.EMAIL_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `order-${order.id}`,
},
body: JSON.stringify(email),
});No. 05
Scheduling
Set scheduled_at to an ISO 8601 date or plain English such as in 1 hour, in 30 minutes or in 2 days. It can be up to 30 days ahead. Reschedule with PATCH /emails/:id or cancel with POST /emails/:id/cancel.
No. 06
Domains and DNS
When you add a domain, postie sets it up for sending and returns the records to add at your DNS provider. Verified domains also cover their subdomains.
| Record | Type | Name | Value |
|---|---|---|---|
| DKIM ×3 | CNAME | <token>._domainkey | <token>.dkim.amazonses.com |
| SPF | MX | bounce | 10 feedback-smtp.eu-west-2.amazonses.com |
| SPF | TXT | bounce | v=spf1 include:amazonses.com ~all |
| DMARC (optional) | TXT | _dmarc | v=DMARC1; p=none; |
The DKIM records sign your mail. The bounce. MX and TXT records set a custom MAIL FROM domain, so bounces come back to us and SPF aligns. Pending domains are checked every 15 minutes, or right away with POST /domains/:id/verify.
No. 07
Webhooks
Add endpoints in the dashboard and choose which events to receive. Each delivery is a JSON { type, created_at, data } body.
email.sentemail.deliveredemail.delivery_delayedemail.bouncedemail.complainedemail.openedemail.clickedemail.failedemail.suppressedEvery delivery carries three headers: webhook-id, webhook-timestamp (unix seconds) and webhook-signature. The signature is v1, followed by a base64 HMAC-SHA256 of ${id}.${timestamp}.${body}, keyed with your signing secret base64-decoded after the whsec_ prefix. Reject requests older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(req: Request, body: string, secret: string) {
const id = req.headers.get("webhook-id") ?? "";
const ts = req.headers.get("webhook-timestamp") ?? "";
const sig = req.headers.get("webhook-signature") ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const want = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest("base64");
return sig.split(" ").some((s) => {
const got = s.replace(/^v1,/, "");
return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want));
});
}Failed deliveries (anything other than a 2xx) retry six times over about 17 hours. Every delivery, its response and a replay button are in the dashboard.
No. 08
Errors
Errors always return the same shape, with a stable name you can switch on:
{ "statusCode": 422, "message": "Missing `to` field.", "name": "validation_error" }| Status | Name | When |
|---|---|---|
| 401 | missing_api_key | No Authorization header |
| 403 | invalid_api_key | The key doesn’t exist or was deleted |
| 401 | restricted_api_key | A sending key called a management endpoint |
| 403 | restricted_api_key | A domain-scoped key sent from another domain |
| 422 | validation_error | Invalid body, address or scheduled_at |
| 403 | validation_error | The from domain isn’t verified |
| 404 | not_found | The email, domain or key doesn’t belong to your team |
| 403 | team_paused | Sending was paused because the bounce or complaint rate got too high |
| 429 | monthly_quota_exceeded | This send would go over your monthly quota |
| 409 | concurrent_idempotent_requests | Same idempotency key is still being processed |
| 502 | application_error | The sending service couldn’t complete the request |
| 500 | internal_server_error | Something unexpected went wrong |
No. 09
Limits
| Limit | Value |
|---|---|
| Recipients per email (to + cc + bcc) | 50 |
| Emails per batch | 100 |
| Attachments per email | 20, 10MB total |
| Tags per email | 10 |
| Scheduling window | 30 days |
| Free plan | 3,000 emails / month, 100 / day |
Monthly volume, domains and webhook endpoints depend on your plan. See pricing for every plan's limits.
Addresses that hard bounce or complain are suppressed automatically. Sending to them returns an ID with status suppressed, and nothing is sent.