Getting started
Authentication
Every request carries an API key as a bearer token. Keys are shown once when created and stored only as an HMAC under a server-side pepper — if you lose one, nobody can recover it, including us.
Getting a key
Keys are created by a signed-in person, in the dashboard. There is no endpoint that mints a key from an API key, deliberately: a server-side credential that can issue more credentials is a narrow key one request away from a full one, which is the opposite of what scoping is for.
- Create an account or sign in.
- Confirm your email address. This is enforced at exactly one place — key creation — and it is the gate on sending, because no key means no mail. An unconfirmed address gets
403 email_unverified. - Open Settings → API keys as an owner or admin. A member cannot create or revoke keys.
- Choose the scopes it needs, and copy the token immediately. The response carries it once and no endpoint will ever show it again.
Grant the narrowest set of scopes that works. A key for a cron job that sends receipts needs emails:send and nothing else. If that key leaks, the damage is bounded by what it was allowed to do — which is the only protection that still applies after the secret is out.
Using a key
import { Posthaste } from '@posthaste/sdk'
// Read the key from the environment. Never commit one, and never ship one to a
// browser — a key is a server-side credential and carries no user identity.
const posthaste = new Posthaste({ apiKey: process.env.POSTHASTE_KEY })
// Optional, all with sensible defaults:
new Posthaste({
apiKey: process.env.POSTHASTE_KEY,
baseUrl: 'https://api.posthastemail.dev', // the default
timeoutMs: 30_000, // per attempt
maxRetries: 2, // 429 and 5xx, with backoff
fetch: myProxyAwareFetch, // anything fetch-shaped
})The TypeScript SDK sets the header for you and refuses to let a custom header replace it. In any other language, set it yourself — it is one header.
The scheme name is matched case-insensitively and surrounding whitespace is tolerated, so bearer works as well as Bearer. Anything else — a bare token, a query parameter, basic auth — is rejected.
The key id is the first twelve characters after the prefix and is stored in clear, so authentication is one indexed lookup rather than a scan. It is also what the request rate limiter counts against, which is why an unparseable key falls back to being limited by IP.
There is no test environment
Every key looks like ph_live_, and there is no second kind. Posthaste has no sandbox: any valid key sends real mail through the real mail servers, from the same IP, against the same sending reputation, and against the same monthly allowance.
To stage safely, control the recipients — not the key. Point your staging deployment at a domain you own and send only to addresses you can read. An address you do not control may be a spam trap, and mail to one costs sending reputation that is slow and expensive to rebuild.
Scopes
A key grants only what it lists. A missing scope is denied, so an endpoint added later is closed to your existing keys rather than silently reachable by all of them. These nine are the complete set that can be granted:
| Scope | Allows |
|---|---|
| account:read | Read the account, its plan and its usage: /v1/me, /v1/usage, /v1/account/verify, /v1/api-keys. Also satisfies the billing reads. |
| domains:read | List sending domains and read their setup guidance. It also still lists inbound addresses, which is where that surface started — use inbound:read for new keys. |
| domains:write | Add, verify and remove domains, and publish DNS through Cloudflare. It also still manages inbound addresses and deletes received mail, which inbound:write now covers on its own — use that for new keys. |
| emails:send | POST /v1/emails. Nothing else. |
| messages:read | Read sent messages, their waybills and daily statistics. It also still reads received mail; inbound:read is the scope for that now. |
| analytics:read | Read delivery rate and time-to-inbox percentiles grouped by receiving domain and by tag. Aggregates only — no recipient address, subject or body, which is why it is separate from messages:read. |
| suppressions:read | List the suppression list. |
| suppressions:write | Add and remove suppressions. |
| webhooks:read | List webhook endpoints. |
| webhooks:write | Create and delete webhook endpoints. |
| streams:read | List message streams. |
| streams:write | Create message streams. |
| templates:read | List templates, read their versions, and render a preview. |
| templates:write | Create templates, publish new versions, and delete them. |
| team:read | See who is on the account. Changing who is on it is not a scope at all — membership needs a signed-in owner or admin, because a leaked key that could invite an owner would be a full takeover. |
| alerts:read | Read alert rules and the history of what has fired. It returns the account’s complaint rate, bounce rate and quota position, which is a status page’s worth of access — hence its own scope rather than account:read. |
| alerts:write | Create, edit and delete alert rules. Deliberately not folded into domains:write: a key that can publish DNS records should not also be able to switch off the alert that says a DNS record has gone missing. |
| inbound:read | Read received mail and the addresses it arrives at, and see whether a domain’s MX actually points here. Prefer this over domains:read and messages:read, which the inbound routes shipped behind and still accept. |
| inbound:write | Claim and remove inbound addresses, mark received mail read, and delete it. Separate from domains:write because publishing DNS and reading somebody’s mail are not the same authority. |
| verifications:read | Read the status of a verification — approved, pending, how many attempts are left. Enough for a status board or a support tool, and it cannot send anything. |
| verifications:write | Start, check, resend and cancel verifications. This one mails a code from your verified domain, so it carries your brand — give it only to the service that runs your sign-in. |
| contacts:read | List and read contacts and contact lists, including each contact’s suppression status. |
| contacts:write | Add, import, update and delete contacts, and manage lists. Every address in the book is readable with this key, so keep it off anything that only sends. |
| broadcasts:read | List broadcasts and read their progress and delivery counts. |
| broadcasts:write | Create, send, pause and cancel broadcasts, and send tests to your team. This key can mail every contact on a list, so give it only to the service that runs your announcements. |
Asking for a scope outside this list returns 400 invalid_request naming the unknown value, rather than storing a string that would silently satisfy some future check.
billing:read is not in the list and cannot be granted to a key. It belongs to a signed-in session only. The billing reads accept account:read as well, so an API key reaches them that way; nothing that spends money is reachable with a key at all.
Listing your keys
/v1/api-keysaccount:readThe hundred most recently created keys, newest first, including revoked ones — “what did this key do” has to stay answerable after it stops working, which is exactly when somebody asks. No secret is returned, and none is possible: only an HMAC of it was ever stored.
curl https://api.posthastemail.dev/v1/api-keys \
-H "authorization: Bearer $POSTHASTE_KEY"
# 200
# {
# "data": [
# {
# "id": "key_Bh4Rm8Ub2Xc6Ye0Zf1Ag3B",
# "name": "production sender",
# "scopes": ["emails:send", "messages:read"],
# "hint": "ph_live_8Fk2…",
# "createdAt": "2026-08-01T09:12:44.108Z",
# "lastUsedAt": "2026-08-18T06:40:11.902Z",
# "revokedAt": null
# }
# ]
# }| Field | Notes |
|---|---|
| id | key_ id. Use it to revoke the key. |
| hint | Prefix plus the first four characters of the key id — enough to recognise which key this is, not enough to use it. |
| lastUsedAt | When it last authenticated a request. null means it has never been used, which is usually a key that can be deleted. |
| revokedAt | Non-null for a key that no longer authenticates. Revoked, never deleted. |
Creating and revoking keys (POST /v1/api-keys, DELETE /v1/api-keys/:id) require a dashboard session and refuse bearer keys with 403 forbidden.
When authentication fails
| Status | When |
|---|---|
| 401 unauthorized | Key missing, malformed, unknown, secret wrong, revoked, expired, or the account suspended. The response is identical in every case — distinguishing them would confirm that a key id is real. The reason is logged, never returned. |
| 403 forbidden | Authenticated, but the key lacks the scope the endpoint requires. The message names the scope. Not a retry — mint a key with the right scope. |
| 429 rate_limited | Too many requests for this key. See rate limits. |
NextTypeScript SDK →The official client for Node and TypeScript: install, send, paginate and verify webhooks.