Skip to content
PostMTA Hosted
StatusSign inOpen panelRequest access
Open menu

Docs

Open in panel → /app · sending

HTTP send API

POST /v1/send accepts an API key in Authorization: Bearer <api_key> (or X-Api-Key: <api_key>) and runs the same runSend pipeline as the panel.

Request

curl -X POST https://<appliance>/v1/send \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer pmh_<api_key>' \
  -d '{
    "from": "ops@verified.example.com",
    "to": ["alice@example.com", "ops@example.com"],
    "cc": ["audit@example.com"],
    "subject": "Hello",
    "text": "Hi Alice,\n\nSent via PostMTA Hosted.\n",
    "idempotency_key": "2026-07-12-roll-123"
  }'
Field Required Notes
from yes email; domain must be verified or active when REQUIRE_VERIFIED_DOMAIN (default on in production)
to yes string or string[]
cc, bcc no string[]
subject yes min 1 char
text yes* one of text / html required
html yes* one of text / html required
idempotency_key no recommended 8–128 chars; unique per workspace; replay returns 409

Responses

Code Error code When
200 Kumo 2xx; credits committed
400 no recipients to empty after trim/dedupe
400 DOMAIN_UNVERIFIED from is malformed (no @)
401 missing / bad API key
402 INSUFFICIENT wallet cannot cover recipient count
403 DOMAIN_UNVERIFIED production + REQUIRE_VERIFIED_DOMAIN and From domain not verified
409 DUPLICATE same idempotency_key was already accepted/rejected
422 (verify only) domain verify failure — {checks, missing[]}
429 token-bucket rate limit hit (60/min per IP for /v1/send)

Idempotency contract

Per docs/credits.md, the idempotency key is unique per workspace on credit_ledger. Replay sequence:

  1. hold with the same key → 409 DUPLICATE.
  2. hold then commit → future replays return 200 + the cached credit disposition (we don’t re-inject if commit already happened).
  3. hold then void → credit released; future replays fresh-hold.

Always supply idempotency_key if you retry on transient network errors.

Verified From admission

When REQUIRE_VERIFIED_DOMAIN=1 (production default) and the From domain is not in domains.status IN ('verified','active') for the caller workspace, the route returns:

{ "error": "from domain is not verified", "code": "DOMAIN_UNVERIFIED", "domain": "<...>" }

Override: REQUIRE_VERIFIED_DOMAIN=0 is the emergency opt-out only — never persist it in .env.production.example.

Rate limit

Token-bucket: 60 requests/min per IP for /v1/send. The panel /v1/panel/send shares the same limiter scoped to JWT/workspace.

Last verified: Sat Jul 11 2026 17:00:00 GMT-0700 (Pacific Daylight Time). Cross-link maintained byscripts/check-docs-drift.sh (M54).

← All docs