Get an API key

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.

ScopeAllows
emails:sendSend messages
messages:readRead waybills and delivery records
domains:writeAdd domains and run verification
webhooks:writeCreate 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.

POST /v1/domains
RecordStatusWhy
s1._domainkeyRequiredThe DKIM public key. The only record we require — DMARC passes when either SPF or DKIM aligns, and we sign as your domain.
_dmarcRecommendedYour policy for your own domain. Start at p=none and read two weeks of reports before tightening.
SPFNot neededOur 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

POST /v1/emails
FieldTypeNotes
fromstring requiredMust be on a verified domain.
tostring requiredA single recipient. One message, one address.
subjectstringRejected if it contains a line break.
text / htmlstringAt least one is required.
headersobjectValues containing CR or LF are rejected.
idempotencyKeystringRetry 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.

GET /v1/messages/:id

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)
})
HeaderMeaning
posthaste-signatureTimestamp and HMAC, as t=…,v1=…
posthaste-delivery-idStable across retries. Use it to deduplicate — a redelivery is not a second event.
posthaste-attemptWhich 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.

TypeStatusWhat to do
unauthorized401Key missing, malformed, revoked, expired, or account suspended. The response is identical in every case — telling you which would confirm a key id is real.
forbidden403Authenticated but lacks the scope. Not a retry.
invalid_request400Body failed validation. The fields array names each problem.
domain_not_verified422Publish the DKIM record and verify.
suppressed422Do 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.

WeekDaily capTypical traffic
150 → 200Seed accounts, verifying placement by hand
2500 → 1,500Real transactional mail from trusted senders
3–43k → 10kFirst external customers
5–825k → 100kOpen 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.