Getting started
TypeScript SDK
@posthaste/sdk is the official client for Node and TypeScript. It has zero runtime dependencies — global fetch and node:crypto, nothing else — so it installs in about a second and adds no transitive supply chain to audit. Everything on this page is also plain HTTPS: the API is usable from any language without it, and the rest of this reference shows the raw requests beside every SDK call.
Install
npm i @posthaste/sdk
# pnpm add @posthaste/sdk
# yarn add @posthaste/sdkRequires Node 22 or newer, or any runtime with a global fetch. ESM only. The package ships its own type declarations; there is no @types package.
Send an email
import { Posthaste } from '@posthaste/sdk'
const posthaste = new Posthaste({ apiKey: process.env.POSTHASTE_KEY })
const result = await posthaste.emails.send({
from: 'Acme <[email protected]>',
to: '[email protected]',
subject: 'Your receipt',
html: '<p>Thanks for your order.</p>',
text: 'Thanks for your order.',
idempotencyKey: `receipt-${orderId}`,
})
result.id // 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'
result.status // 'queued' | 'duplicate'
result.duplicate // false — this was a new send, not an idempotency replaystatus tells you which kind of success this was. A new send answers 202 with status: "queued"; a replay of the same idempotencyKey answers 200 with status: "duplicate" and the id of the message we accepted the first time. Both are success, and the SDK surfaces the difference as result.duplicate rather than hiding it — if you count, bill or log per send, that distinction is the difference between an accurate number and a drifting one.
idempotencyKey is a field in the JSON body, and the SDK maps it there. The Idempotency-Key HTTP header is never read by any endpoint — see why.
Configuring the client
new Posthaste({
apiKey: process.env.POSTHASTE_KEY, // required
baseUrl: 'https://api.posthastemail.dev', // default
timeoutMs: 30_000, // per ATTEMPT, not per call
maxRetries: 2, // retries after the first attempt
maxRetryDelayMs: 60_000, // longest Retry-After it will sit through
headers: { 'x-trace-id': traceId }, // added to every request
fetch: myFetch, // anything fetch-shaped
})| Option | Notes |
|---|---|
| apiKey | Required. A ph_live_ key, sent as a bearer token. Server-side only — a key carries no user identity and must never reach a browser. Getting a key → |
| baseUrl | Defaults to https://api.posthastemail.dev. Trailing slashes are stripped. |
| timeoutMs | Per attempt, not per call — a call that retries twice may take longer than this. 30 seconds by default; 0 disables it. |
| maxRetries | Retries after the first attempt. Two by default; 0 turns retrying off entirely. |
| fetch | Any fetch-shaped function. Useful behind a proxy, for tracing, and in tests. |
import { ProxyAgent } from 'undici'
const dispatcher = new ProxyAgent('http://proxy.internal:8080')
const posthaste = new Posthaste({
apiKey: process.env.POSTHASTE_KEY,
fetch: (url, init) => fetch(url, { ...init, dispatcher }),
})Errors
Every failure throws a PosthasteError, including the ones that never reached a server. It carries status, type, message, fields when the server sent them, retryAfterSeconds when the server said, and body for the extra keys a refusal carries.
import { PosthasteError, isPosthasteError } from '@posthaste/sdk'
try {
await posthaste.emails.send({ from, to, text })
} catch (err) {
if (!isPosthasteError(err)) throw err
switch (err.type) {
// Permanent. Retrying changes nothing; fix the request.
case 'domain_not_verified':
case 'invalid_address':
case 'suppressed':
return drop(err)
// Transient throttling, measured in seconds. err.isRateLimited is true
// for both: platform_paused is OUR daily ceiling, not this account's, so
// it frees up on its own and no plan change touches it.
case 'rate_limited':
case 'platform_paused':
return retryAfter(err.retryAfterSeconds ?? 60)
// Exhausted quota — hours or days away. Not a retry, an alert.
case 'daily_limit_reached':
case 'monthly_limit_reached':
return alertOps(err)
default:
return transient(err)
}
}Branch on the type, never on the status. Four different refusals arrive as 429. rate_limited and platform_paused are transient and measured in seconds — the first is your request rate, the second is the platform’s own daily send ceiling, which is ours rather than yours. The other two, daily_limit_reached and monthly_limit_reached, are exhausted quota with a Retry-After measured in hours or days. The SDK exposes err.isRateLimited and err.isQuotaExhausted for exactly that fork — and they deliberately do not overlap, because telling a customer to upgrade for a ceiling that is not theirs would be wrong twice over. The error reference lists every type.
Two types are synthesised by the SDK and never sent by the API: connection_error (the request never opened) and timeout (it opened and never answered). Both carry status: 0. An unhandled 500 — which does not use the error envelope — becomes unknown_error with the server’s message intact, rather than a crash inside the SDK.
Retries
The SDK retries 408, 429 and 5xx, and connection failures, with exponential backoff and full jitter, honouring Retry-After. Two rules make that safe rather than merely automatic.
- Quota is never retried in process.
daily_limit_reachedandmonthly_limit_reachedare raised immediately with the wait attached, so you can queue, delay or alert instead of hammering a wall the calendar has to move before it opens. - Nothing is repeated that repeating could duplicate. A send is retried only when you supplied an
idempotencyKey, andwebhooks.createis never retried — a duplicate endpoint would receive every event twice, for ever. Reads, deletes,domains.create(a duplicate is a409) andsuppressions.create(an upsert) are all safe, and are retried.
Pagination
Every list on the API pages, and every one of them takes parameters. There is no longer any list that comes back whole — domains.list(), webhooks.list(), apiKeys.list(), billing.invoices() and billing.history() all accept limit and a cursor, and all of them return one page. Reading only data off the first response is the way you lose rows without being told.
// One page.
const page = await posthaste.messages.list({ limit: 50, status: 'bounced' })
page.data // MessageSummary[]
page.hasMore // the ONLY correct loop condition
page.nextCursor // pass as `before` on the next request
// Every page, as an async iterator. Loops on hasMore for you.
for await (const message of posthaste.messages.autoPaginate({ status: 'bounced' })) {
await suppress(message.to)
}
// Or collect into an array, with a ceiling you choose.
const recent = await posthaste.messages.listAll({ status: 'bounced' }, 500)
// EVERY list has the same pair, including the ones that send no hasMore at
// all — domains, webhooks, API keys and invoices. This is all sixty invoices,
// not the first fifty.
const invoices = await posthaste.billing.listAllInvoices()
for await (const key of posthaste.apiKeys.autoPaginate()) {
await audit(key)
}
// Billing history is the odd one out: it pages FORWARD from a sequence number
// in `after`, oldest first. Hence the different names — the helper knows.
for await (const event of posthaste.billing.autoPaginateHistory()) {
await record(event.seq, event.type)
}So each resource carries three methods rather than one: list for a single page, autoPaginate for an async iterator over every page, and listAll to drain that iterator into an array up to a maxItems ceiling you pass — 1,000 by default. The ceiling is not garnish. Draining an unbounded list into memory is how a convenience helper becomes an incident on the one account with four million messages, so the caller chooses the bound.
Billing history is spelled autoPaginateHistory and listAllHistory, and invoices autoPaginateInvoices and listAllInvoices, because billing is one resource with two separate lists. The history helper also pages forward from after rather than back from before, which is the whole reason it needs a helper at all.
Neither stopping rule is correct on its own, which is why the helper exists. while (nextCursor) never terminates on the endpoints that return a non-null cursor on their last page: the loop asks for the page after the last one, gets an empty page carrying the same cursor, and spins for ever without erroring. And if (!page.hasMore) return stops after ONE page on /v1/domains, /v1/webhooks, /v1/api-keys, /v1/billing/invoices and /v1/billing/history, because those send no hasMore at all — the read is undefined, which is falsy, which looks exactly like “that was everything”. autoPaginate consults both signals and treats each as authoritative only where the server actually sends it. The full explanation →
Verifying webhooks
verifyWebhook is the strongest reason to use the SDK. It parses the signature header by key rather than positionally (v1= exists so a v2= can be added beside it), compares in constant time, and rejects a timestamp more than 300 seconds old or more than 300 seconds in the future — a future timestamp is not clock skew to be generous about, it is an attacker buying an unlimited replay window.
import express from 'express'
import { verifyWebhook, DELIVERY_ID_HEADER } from '@posthaste/sdk'
const app = express()
app.post(
'/hooks/posthaste',
// THE RAW BYTES. express.json() would hand you an object, and the signature
// covers the bytes we sent — not the object they decode to.
express.raw({ type: 'application/json' }),
(req, res) => {
const result = verifyWebhook(
req.body, // a Buffer
req.get('posthaste-signature'),
process.env.POSTHASTE_WEBHOOK_SECRET,
)
if (!result.valid) {
// 'malformed_header' | 'unsupported_version' | 'timestamp_too_old'
// | 'timestamp_in_future' | 'signature_mismatch'
console.warn('rejected webhook', result.reason)
return res.sendStatus(400)
}
const event = JSON.parse(req.body.toString('utf8'))
// Deduplicate on the delivery id — a retry is not a second event.
enqueue(req.get(DELIVERY_ID_HEADER), event)
// Acknowledge fast; do the work elsewhere. We time out after 10 seconds.
return res.sendStatus(204)
},
)Verify over the raw bytes. A body that has been parsed and re-serialised will never verify — JSON.parse followed by JSON.stringify is not a byte-level round trip, and the signature covers the bytes we sent, not the object they decode to. This is the single most common reason verification “mysteriously” fails, and no amount of correct key handling rescues it.
parseWebhookEvent verifies and JSON-parses in one step, returning null on any failure, for the common case where a bad delivery just gets a 400. The signing secret is the one returned once when the endpoint was created — webhooks in full →
Types
import type {
SendEmailParams,
SendEmailResult,
Message,
MessageStatus, // the seven statuses
EventType, // the ten event types
SuppressionReason,
WebhookEvent,
PosthasteErrorType,
} from '@posthaste/sdk'
// Ids returned by the API are typed by prefix…
const id: `msg_${string}` = result.id
// …and every method ACCEPTS a plain string, so an id from your own
// database needs no cast.
await posthaste.messages.get(row.posthaste_message_id)Every request and response shape is exported, along with the message statuses, the ten event types, the suppression reasons, the scopes and the error type union. That union is widened with (string & {}), so a switch stays useful and a refusal reason added after your SDK version still type-checks.
Every method
account.me() GET /v1/me
account.verify() GET /v1/account/verify
account.usage() GET /v1/usage
domains.create({ name }) POST /v1/domains
domains.list(params) GET /v1/domains
domains.autoPaginate(params) GET /v1/domains (all pages)
domains.listAll(params, maxItems) GET /v1/domains (all pages)
domains.verify(id) POST /v1/domains/:id/verify
domains.delete(id) DELETE /v1/domains/:id
domains.setup(id) GET /v1/domains/:id/setup
domains.connectCloudflare(id, { token }) POST /v1/domains/:id/cloudflare
domains.disconnectCloudflare() DELETE /v1/account/cloudflare
emails.send(params) POST /v1/emails
messages.list(params) GET /v1/messages
messages.autoPaginate(params) GET /v1/messages (all pages)
messages.listAll(params, maxItems) GET /v1/messages (all pages)
messages.get(id) GET /v1/messages/:id
messages.stats(params) GET /v1/stats/messages
suppressions.list(params) GET /v1/suppressions
suppressions.autoPaginate(params) GET /v1/suppressions (all pages)
suppressions.listAll(params, maxItems) GET /v1/suppressions (all pages)
suppressions.create({ address, reason }) POST /v1/suppressions
suppressions.delete(address) DELETE /v1/suppressions/:address
webhooks.create({ url, eventTypes }) POST /v1/webhooks
webhooks.list(params) GET /v1/webhooks
webhooks.autoPaginate(params) GET /v1/webhooks (all pages)
webhooks.listAll(params, maxItems) GET /v1/webhooks (all pages)
webhooks.delete(id) DELETE /v1/webhooks/:id
apiKeys.list(params) GET /v1/api-keys
apiKeys.autoPaginate(params) GET /v1/api-keys (all pages)
apiKeys.listAll(params, maxItems) GET /v1/api-keys (all pages)
billing.get() GET /v1/billing
billing.history(params) GET /v1/billing/history
billing.autoPaginateHistory(params) GET /v1/billing/history (all pages)
billing.listAllHistory(params, maxItems) GET /v1/billing/history (all pages)
billing.invoices(params) GET /v1/billing/invoices
billing.autoPaginateInvoices(params) GET /v1/billing/invoices (all pages)
billing.listAllInvoices(params, maxItems) GET /v1/billing/invoices (all pages)
billing.invoice(id) GET /v1/billing/invoices/:idEvery list appears three times, because list returns one page and the other two return all of them. maxItems on listAll defaults to 1,000. The two on billing are named for their list rather than for the resource — listAllInvoices and listAllHistory — since one resource owns two.
Deliberately absent: creating and revoking API keys, and everything else that requires a signed-in person rather than a key — checkout, plan changes, profile edits. Those endpoints refuse a bearer token outright, and a method that can only ever return 403 is worse than no method. The platform operator API under /admin/ is not part of the public surface at all.
NextPython SDK →The official client for Python: no third-party dependencies, typed exceptions, pagination that terminates, and webhook verification over raw bytes.