PosthasteDocsGet an API key

Sending

Broadcasts

One message to every contact on a list: an outage notice, a change to your terms or privacy policy, release notes, an account notice. It is for operational announcements to your own users. It is not a newsletter tool, and using it for marketing breaks the acceptable-use policy however genuine your list is.

Every copy is an ordinary send

A broadcast is never one message with a thousand recipients. It is expanded into one message per contact, and each of them goes through the same accept path as a single POST /v1/emails:

  • The suppression list — account-wide and the stream’s own. A suppressed contact is skipped and counted in progress.suppressed.
  • Your account’s daily warmup cap and monthly allowance. Every copy spends one of each; there is no separate price and no way round them.
  • The pre-send content lint, and the verified-domain check on the sender.
  • The delivery record: every copy has its own msg_ id, events and waybill, and appears in the message log.

Each copy carries a one-click List-Unsubscribe header and a visible unsubscribe link added to the body, both pointing at that one message. Pressing either suppresses the address on the broadcast’s stream.

Each contact is sent to exactly once. The copy and the broadcast’s progress are written in the same transaction, under a per-contact idempotency key, so a restart mid-send carries on after the last contact done and never mails anybody twice.

Never on the transactional stream

A broadcast goes out on the built-in announcements stream unless you name another stream you created. It can never use transactional: the API refuses with 422 transactional_stream, and the database refuses it too.

That is the point of the separation. Somebody who unsubscribes from your release notes, or reports one as spam, is suppressed on the announcements stream only — their password resets, receipts and sign-in codes keep arriving. A hard bounce is still account-wide, because a dead mailbox is dead on every stream.

Merge fields

Content is either a stored template (by slug or tpl_ id) or an inline subject with html and/or text — one or the other, never both. Tags are filled per contact from:

TagValue
{{email}}The contact’s address. Always present.
{{name}}The contact’s name, when it has one.
{{plan}}, {{region}}, …Any of the contact’s fields. A field called email or name is overridden by the real one.

A missing value skips that contact; it is never sent blank. With inline content every tag you use is required. With a template, its own declared variables decide what is required. A contact without a required value is not mailed and is counted in progress.failures as "missing_field:plan": 6, so you know which field to fill in — the rest of the list carries on.

A template is pinned to its latest published version when the send starts, so publishing an edit halfway through does not change what the second half of the list receives. Escaping follows the same rules as any template: values are escaped in HTML, not in the subject or the text.

What stops a broadcast

These exist because a broadcast shares a sending IP with every other account on it. They are tighter than anything on a single send.

SafeguardWhat happens
Daily capReaching today’s cap pauses the broadcast with daily_limit. It resumes on its own after midnight UTC, starting with the contact it stopped at.
Bounce rateOnce 200 of its messages have settled — delivered, bounced or complained — a broadcast with more than 5% bounced is stopped with high_bounce_rate.
Complaint rateAfter the same 200, more than 0.1% complaints — and at least two, because one in the first two hundred is a person rather than a pattern — stops it with high_complaint_rate.
First large broadcastAn account’s first broadcast to more than 1,000 people is reviewed by Posthaste staff before it starts. It waits as scheduled with "reviewState": "pending". Once one is approved the account is trusted with its own list; a rejected one is canceled without sending anything.

A quality stop cannot be resumed. The rates are the ones every account on the IP is judged by, so /resume refuses with 409 quality_pause. Cancel, remove the addresses that bounced, and send a new broadcast — or contact support.

Create a draft

POST/v1/broadcastsbroadcasts:write
FieldTypeNotes
namestring required1–120 characters. A label for you; recipients never see it.
liststring requiredA lst_ id.
fromstring requiredAn address, optionally with a display name, on a verified domain — checked when you send.
replyTostring optionalAn email address.
streamstring optionalDefaults to announcements. Never transactional.
templatestring optionalA template slug or tpl_ id. Give this, or the three below.
subject, html, textstring optionalInline content: a subject, and html, text or both.
// A draft. Nothing is sent until send().
const draft = await posthaste.broadcasts.create({
  name: 'Terms of service update',
  list: listId,
  from: 'Acme <[email protected]>',
  template: 'terms-update', // a stored template, by slug or tpl_ id
})

// Your own team only, up to five. Not auto-retried: each call mails again.
const { data } = await posthaste.broadcasts.test(draft.id, ['[email protected]'])

await posthaste.broadcasts.send(draft.id) // or { scheduledAt: '2026-09-25T09:00:00Z' }

// Later: progress is a row read, delivery is counted from the messages.
const b = await posthaste.broadcasts.get(draft.id)
b.status // 'sending'
b.progress // { targeted, accepted, suppressed, failed, failures }
b.delivery // { inFlight, delivered, bounced, complained, failed, unsubscribed }

An account may hold 50 unsent drafts; a 51st is 409 too_many_drafts.

Test, then send

POST/v1/broadcasts/:id/testbroadcasts:write

Sends the broadcast to up to five addresses, and every one of them must belong to somebody on your account’s team — anyone else is 422 not_a_team_member, so this cannot be used to mail strangers. The subject is prefixed [Test], merge fields come from the contact with that address or else from the first person on the list, and a test is not counted in the broadcast’s progress. It is a real send through the same checks, reported per address.

POST/v1/broadcasts/:id/sendbroadcasts:write

Starts it now, or at scheduledAt (ISO-8601, up to 30 days ahead). Everything that would make the whole send fail is checked here, while you are looking, rather than discovered halfway through.

# Try it on your own team first — up to five people, all on this account
curl -X POST https://api.posthastemail.dev/v1/broadcasts/brd_Vn4Ks8Qp2Rt6Wx0Yz3Ab5C/test \
  -H "authorization: Bearer $POSTHASTE_KEY" \
  -H "content-type: application/json" \
  -d '{"to": ["[email protected]"]}'

# 200 {"data":[{"to":"[email protected]","id":"msg_7Qw2Er4Ty6Ui8Op0As1Df3"}]}

# Then send it — now, or at a time up to 30 days ahead
curl -X POST https://api.posthastemail.dev/v1/broadcasts/brd_Vn4Ks8Qp2Rt6Wx0Yz3Ab5C/send \
  -H "authorization: Bearer $POSTHASTE_KEY" \
  -H "content-type: application/json" \
  -d '{"scheduledAt": "2026-09-25T09:00:00Z"}'

# 202 { "id": "brd_…", "status": "scheduled", "reviewState": "not_required", … }
StatusWhen
202The broadcast, now scheduled. reviewState is pending if it is waiting for review.
409 not_a_draftIt has already been sent, scheduled or canceled.
422 list_emptyThe list has nobody on it.
422 list_missingThe list was deleted.
422 domain_not_verifiedThe sender’s domain is not verified on this account.
422 template_unpublishedThe template has no published version.
422 schedule_too_farscheduledAt is more than 30 days away.

States, pausing and cancelling

statusMeaning
draftEditable and deletable. Nothing has been sent.
scheduledAccepted by /send and waiting for its time — or, when review is pending, for a person at Posthaste.
sendingBeing expanded, one recipient at a time.
pausedStopped part-way. pauseReason says why, and whether you can resume it.
sentEvery contact on the list has been processed.
canceledStopped for good. Mail already accepted stays sent.
POST/v1/broadcasts/:id/pausebroadcasts:write
POST/v1/broadcasts/:id/resumebroadcasts:write
POST/v1/broadcasts/:id/cancelbroadcasts:write

Pausing takes effect within one recipient, not one batch: every copy re-reads the broadcast under a lock before it is written. Cancelling stops everything still to come; mail already accepted stays sent.

pauseReasonCauseWhat to do
paused_by_userYou pressed pause.Resume when ready.
daily_limitYour account reached today’s warmup cap.Resumes on its own after midnight UTC. Nothing to do.
monthly_limitYour plan’s monthly allowance is spent.Upgrade, then resume.
domain_not_verifiedThe sending domain stopped being verified.Fix the DNS record, then resume.
content_blockedThe pre-send lint refused the content.Cancel, fix the content, send a new one.
template_unpublishedThe template has no published version.Publish it, then resume.
account_suspendedThe account is suspended.Contact support.
stream_missingThe stream no longer exists.Cancel and send on another stream.
high_bounce_rateMore than 5% of settled messages bounced.Cannot be resumed. Cancel, clean the list, send again — or contact support.
high_complaint_rateMore than 0.1% of settled messages drew a complaint.Cannot be resumed. Cancel, and look hard at who is on the list.

A broadcast whose list is deleted before it finishes is canceled with list_deleted; one rejected in review is canceled with rejected_in_review.

Progress and delivery

GET/v1/broadcastsbroadcasts:read
GET/v1/broadcasts/:idbroadcasts:read

The list is newest first, keyset-paginated with limit (1–100, default 25) and before. One broadcast adds delivery, counted from the messages themselves, so it is the same answer the message log gives.

GET /v1/broadcasts/brd_Vn4Ks8Qp2Rt6Wx0Yz3Ab5C

200 {
  "id": "brd_Vn4Ks8Qp2Rt6Wx0Yz3Ab5C",
  "status": "paused",
  "pauseReason": "daily_limit",
  "reviewState": "not_required",
  "startedAt": "2026-09-25T09:00:04.118Z",
  "progress": {
    "targeted": 1840,
    "accepted": 1203,
    "suppressed": 31,
    "failed": 6,
    "failures": { "missing_field:plan": 6 }
  },
  "delivery": {
    "inFlight": 12,
    "delivered": 1180,
    "bounced": 9,
    "complained": 0,
    "failed": 2,
    "unsubscribed": 4
  },
  …
}
FieldMeaning
progress.targetedContacts on the list when the send started.
progress.acceptedCopies accepted for delivery.
progress.suppressedContacts skipped because the address is suppressed.
progress.failed / failuresContacts not mailed, with the reasons as { "reason": count }.
delivery.unsubscribedRecipients who unsubscribed through this broadcast.

Change or delete a draft

PATCH/v1/broadcasts/:idbroadcasts:write
DELETE/v1/broadcasts/:idbroadcasts:write

Only a draft can be edited; anything else is 409 not_a_draft. Switching between a template and inline content clears the other side. A draft can be deleted, and so can a canceled broadcast that sent nothing; one that sent mail is 409 broadcast_has_history, because its record is what was sent.

A key with broadcasts:write can mail everybody on a list. Give it only to the service that runs your announcements — see authentication.

NextTags and metadata →Label a message with your own vocabulary, get it back on the log and every webhook, and filter by it.