Getting started
Command line
Verify a domain, send a message, and watch it to delivery — without opening a browser and without writing a script. @posthaste/cli is a shell over the TypeScript SDK, so it retries the same way, fails the same way, and refuses the same things.
Install
npm i -g @posthaste/cli
export POSTHASTE_API_KEY=ph_live_…
posthaste --versionNode 22 or newer. One dependency — the SDK — and no argument parser, colour library or table formatter, because a tool you install globally should not bring a dependency tree with it.
The whole thing, end to end
Four commands take a domain nobody has heard of to a message you watched arrive. Nothing below needs the dashboard.
# 1. Add the domain. Prints the DNS records to publish.
posthaste domains add acme.com
# 2. Publish them, wait a few minutes, then check.
posthaste domains verify acme.com
# 3. Send.
posthaste send \
--from 'Acme <[email protected]>' \
--to [email protected] \
--subject 'Invoice 2026-114' \
--text 'Your invoice is attached.' \
--attach ./invoice.pdf \
--idempotency-key invoice-2026-114
# 4. Watch it land.
posthaste tail msg_AZLm3kQ8T2Sf9pXbNc7HrQWhere the key comes from
The environment, or a config file you own. The environment wins. $XDG_CONFIG_HOME is honoured, and --config or POSTHASTE_CONFIG points somewhere else.
# This wins over the config file.
export POSTHASTE_API_KEY=ph_live_…
# Self-hosting? Point it at your own deployment.
export POSTHASTE_BASE_URL=https://mail.internal.example.com
# Or at one file, for one command.
posthaste tail --config ./staging.jsonThere is no --api-key flag, and there will not be one. A key on the command line is visible to every process on the machine through ps, it is written verbatim into your shell history, and CI captures it into logs that outlive the key. The environment and a mode-600 file are the two ways in.
Two rules follow from the same reasoning, and both are enforced in the code:
- Nothing the CLI does writes a key to disk. There is no
posthaste login. If the config file exists, you put it there and you own its permissions — and the CLI checks them, and tells you tochmod 600when anybody else on the machine can read it. - No output ever contains a key. Every byte written passes a redactor that removes the key it holds, any
authorizationheader or bearer token, and anything shaped like a Posthaste key — including one the server echoed back. It is applied at the output sink rather than at each call site, so it cannot be forgotten in the one error handler that mattered.
The base URL is not a secret, so it is a flag: point --base-url at your own deployment and everything works the same.
send
# The obvious one.
posthaste send --from [email protected] --to [email protected] \
--subject 'Reset your password' --text 'Here is your link.'
# A body from a file, or from a pipe.
posthaste send … --html-file ./welcome.html
render-invoice 2026-114 | posthaste send … --text -
# Stored content, by name.
posthaste send … --template invoice-issued \
--var name=Ada --var amount='£42.00'
# Your own vocabulary, back on the log and on every webhook.
posthaste send … --tag invoice --metadata orderId=2026-114Every field on POST /v1/emails has a flag. --to, --cc, --bcc, --tag, --metadata, --header, --var and --attach repeat; --text and --html read stdin when given -.
A suppressed recipient is printed to stderr, in every output mode. A suppressed address is never sent to and never charged. When only some of a fan-out were skipped the rest still go, so the only thing between that and a silent non-delivery is this line — which is why it does not go to stdout, where --json | jq would swallow it.
$ posthaste send --from [email protected] \
--to [email protected] --to [email protected] --subject Hi --text Hello
queued msg_AZLm3kQ8T2Sf9pXbNc7HrQ
from [email protected]
to [email protected] queued
to [email protected] suppressed
# …and on stderr, in every output mode:
Not sent to 1 suppressed recipient (never sent to, never charged):
[email protected] — complainttail
The CLI polls, and does not pretend otherwise. tail asks GET /v1/messages on a bounded interval and prints what changed. --interval defaults to five seconds and will not go below two; the floor is enforced locally, because a bound that only exists on the far side of the network is not a bound for a polling loop.
Nothing is missed. A status the poll stepped over is still in the message’s waybill, which is append-only, so following one message sees every event whatever the interval. What is approximate is the timing.
There is now a streaming endpoint — the live tail — and the CLI does not use it yet. For events the instant they happen, read that stream directly, or use a webhook.
# The whole log, as it moves.
posthaste tail
# Only what went wrong, on one stream.
posthaste tail --status bounced --stream product-updates
# One message, until it settles.
posthaste tail msg_AZLm3kQ8T2Sf9pXbNc7HrQ --timeout 120
# Machine-readable: one complete JSON value per line.
posthaste tail --json | jq 'select(.status == "bounced") | .to'Exit codes, and using it in a script
Following one message is built to be a gate: it exits 0 on delivery, 8 when the message settles any other way, and 9 when --timeout runs out with the message still in flight.
#!/usr/bin/env bash
set -euo pipefail
id=$(posthaste send \
--from [email protected] --to [email protected] \
--subject 'Invoice 2026-114' --text 'Attached.' \
--idempotency-key invoice-2026-114 --json | jq -r .id)
if posthaste tail "$id" --timeout 120; then
echo "delivered"
else
case $? in
8) echo "settled without delivering — see the waybill above" ;;
9) echo "still in flight after two minutes" ;;
*) echo "the command itself failed" ;;
esac
fi| Code | Meaning |
|---|---|
0 | It worked. |
1 | It did not, and nothing below fits. |
2 | The command line was wrong: unknown flag, missing argument, bad value. |
3 | No key, a revoked key, or a key missing the scope this command needs. |
4 | Refused — a suppressed recipient, an unverified domain, content the pre-send lint blocked. Never worth repeating unchanged. |
5 | Rate limited, or the daily or monthly allowance is spent. |
6 | No such message, domain or account. |
7 | The API was not reachable at all. |
8 | tail <id>: the message settled without being delivered. |
9 | tail <id>: --timeout ran out, message still in flight. |
Human-readable output by default; --json gives one complete JSON value per line, so an open-ended tail stays parseable. Warnings and errors always go to stderr, in both modes.
An ordinary refusal is never a stack trace. A 401, a suppressed recipient and an unverified domain are all anticipated, and each gets a sentence saying what happened and what to do next. A stack is available behind POSTHASTE_DEBUG=1 — and it goes through the redactor too.
domains
verify takes the domain name or its dom_… id. Only DKIM has to pass; a failing SPF or DMARC check is reported and does not stop the domain being usable. When it is not verified, the records are printed again — so you never have to go back to a browser for a string you were given once.
$ posthaste domains verify acme.com
acme.com not verified
dkim not found posthaste._domainkey.acme.com
No TXT record at that name.
spf (optional) pass acme.com
Publish these and try again:
TXT posthaste._domainkey.acme.com
v=DKIM1; k=rsa; p=MIIBIjAN…
required — Proves the mail was signed by us.DNS takes time. Publishing a record and verifying ten seconds later usually fails; wait a few minutes and run it again. See verify a domain, step by step for the two mistakes that get people stuck.
NextConventions →Base URL, identifier format, timestamps, and the shape every response takes.