PosthasteDocsGet an API key

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 --version

Node 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_AZLm3kQ8T2Sf9pXbNc7HrQ

Where 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.json

There 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 to chmod 600 when 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 authorization header 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-114

Every 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] — complaint

tail

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
CodeMeaning
0It worked.
1It did not, and nothing below fits.
2The command line was wrong: unknown flag, missing argument, bad value.
3No key, a revoked key, or a key missing the scope this command needs.
4Refused — a suppressed recipient, an unverified domain, content the pre-send lint blocked. Never worth repeating unchanged.
5Rate limited, or the daily or monthly allowance is spent.
6No such message, domain or account.
7The API was not reachable at all.
8tail <id>: the message settled without being delivered.
9tail <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.

NextConventionsBase URL, identifier format, timestamps, and the shape every response takes.