Get an API key

Getting started

API conventions

Everything the endpoints have in common: where to send a request, what an identifier looks like, and the four response shapes you will ever see.

Base URL

https://api.posthastemail.dev

HTTPS only. Every path in this reference is relative to it, and every path begins with /v1/. There is no other version; when there is, this one will keep working.

Request bodies are JSON and capped at 5 MB, which is also the effective ceiling on an email body. Anything larger is refused before it is buffered. Send content-type: application/json on anything with a body.

Response shapes

# A single object
GET /v1/messages/msg_AZLm3kQ8T2Sf9pXbNc7HrQ
200 { "id": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ", "status": "delivered", … }

# A short list — everything the account has, no cursor
GET /v1/domains
200 { "data": [ … ] }

# A paginated list — see /docs/pagination
GET /v1/messages?limit=25
200 { "data": [ … ], "hasMore": true, "nextCursor": "msg_…" }

# An error
POST /v1/emails
422 { "error": { "type": "domain_not_verified", "message": "…" } }

# Nothing to return
DELETE /v1/webhooks/whk_4Rm8Ub2Xc6Ye0Zf1Ag3Bhk
204

Every list is under data, without exception. That is worth relying on: it means one helper can read any list rather than one per endpoint. Lists that can grow without bound also carry hasMore and nextCursor — see pagination, and in particular which of the two to loop on.

Every error is under error, with a machine-readable type and a message written for a human reading a log at 2am. Some validation failures add a fields array; a few errors add their own numbers, such as limit and sentToday. See errors.

Identifiers

The API never exposes a bare UUID. Every id is a prefix, an underscore, and exactly 22 base64url characters — the raw 16 bytes of a UUIDv7, so ids also sort by creation time.

msg_AZLm3kQ8T2Sf9pXbNc7HrQ
└─┬┘ └──────────┬─────────┘
  │             └── 22 base64url characters: [A-Za-z0-9_-]
  └── what it is

Treat them as opaque strings of at most 30 characters and store them as text. An id of the wrong kind is rejected before any query runs, so passing a domain id where a message id belongs returns 404 not_found rather than reading the wrong row.

PrefixObject
acct_Account
usr_User
key_API key
dom_Sending domain — and inbound addresses, which have no prefix of their own
msg_Message, sent or received
evt_Message event, as delivered by a webhook
sup_Suppression list entry
whk_Webhook endpoint
dlv_Delivery job
sub_Subscription
pay_Payment
inv_Invoice

Inbound addresses carry a dom_ prefix, not one of their own. So DELETE /v1/inbound/addresses/dom_… is correct and not a typo. It is the one place the prefix does not name the thing, and passing an inbound address id to a domain endpoint will decode successfully and then find nothing.

Times, and the one time zone

Timestamps are ISO 8601 in UTC — 2026-08-18T06:40:11.902Z. Quotas and usage periods are also UTC: the daily sending cap resets at midnight UTC and the monthly allowance at the first of the month UTC, wherever you are.

The one exception is deliberate. GET /v1/stats/messages takes a tz parameter, because somebody in Sydney sending at 9am local is sending at 11pm UTC the previous day, and bucketing that under the wrong date makes every daily total wrong.

Methods and status codes

CodeMeaning here
200A read succeeded — or a send was a duplicate, which is also success.
201Something was created and is returned in the body.
202A message was accepted: durably stored and queued, not yet delivered.
204Deleted. No body.
400The request is malformed. Fix it; retrying will not help.
401 / 403Credential rejected, or the credential lacks the scope. See authentication.
404No such object — including one belonging to another account, which is invisible rather than forbidden.
409A conflict with something that already exists.
422The request is well-formed and we will not act on it. The distinction from 400 matters to a client deciding whether the fix is in the request or in the account.
429A limit. Read error.type to learn which — see rate limits and sending limits.
503/health/ready only, when the database is unreachable.

Calling it from a browser

You should not. An API key in a browser is a published API key. The dashboard reaches the same endpoints with a session cookie from an allowlisted origin; there is no wildcard CORS policy, and an origin that is not on the allowlist gets no access-control header at all.

Server-to-server callers are unaffected by any of this — CORS is a browser mechanism, and nothing else consults it.

NextSending domainsPublish one DKIM record, verify it, and read the per-record check results.