Documentation
Send email, and see exactly what happened to it.
Posthaste delivers your mail directly to the recipient’s server from infrastructure we run ourselves, and keeps the entire SMTP conversation so you can read it back. This is everything you need to send your first message and handle what comes back.
Quickstart
Two things stand between you and a delivered email: one DNS record, and one request.
const res = await fetch('https://api.posthastemail.dev/v1/emails', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.POSTHASTE_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({
from: '[email protected]',
to: '[email protected]',
subject: 'Reset your password',
text: 'Click the link to reset your password.',
}),
})
// 202 { id: 'msg_AaBbCcDdEeFfGgHh4Kq', status: 'queued' }202 Accepted means the message is durably stored and queued — not that it has been delivered. Nothing is accepted without being written to disk first, so a crash cannot lose a message you were told we had.
Authentication
Every request carries an API key as a bearer token. Keys are shown once when created and stored only as a hash — if you lose one, nobody can recover it.
authorization: Bearer ph_live_8Fk2mQx7Rd4pLw9…Scopes
Keys grant only what they list. A missing scope is denied, so an endpoint added later is closed to your existing keys rather than silently reachable by all of them.
| Scope | Allows |
|---|---|
| emails:send | Send messages |
| messages:read | Read waybills and delivery records |
| domains:write | Add domains and run verification |
| webhooks:write | Create and remove endpoints |
Test keys are not interchangeable with live keys. A ph_test_ token carrying a live secret is refused even when the secret is correct, so a staging deploy cannot accidentally send real mail.
Verify a domain
We sign your mail as your domain, so you publish one record proving we are allowed to. Sending from an unverified domain is refused outright rather than queued and failed later.
| Record | Status | Why |
|---|---|---|
| s1._domainkey | Required | The DKIM public key. The only record we require — DMARC passes when either SPF or DKIM aligns, and we sign as your domain. |
| _dmarc | Recommended | Your policy for your own domain. Start at p=none and read two weeks of reports before tightening. |
| SPF | Not needed | Our envelope sender is on our own bounce domain, so SPF is evaluated against us, not you. |
Do not replace an existing SPF record. A domain may publish only one, and overwriting yours would break every other system that sends as you — your CRM, your invoicing, your helpdesk.
Send an email
| Field | Type | Notes |
|---|---|---|
| from | string required | Must be on a verified domain. |
| to | string required | A single recipient. One message, one address. |
| subject | string | Rejected if it contains a line break. |
| text / html | string | At least one is required. |
| headers | object | Values containing CR or LF are rejected. |
| idempotencyKey | string | Retry safely — the same key never sends twice. |
Idempotency
Send the same idempotencyKey twice and the second call returns 200 with the original message id, rather than 202 and a second delivery. This is the safe way to retry a request whose response you never saw.
Header injection is rejected, not sanitised. A carriage return anywhere in an address, subject or custom header returns 400. Silently stripping them would let a crafted subject append a Bcc: nobody could see.
The waybill
A waybill is the complete record of one message: every event it passed through, and the verbatim SMTP conversation with the receiving server. It is append-only and hash-linked, so you can verify independently that nothing was rewritten afterwards.
Each event carries hash and prevHash. The chain is per account rather than per message, so a message’s first event links to whatever preceded it in your account’s chain.
Webhooks
Rather than polling, register an endpoint and we post events to it as they happen. Every delivery is signed and timestamped.
Verifying a signature
The timestamp is signed with the body. Signing the body alone would be replayable forever — anyone who captured one valid request could resend it indefinitely and it would still verify.
import { verifyWebhook } from 'posthaste'
app.post('/hooks/posthaste', (req, res) => {
const result = verifyWebhook(
req.rawBody,
req.headers['posthaste-signature'],
process.env.POSTHASTE_WEBHOOK_SECRET,
)
if (!result.valid) {
// 'signature_mismatch' | 'timestamp_too_old' | …
return res.status(400).send(result.reason)
}
return res.sendStatus(200)
})| Header | Meaning |
|---|---|
| posthaste-signature | Timestamp and HMAC, as t=…,v1=… |
| posthaste-delivery-id | Stable across retries. Use it to deduplicate — a redelivery is not a second event. |
| posthaste-attempt | Which attempt this is, so you can tell retries apart. |
Retries
Any non-2xx is retried on a schedule of 10s, 1m, 5m, 30m, 2h, 6h and 12h. Two exceptions: 410 Gone and 404 stop immediately, because the endpoint is telling us it no longer exists. An endpoint failing for 24 hours with no successes is disabled automatically.
Suppressions
An address that hard bounces or files a complaint is suppressed immediately and will not be sent to again by anyone. Attempting to send returns 422 with the reason.
A temporary failure never suppresses. A mailbox that was full for an afternoon is not a dead address — suppressing on that would make a working recipient permanently unreachable and you would never find out why.
Errors
Errors return a machine-readable type and a message written for a human reading a log at 2am.
| Type | Status | What to do |
|---|---|---|
| unauthorized | 401 | Key missing, malformed, revoked, expired, or account suspended. The response is identical in every case — telling you which would confirm a key id is real. |
| forbidden | 403 | Authenticated but lacks the scope. Not a retry. |
| invalid_request | 400 | Body failed validation. The fields array names each problem. |
| domain_not_verified | 422 | Publish the DKIM record and verify. |
| suppressed | 422 | Do not retry. Remove the address from your list. |
Limits and warmup
New sending IPs have no reputation, and volume that appears from nothing looks exactly like a compromised host. So capacity rises on a schedule rather than being available immediately.
| Week | Daily cap | Typical traffic |
|---|---|---|
| 1 | 50 → 200 | Seed accounts, verifying placement by hand |
| 2 | 500 → 1,500 | Real transactional mail from trusted senders |
| 3–4 | 3k → 10k | First external customers |
| 5–8 | 25k → 100k | Open signups |
The complaint rate is the governor, not the calendar. If it rises above 0.3%, the cap holds or steps back regardless of which week it is. That threshold is Gmail’s, not ours.
Your monthly allowance
Separately from the daily ramp, each plan includes a number of messages a month — 5,000 on Free, 100,000 on Starter, 300,000 on Growth, 1,000,000 on Scale. It is counted per calendar month in UTC, and it counts accepted messages only: a send we refuse was never attempted, so it is never billed and never counted.
Exceeding it returns 429 with "type": "monthly_limit_reached" and a Retry-After pointing at the start of next month rather than at midnight. Moving to a larger plan lifts the ceiling immediately — there is nothing to wait for.
GET /v1/usage returns the current period’s totals with the daily series behind them, so you can see which days produced a number rather than taking it on trust. Those counters are derived from the delivery record itself, not tallied separately, which is why they cannot drift from what actually happened.
Your account’s daily cap
The schedule above is the shared sending IP. Your own account also has a daily cap that starts at 200 messages and climbs one step at a time: 200, 500, 1,000, 2,500, 5,000, 10,000, 25,000, 50,000, 100,000, 250,000.
It moves once a day, based only on the day before. You go up a step after a day where you used at least 80% of your current cap with a complaint rate at or under 0.1% and a bounce rate at or under 5%. You go down a step after a day where complaints passed 0.3% or bounces passed 10%. Never more than one step either way, and an idle day moves nothing — capacity is earned with real volume, not with time.
Sending past the cap returns 429 with a Retry-After header pointing at the reset, which is midnight UTC. The error body carries the limit and how many you have sentToday. Nothing is queued and delivered later — a refused message was never accepted.
You do not have to wait for a 429 to find out where you stand: GET /v1/me returns sending.dailyLimit, sending.sentToday and sending.remainingToday.