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_ or ph_test_ 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.
case 'rate_limited':
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. Three different refusals arrive as 429: rate_limited is transient and measured in seconds, while daily_limit_reached and monthly_limit_reached are exhausted quota with a Retry-After measured in hours or days. The SDK exposes err.isQuotaExhausted for exactly that fork, and 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
// 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)autoPaginate loops on hasMore, which is the only condition that terminates. A hand-written while (nextCursor) loop does not: some endpoints return a non-null cursor on their last page, so the loop asks for the page after the last one, gets an empty page carrying the same cursor, and spins for ever without erroring. 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() GET /v1/domains
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() GET /v1/webhooks
webhooks.delete(id) DELETE /v1/webhooks/:id
apiKeys.list() GET /v1/api-keys
billing.get() GET /v1/billing
billing.history() GET /v1/billing/history
billing.invoices() GET /v1/billing/invoices
billing.invoice(id) GET /v1/billing/invoices/:idDeliberately 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.
NextConventions →Base URL, identifier format, timestamps, and the shape every response takes.