Get an API key

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.

  1. Create an account or sign in.
  2. 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.
  3. Open Settings → API keys as an owner or admin. A member cannot create or revoke keys.
  4. 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.

Live and test keys

A key carries an environment in its prefix: ph_live_ or ph_test_. The prefix must match the key that was created — presenting a live key’s secret under a ph_test_ prefix fails authentication even though the secret is correct.

A test key is a label, not a sandbox. Any valid key sends real mail through the real mail servers. Nothing in the send path branches on the environment: the same domain check, the same quota, the same SMTP conversation with the recipient’s server, the same charge against your monthly allowance.

The prefix exists so you can tell two keys apart in a log or a secret store — nothing more. If you need a staging environment that cannot reach your customers, point it at a domain you own and send only to addresses you control. Do not rely on the prefix.

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:

ScopeAllows
account:readRead the account, its plan and its usage: /v1/me, /v1/usage, /v1/account/verify, /v1/api-keys. Also satisfies the billing reads.
domains:readList sending domains, read their setup guidance, and list inbound addresses.
domains:writeAdd, verify and remove domains; publish DNS through Cloudflare; create and remove inbound addresses; delete received mail.
emails:sendPOST /v1/emails. Nothing else.
messages:readRead sent messages, their waybills and daily statistics, and read received mail.
suppressions:readList the suppression list.
suppressions:writeAdd and remove suppressions.
webhooks:readList webhook endpoints.
webhooks:writeCreate and delete webhook endpoints.

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

GET/v1/api-keysaccount:read

The 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",
#       "environment": "live",
#       "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
#     }
#   ]
# }
FieldNotes
idkey_ id. Use it to revoke the key.
hintPrefix plus the first four characters of the key id — enough to recognise which key this is, not enough to use it.
lastUsedAtWhen it last authenticated a request. null means it has never been used, which is usually a key that can be deleted.
revokedAtNon-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

StatusWhen
401 unauthorizedKey missing, malformed, unknown, secret wrong, environment mismatched, 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 forbiddenAuthenticated, 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_limitedToo many requests for this key. See rate limits.

NextTypeScript SDKThe official client for Node and TypeScript: install, send, paginate and verify webhooks.