Postrun
Transactional and marketing email, with a delivery log you can actually debug.
Start here:
- Quickstart — your first email in about two minutes.
- The sandbox sender — send before you own a domain, with no DNS change and nobody else’s permission.
- Migrating — moving from another provider. Read step 2 before your first send; it is the step everybody skips and the one that costs.
- API reference — endpoints, errors, and how to verify a webhook.
What is different here
A domain whose DNS lives with us verifies with a button. No DKIM records to paste into somebody else’s control panel, and nothing to re-paste on the next key rotation. Point a sending domain’s nameservers at us once and that step never comes back.
The log is the product. Every message carries its full timeline — accepted, sent, delivered, opened, bounced — including the receiving server’s own diagnostic text. That is what answers “why did this not arrive”, and it is the thing you will actually use.
Any language
The API is plain JSON over HTTPS with one header of authentication, and the reference is complete. If your framework already speaks SMTP, that works too with nothing to install.
Quickstart
Send your first email in about two minutes — or run your first API call right now, with no domain at all.
1. Add a domain
You can only send from a domain you own. Go to Domains → Add a domain and type it in.
What happens next depends on where the domain’s DNS lives, and this is the part that is different here:
| Your situation | What you do |
|---|---|
| The domain is registered with us | Nothing. We publish every record it needs. There is no table to copy and nothing to mistype. |
| You have a domain used only for sending | Point its nameservers at us at your registrar. After that it behaves like the first row — forever. Key rotation, tracking domains and DMARC policy will never need anything from you again. |
| You want to use your main domain | Copy three DKIM records by hand — plus one MX if you switch on receiving — the way every other provider works. |
If you point a domain’s nameservers at us, we take over all of its DNS. Use a domain that is not used for anything else — one with a website or mail on it will break the moment the change propagates. We check before you switch and tell you if the domain looks like it is in use, but the check is not infallible.
A separate sending domain is the recommended practice anyway: it keeps transactional reputation off the domain your team emails from, so a bad bounce week does not touch their inbox placement.
Wait for the domain to show verified. Press Check now if you are impatient.
2. Get an API key
You already have one. The first time you open the dashboard, Get started
shows a curl with a key named Default already in it — sending access, not
tied to any domain. Copy it from there.
To make another — for a second application, or because you did not copy the first — go to API keys → Create a key and give it a name you will recognise in six months.
Two permissions:
- Sending access — can send, nothing else. Use this in your application. You can also tie it to one domain.
- Full access — can also read the message log and manage contacts.
Neither can add a domain or a key: those live in the dashboard, behind a signed-in session. See the API.
The key is shown once. We store a hash, so there is no way to show it again. Copy it now; if you lose it, create another and revoke the old one.
3. Send
Pick the one you are actually using:
- HTTP —
curl, and any language, since the API is plain JSON over HTTPS - SMTP — if your framework already speaks it, there is nothing to install
In a hurry, with no DNS access?
You can send before step 1. Every new account can send from an address we own, to four test addresses that reach Amazon’s mailbox simulator, with no domain and no DNS — see the sandbox sender. It exists precisely because “verify a domain” is a dependency that often belongs to another department.
4. Watch it arrive
Open Emails. Every message is there with its full delivery timeline — accepted, delivered, opened, bounced — including the receiving server’s own diagnostic text. That log is the thing you will actually use, because it is what answers “why did this not arrive”.
What to do next
Add a webhook so your application learns about bounces without polling. Every delivery is signed; verify it rather than trusting the source address.
Import your suppression list if you are moving from another provider. Do this before your first send — see migrating. It is the single most important step and the easiest one to skip.
Set an idempotency key on anything you might retry:
Idempotency-Key: order-4821
A repeat with the same key returns the original message instead of sending twice. Without one, a timeout leaves you unable to tell “not sent” from “sent, response lost” — and both retrying and not retrying are wrong.
Quickstart: curl
curl https://api.postrun.dev/emails \
-H "Authorization: Bearer $POSTRUN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@yourdomain.com>",
"to": "someone@example.com",
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>"
}'
{ "id": "1042", "status": "queued" }
Then watch it arrive in the dashboard, where every message carries its full delivery timeline.
Every endpoint works this way: plain JSON over HTTPS, one header of authentication, and the reference is complete.
Retry safely
curl https://api.postrun.dev/emails \
-H "Authorization: Bearer $POSTRUN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821" \
-d '{ "from": "Acme <hello@yourdomain.com>", "to": "someone@example.com",
"subject": "Your receipt", "html": "<p>Thanks.</p>" }'
Read the log
curl "https://api.postrun.dev/emails?status=bounced&limit=25" \
-H "Authorization: Bearer $POSTRUN_API_KEY"
Reading needs a full-access key; a sending key answers
403 key_not_permitted.
Quickstart: SMTP
If your framework already speaks SMTP, there is nothing to install:
| Setting | Value |
|---|---|
| Host | smtp.postrun.dev |
| Port | 465 (TLS), or 587 (STARTTLS) |
| Username | postrun — the same for everyone |
| Password | your API key |
It is the same service, not a bridge
A message submitted over SMTP lands in the same queue, gets the same delivery
timeline, fires the same webhooks and counts against the same quota as one sent
over the API. The only thing you give up is the id in a response — SMTP has
nowhere to put one — so if you need to join a send back to your own record,
find it in the log by recipient and subject, or send that one over the API.
Everything the API enforces is enforced here too: an unverified domain, a
suppressed recipient or a spent quota is refused at RCPT or at the final dot
with a 550, carrying the same reason the API would have given.
Authentication
The username is the literal string postrun for every account — it is not
your email address and not your key’s prefix. Your API key is the password, and
which account you are is decided entirely by that key.
A sending_access key is the right one to put here. That is what it is for.
Ports
465 is implicit TLS and 587 is STARTTLS; both are the standard submission
ports and either is fine.
We never accept submission in the clear. A connection that will not negotiate TLS is refused rather than downgraded.
The sandbox sender
You can send your first email before you own a domain, before any DNS change, and before anybody in another department answers a ticket.
Every new account may send from an address we own and have already verified:
onboarding@<our sandbox domain>
The dashboard’s checklist shows the exact address for this deployment — do not compose it yourself, because it is configuration and it differs between deployments. An account with no sandbox configured sees the field empty, which is the signal that this feature is off here.
Two rules, and they are the whole thing
The From is ours. You are not claiming an identity, you are borrowing
ours — which is exactly why no verification step is needed.
The To can only be one of four test addresses, listed below. They reach
Amazon’s mailbox simulator, so a sandbox message is never delivered to anybody —
not to a stranger, and not to you either.
That sounds like a limitation and is mostly a favour. A shared sender has no sending reputation of its own, so mail from it is the mail most likely to be filtered — and a message you had to dig out of a spam folder teaches you nothing about what your own domain will do. Sending to the simulator gives you the part that is actually informative, immediately and identically for everybody: a real message, through the real provider, with real events coming back.
The four addresses
| Address | What happens |
|---|---|
delivered@ | Delivers. The default the dashboard’s snippet uses. |
bounced@ | Comes back as a hard bounce, with a real bounce event, Amazon’s own classification and its diagnostic text. |
complained@ | Comes back as a spam complaint. |
suppressed@ | Lands on your suppression list, so you can watch the next send to it be refused before it costs anything. |
All four are on the sandbox domain — bounced@<our sandbox domain>, not
bounced@ anywhere else.
This is how you build and test bounce handling without needing an address that really bounces. Nothing here reaches an inbox, so none of it touches your sending reputation.
There are ten sandbox sends per account, for its lifetime. It is not a reputation guard — nothing can be complained about — it bounds a shared sender being used as a permanent free one, and a script left running against it. The dashboard shows what is left; past that, the answer is to connect a domain.
Watch the timeline, not your inbox
Open the message in the log. sent appears when the provider accepts it,
delivered when the notification comes back, and a bounce or complaint arrives
the same way a real one would — through the same webhook and the same timeline
your production traffic will use. That is the thing worth seeing, and it is the
thing a happy-path email in your inbox does not show you.
It is a restriction, not a convenience
Until you have a verified domain this is the only From available, and those
four are the only To — the sandbox replaces “you cannot send yet” with “here is
what you can send”, at the same enforcement point.
Once a domain of yours verifies, send from it and reach anybody: the restriction is gone, along with the reputation problem, because the reputation is then yours.
Migrating from another provider
Three steps. The second one is the one people skip and the one that matters.
1. Point your code at us
The request shape is deliberately the same as what you already have, so this is usually a base URL and a key rather than a rewrite.
From SendGrid, Resend, Postmark or Mailgun over SMTP: change the host, username and password. Nothing else.
| Setting | Value |
|---|---|
| Host | smtp.postrun.dev |
| Port | 465 or 587 |
| Username | postrun |
| Password | your API key |
Over HTTP: point the base URL at https://api.postrun.dev and swap the key.
The field names — from, to, subject, html, text, cc, bcc,
reply_to, headers, attachments, scheduled_at — are the ones you are
already sending.
2. Import your suppression list
Do this before your first send.
Your old provider has been quietly refusing to mail addresses that hard-bounced or marked you as spam. That list does not come with you. If you start sending without it, your first campaign mails every dead address you have accumulated — and a bounce spike on day one is the single fastest way to damage your sending reputation, on a brand-new account with no history to absorb it.
Export the list and paste it into Suppressions → Import. Header rows and blank lines are skipped, so you can paste a raw export without cleaning it up.
Where to find it:
| Provider | Where |
|---|---|
| SendGrid | Suppressions → Global Unsubscribes, plus Bounces and Spam Reports |
| Resend | Suppressions → export |
| Postmark | Suppressions tab, per message stream |
| Mailgun | Suppressions → Bounces / Complaints / Unsubscribes |
Import all of them. An address that bounced permanently and an address that complained are both addresses you must not mail again.
3. Consider a dedicated sending domain
Your existing domain works exactly as it does today if you copy three DKIM records — the third row of the table in the quickstart, and what every provider asks of you.
But if you use a separate domain for sending and point its nameservers at us, that is the last DNS work you will ever do for email. Key rotation, tracking subdomains, DMARC policy changes and anything we add later all happen without touching a zone of yours again.
This takes over all of that domain’s DNS. Use one that is not used for anything else. We check and warn before you switch, but a domain with a website or mail on it will break when the change propagates.
Two reasons it is worth doing at migration time rather than never:
- You are already in the DNS console.
- A separate sending domain keeps transactional reputation off the domain your
team emails from. A bad bounce week then costs you deliverability on
mail.acme.com, not on the address your CEO writes from.
If you do not have a spare domain, you can register one with us and skip the nameserver step entirely — it lands in the first row of that table.
What changes
Your daily limit starts low. A new account is capped and rises automatically as delivery history builds. This is not a judgement about you — it is how a shared sending reputation stays intact when anyone can sign up, and it means you never have to ask for a limit increase. If you are migrating real volume and cannot wait for the ramp, tell us.
Rejections happen earlier. A suppressed recipient is refused when you send,
not silently accepted and bounced later. Your code sees a suppressed error
instead of a delivery that never happens.
Errors carry a stable slug. Branch on name — validation_error,
rate_limit_exceeded, quota_exceeded, suppressed — not on the prose, which
may be reworded.
The API
Base URL https://api.postrun.dev. Authenticate with
Authorization: Bearer <your API key>.
Only that header — never a query parameter. A credential in a URL ends up in access logs, browser history and referrer headers.
Errors
Every failure has the same shape:
{ "name": "validation_error", "message": "`subject` is required" }
name is stable and machine-readable. message is prose and may be reworded,
so do not match on it.
name | HTTP | Meaning | Retry? |
|---|---|---|---|
unauthorized | 401 | The key is missing, wrong or revoked | No |
validation_error | 422 | The request is malformed | No |
domain_not_found | 422 | from is not one of your sending domains | No |
domain_not_verified | 422 | The domain is yours but its DNS is not verified yet | No |
key_not_permitted | 403 | This key may not do this: a sending-access key reading, or a key scoped to another domain | No |
email_unconfirmed | 403 | Confirm your email address before sending | No |
account_suspended | 403 | The account may not send | No |
suppressed | 422 | Every recipient is on your suppression list | No |
already_sent | 422 | The message has already left, so it cannot be changed or cancelled | No |
not_found | 404 | No such message or contact | No |
already_exists | 409 | on_conflict: "error" and that contact is already on your list | No |
forbidden | 403 | The account may not do this | No |
failed_precondition | 422 | A state the request cannot argue with, e.g. re-subscribing an address that complained | No |
rate_limit_exceeded | 429 | Too fast. Retry-After says how long | Yes |
quota_exceeded | 402 | An allowance spent: the daily send limit, or the plan’s contact ceiling | No — see below |
internal_error | 500 | Ours | Yes, with an Idempotency-Key |
The 403s are about the account or the key, not the request: your JSON is
fine and re-sending it will not help. email_unconfirmed in particular is the
one a new account meets first — spend the link in the mail we sent at signup and
the same request works.
domain_not_found is returned for a domain that does not exist here and for
one belonging to somebody else, with identical wording. Which of the two it is
is not something this endpoint will tell you.
quota_exceeded is deliberately not a 429. A quota does not clear in a
moment, and telling a client to retry shortly would be a lie they would act on.
A 500 does not mean the message was not accepted. Sending is several steps, and a failure in a later one — recording the send against your usage, say — leaves a message that is already stored and will still go out. Discarding it to make the error tidier would trade a duplicate for a message that silently never arrives.
So retry with an Idempotency-Key: it is the key that makes
the retry safe, not the failure.
What is here, and what is not
Everything about sending and reading email is on this API: send, batch, retrieve, list, reschedule, cancel, and the webhook stream that tells you what happened afterwards.
Your audience is here too. Contacts can be created, listed, updated, deleted and unsubscribed with a key, because the people on your list arrive in your own application and a list you can only update by uploading a CSV is a list that is out of date by definition. Those are the pages that follow.
Everything else is the dashboard. Domains, segments, broadcasts, templates,
suppression lists, webhook endpoints and API keys are managed there and have no
HTTP API a key can reach — the control surface is an RPC surface that takes a
signed-in session, not a bearer key. So a full_access key is “full access to
the email API and the audience”, not to the account.
The two permissions
A key is full_access or sending_access, and both are about this API alone — a
script that needs to add a domain cannot, whichever it holds.
sending_access may send and nothing else: it is the credential for the
machine you trust least. Reading the log, or touching contacts, answers
403 key_not_permitted.
full_access may also read the message log and manage the audience.
Send an email
POST /emails
{
"from": "Acme <hello@yourdomain.com>",
"to": "someone@example.com",
"subject": "Your receipt",
"html": "<p>Thanks.</p>"
}
| Field | Type | Notes |
|---|---|---|
from | string | Required. Name <addr> or a bare address. The domain must be one you have verified. |
to | string or array | Required. |
subject | string | Required. |
html | string | One of html or text is required. |
text | string | |
cc, bcc | string or array | |
reply_to | string or array | |
headers | object | Extra headers to set. |
attachments | array | `{ filename, content (base64) |
tags | object | Yours, for your own filtering. |
scheduled_at | string | RFC 3339. Omit to send now. |
Recipient fields accept a bare string or an array — both are correct.
Header: Idempotency-Key: <your key>. A repeat with the same key returns
the original message rather than sending twice.
Send one on every request you would retry. Without it, a timeout or a 500
leaves you unable to tell “not sent” from “sent, response lost”. Any unique
value works, and your own order or ticket id has the useful property that two
parts of your system retrying the same business event collapse onto one message.
Keys are scoped to your account and honoured for 24 hours; past that the
identifier is free again, so a stable one of yours is never permanently spent.
{ "id": "1042", "status": "queued" }
A partially suppressed send still goes. If three of fifty recipients are
suppressed, the other forty-seven are sent and the three are dropped. Only when
every recipient is suppressed do you get a suppressed error — one dead
address must not fail forty-nine good ones.
Limits
| Recipients | 50 per message, counting to, cc and bcc together |
| Body | 25 MB of html plus text |
| Attachments | 20 per message, 10 MB each |
Over any of them is 422 validation_error, naming which.
Send several at once
POST /emails/batch — an array of the same objects, up to 100.
{
"data": [
{ "id": "1043", "status": "queued" },
{ "id": "", "status": "validation_error" },
{ "id": "1044", "status": "queued" }
]
}
Results are positional and each message is queued independently. An entry
with an empty id failed and its status carries the reason — one bad address
does not discard the rest.
Retrieve an email
GET /emails/{id}
Returns the message with its full delivery timeline, including the receiving server’s own diagnostic text.
{
"id": "1042",
"from": "hello@yourdomain.com",
"to": ["someone@example.com"],
"subject": "Your receipt",
"status": "bounced",
"created_at": 1735689600,
"html": "<p>Thanks.</p>",
"events": [
{ "kind": "queued", "occurred_at": 1735689600 },
{ "kind": "sent", "occurred_at": 1735689601 },
{
"kind": "bounced",
"bounce_type": "Permanent",
"recipient": "someone@example.com",
"detail": "550 5.1.1 user unknown",
"occurred_at": 1735689640
}
]
}
bounce_type is Permanent, Transient or Undetermined, passed through
from the receiving infrastructure unchanged.
Status is monotonic. A message never moves backwards, so a late delivery notice for one recipient cannot overwrite a bounce recorded for another.
Content has a retention window; the record does not. html and text are
kept for your plan’s retention period — 30 days by default — after which this
endpoint still returns the message, its status and its full timeline, with the
body omitted. Nothing about how the content is stored is visible to you: it
moves out of the hot database within days, and reads are served transparently
either way.
List emails
GET /emails
Newest first.
| Parameter | Default | Notes |
|---|---|---|
limit | 50 | Clamped to 1–200. |
after | — | The keyset cursor: the last id from the previous page. |
query | — | Matches the recipient or the subject, case-insensitively, as a substring — the two things you have when you come looking for a message somebody is asking about. |
status | — | Exact match. queued, sent, delivered, bounced, complained, failed, refused, cancelled. Omit for any. |
{
"data": [
{ "id": "1042", "from": "hello@yourdomain.com", "to": ["someone@example.com"],
"subject": "Your receipt", "status": "delivered", "created_at": 1735689600 }
],
"next": "1042"
}
Pass next back as after to continue, and stop when data comes back
empty: a non-empty next is not a promise that more exist, and a full page
followed by nothing is an ordinary end.
Keyset rather than an offset, because rows arriving while you page would shift an
offset under you — you would see one message twice and miss another — and this is
the largest table we own, where OFFSET gets slower the deeper you go.
Bodies are not included. html and text come back only from
retrieving one message: a listing that carried fifty bodies
would be a slow endpoint for a field almost nobody reads in a list.
Reading requires a full-access key
A sending_access key answers 403 key_not_permitted here and on every other
read. The point of the narrow key is that it is the credential you can put on the
machine you trust least, and one that could read every message the account ever
sent would not be that.
Reads have their own rate ceiling, separate from sending, so polling the log can
never eat the send rate you are paying for. Over it you get 429 with
Retry-After, exactly as on a send.
Reschedule an email
PATCH /emails/{id}
{ "scheduled_at": "2027-01-02T09:00:00Z" }
Moves a queued message’s send time. An empty or absent scheduled_at clears the
schedule and sends immediately. A time in the past means the same thing — your
clock being behind ours is not an error.
Only the schedule can change. Everything else about a message is committed the moment the send is accepted: it is in the log, and once it is being sent, at the provider. An endpoint that appeared to edit a body would be editing a copy nobody sends.
The id does not change, so anything you stored against it stays valid — which is the reason to use this rather than cancelling and sending again.
Answers 422 already_sent once the message has left. Deliberately not a 404:
the message exists, it has simply gone, and “not found” would send you looking
for a bug in your ids.
Cancel a scheduled email
DELETE /emails/{id}
Works only while the message is still queued. A message already handed to the
sending infrastructure answers already_sent, because it genuinely cannot be
recalled and saying otherwise would be a lie you would rely on.
Create a contact
POST /contacts
The endpoint your signup handler calls. Send it the same address every time and it stays one contact — the address is the key, so you never store an id of ours.
{
"email": "alice@example.com",
"first_name": "Alice",
"last_name": "Ng",
"properties": { "plan": "pro", "seats": 9 }
}
| Field | Notes |
|---|---|
email | Required. Lowercased and trimmed before it is stored. |
first_name, last_name | Optional. Available in a template as {{first_name}}. |
properties | A JSON object of merge fields. Anything you put here is usable in a template and in a segment rule. |
on_conflict | upsert (default), skip or error when that address is already on the list. |
201 when the contact was created, 200 when it already existed — that is
how you tell the two apart without asking a second time.
{
"id": "8412",
"email": "alice@example.com",
"first_name": "Alice",
"last_name": "Ng",
"properties": { "plan": "pro", "seats": 9 },
"unsubscribed": false,
"created_at": 1735689600
}
The body says what the contact IS
A POST replaces properties — send {"plan": "pro"} for a contact who had
{"plan": "free", "seats": 3} and seats is gone. That is what makes removing a
field expressible at all.
When you only know that one field changed, use
PATCH, which merges.
on_conflict
upsert is the default because the caller is usually a signup or a sync, and
both mean “this is the contact now”.
skipreturns what is stored, untouched. Use it for an import that must not overwrite edits somebody made on the dashboard.erroranswers409 already_exists. Use it when you believe the address is new and would rather hear that it is not.
Refusals
| Status | name | When |
|---|---|---|
422 | validation_error | email is not an address, or properties is not a JSON object. |
409 | already_exists | on_conflict: "error" and the address is on the list. |
402 | quota_exceeded | The plan’s contact ceiling. It is metered on contacts stored, so this is the write that crosses it. |
403 | key_not_permitted | A sending-access key. The audience needs a full-access one. |
Create several contacts
POST /contacts/batch
The nightly sync, rather than the signup hook. Up to 100 per request, each
entry exactly the body POST /contacts takes.
[
{ "email": "alice@example.com", "properties": { "plan": "pro" } },
{ "email": "bob@example.com", "properties": { "plan": "free" } }
]
The response is one result per input, in the order you sent them:
{
"data": [
{ "email": "alice@example.com", "status": "created" },
{ "email": "bob@example.com", "status": "updated" }
]
}
status is created, updated or skipped, or the error slug for that entry
with a message beside it.
It answers 200 even when entries failed
One bad address is that address’s problem: a 4xx for the whole request would
make you replay the entries that were fine, and hit the same bad one again. So
check status per entry. The request itself is refused only when the batch is
empty or longer than 100 — both wrong before anything was attempted.
The contact ceiling is the exception worth knowing about. It is a fact about
the account, not the row, so once one entry reports quota_exceeded every
remaining one will too. They are still reported in place rather than truncating
the list, because results you match to inputs by position have to be as many as
you sent.
Still one contact per address
A batch containing the same address twice applies both, in order — the second is an update of what the first wrote, not a duplicate.
Retrieve a contact
GET /contacts/{address}
The address, URL-encoded: GET /contacts/alice%40example.com.
{
"id": "8412",
"email": "alice@example.com",
"first_name": "Alice",
"last_name": "Ng",
"properties": { "plan": "pro", "seats": 9 },
"unsubscribed": false,
"unsubscribe_reason": "",
"created_at": 1735689600,
"topics": { "product-updates": true, "weekly-digest": false }
}
404 not_found when that address is not a contact of yours.
What unsubscribed and topics each mean
unsubscribed is the global opt-out: no broadcast reaches them, whatever
its topic. topics is the narrower answer — per topic, with the topic’s own
default applied where they have never said.
unsubscribe_reason is worth reading. complaint is a spam report, which is
sending reputation being spent rather than a preference somebody changed; it is
also the one state you cannot undo through
/subscribe.
Transactional mail is unaffected by either. Consent here governs broadcasts, and a receipt is mail they asked for.
List contacts
GET /contacts
Newest first.
| Parameter | Default | Notes |
|---|---|---|
limit | 50 | Clamped to 1–200. |
after | — | The keyset cursor: the last id from the previous page. |
query | — | Matches the address or either name, case-insensitively, as a substring. |
segment_id | — | Only the contacts in that segment — a list you picked or a rule, whichever it is. |
rules | — | Ad hoc conditions, the same JSON a rule segment stores. |
{
"data": [
{ "id": "8412", "email": "alice@example.com", "first_name": "Alice",
"properties": { "plan": "pro" }, "unsubscribed": false,
"created_at": 1735689600 }
],
"next": "8412"
}
Pass next back as after to continue, and stop when data comes back empty —
a full page followed by nothing is an ordinary end.
Topic answers are not included. They cost a query per topic, which a page of fifty must not pay; retrieving one contact returns them.
Asking a question about the list
rules is the segment predicate, so you can ask the API what the dashboard’s
filters ask:
{ "match_": "all", "conditions": [
{ "field": "property", "key": "plan", "op": "is", "value": "pro" },
{ "field": "engagement", "what": "opened", "op": "not_within_days", "days": 90 }
]}
URL-encoded as the rules parameter. Conditions are property (is, is_not,
contains, is_set, is_not_set, greater_than, less_than),
joined_within_days / joined_before_days, and engagement — delivered,
bounced, complained, opened, clicked with ever, never,
within_days, not_within_days.
match_ is all or any.
It is the same translation a broadcast uses, so what you count here is who a send to the same conditions would reach.
Update a contact
PATCH /contacts/{address}
What you send is what changes. Everything you leave out stays as it was.
{ "properties": { "plan": "enterprise", "trial_ends": null } }
- A field you omit is left alone — this is the difference from
POST, which replaces. propertiesis merged key by key, so the other merge fields survive.- A merge field set to
nullis removed.
Returns the contact as it now stands. 404 not_found if the address is not on
your list — PATCH does not create, because a typo in an address would
otherwise quietly add a contact nobody meant.
Changing an address
You cannot. The address is the key, and an endpoint that changed it would be indistinguishable from a typo correcting the wrong person’s record. Create the new contact and delete the old one, with the consent it carried in mind — see deleting for why that matters.
Delete a contact
DELETE /contacts/{address}
{ "deleted": true }
404 not_found if the address is not on your list.
Their segment memberships and topic answers go with them, and any segment they were counted in is corrected.
This is almost never what you want
Deleting loses the record that they opted out. Re-import the same CSV next month and you will mail somebody who asked you not to — and the second time, they report it as spam rather than clicking unsubscribe.
POST /contacts/{address}/unsubscribe keeps the contact and the
record. Use DELETE for an erasure request, or for a row that should never have
existed.
Subscribe and unsubscribe
POST /contacts/{address}/unsubscribe
POST /contacts/{address}/subscribe
Both take an optional body, and both return the contact.
Two layers, and they are not the same thing
No body is the whole list. It sets the global opt-out, and no broadcast reaches them again whatever its topic.
POST /contacts/alice%40example.com/unsubscribe
{ "reason": "preferences" }
A topic is one topic. Everything else they agreed to stands.
POST /contacts/alice%40example.com/unsubscribe
{ "topic": "weekly-digest" }
Topics are named, not numbered — your code already says
"product-updates", and an id would be a lookup you have to store.
404 not_found names the topic when there is no such one.
A contact who has never answered about a topic inherits that topic’s default, so
absence of an answer is not absence of consent. Their stated answers are in
topics on retrieve.
reason
manual (the default) or preferences, on an unsubscribe.
complaint is refused with 422 validation_error: it is written from the
mailbox provider’s own report, and a sender declaring one on a contact’s behalf
would be fabricating the fact that costs them most.
Subscribing back
POST /contacts/{address}/subscribe clears the global opt-out, or opts them into
a named topic.
It is refused for a contact whose unsubscribe_reason is complaint
(422 failed_precondition). That address is on the suppression list too, so mail
would not go out even with the flag flipped — answering OK would tell you a
contact is reachable when they are not. Re-consent after a spam report is yours
to obtain, not ours to record.
Webhook events
Configure endpoints in the dashboard. Every delivery carries:
webhook-signature: <hex>
webhook-timestamp: <unix seconds>
webhook-event: email.bounced
Verify with HMAC-SHA256(secret, timestamp + "." + rawBody), compared against
webhook-signature. Use the raw body, before any JSON parsing —
re-serialising changes the bytes and the signature will not match.
The timestamp is inside the signed material, so reject anything older than a few minutes to stop a captured request being replayed.
import crypto from 'node:crypto'
// Express: app.post('/hooks', express.raw({ type: 'application/json' }), …)
function verify(req, secret) {
const ts = req.header('webhook-timestamp')
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.body}`) // req.body is a Buffer, not parsed JSON
.digest('hex')
const sig = Buffer.from(req.header('webhook-signature'), 'hex')
const ok = crypto.timingSafeEqual(Buffer.from(expected, 'hex'), sig)
return ok && Math.abs(Date.now() / 1000 - Number(ts)) < 300
}
import hashlib, hmac, time
def verify(headers, raw_body: bytes, secret: str) -> bool:
ts = headers["webhook-timestamp"]
expected = hmac.new(
secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
).hexdigest()
fresh = abs(time.time() - int(ts)) < 300
return hmac.compare_digest(expected, headers["webhook-signature"]) and fresh
Event types: email.sent, email.delivered, email.bounced,
email.complained, email.delivery_delayed, email.opened, email.clicked,
email.failed, email.refused, email.received.
The payload
{
"id": "evt_7Qk2...",
"type": "email.bounced",
"created_at": 1730000000,
"data": { … }
}
data has two shapes, and which one you get is decided by type.
Everything except email.received
Mail you sent. One event per RECIPIENT, because a message to five addresses can be delivered to four of them and bounce on the fifth, and one event carrying “partly bounced” would be useless.
| Field | Notes |
|---|---|
email_id | The id POST /emails returned, as a string. This is what joins the event back to your own record. |
from | The envelope sender of the message. |
to | The one recipient this event is about, as a string — not the message’s full recipient list. |
subject | |
bounce_type | Permanent, Transient or Undetermined, passed through from the receiving infrastructure unchanged. Empty on events that are not bounces. |
detail | The receiving server’s own diagnostic text, e.g. 550 5.1.1 user unknown. Prose from somebody else’s mail server: read it, do not parse it. Empty where there is none. |
{
"id": "evt_7Qk2LmR4",
"type": "email.bounced",
"created_at": 1735689640,
"data": {
"email_id": "1042",
"from": "hello@yourdomain.com",
"to": "someone@example.com",
"subject": "Your receipt",
"bounce_type": "Permanent",
"detail": "550 5.1.1 user unknown"
}
}
A email.refused event carries the same shape, with detail naming why we
declined to send to that address — a suppression, an unsubscribe, an opt-out.
It is the one event that means a message did not go, so it is the one worth
handling even if you ignore the rest.
email.received
Mail that arrived at a domain you have inbound switched on for. A different shape, because it is a different thing: there is no message of yours to join to.
| Field | Notes |
|---|---|
inbound_id | The received message’s id. |
from | Who sent it. |
to | Array of recipients it was addressed to. |
subject | |
in_reply_to_email_id | The id of YOUR message this is a reply to, or absent. The point of the whole feature: you get the thread without parsing a subject line. |
text | The body with the quoted reply and signature block already stripped — the part an application actually wants. |
authentication_results | What we determined about the sender, in Authentication-Results form: mx.postrun.dev; spf=pass smtp.mailfrom=acme.test; dkim=not-evaluated; dmarc=not-evaluated. Only SPF is checked today — DKIM and DMARC read not-evaluated, never none, so you can tell “we did not look” from “there was nothing to find”. |
{
"id": "evt_9Wz1Ppq7",
"type": "email.received",
"created_at": 1735690000,
"data": {
"inbound_id": "88",
"from": "someone@example.com",
"to": ["support@yourdomain.com"],
"subject": "Re: Your receipt",
"in_reply_to_email_id": "1042",
"text": "Thanks, got it.",
"authentication_results": "mx.postrun.dev; spf=pass smtp.mailfrom=example.com; dkim=not-evaluated; dmarc=not-evaluated"
}
}
authentication_results is reporting, not a verdict. We do not refuse mail
on it and neither should you without thinking: SPF fail is routine on
forwarded mail, and what it is worth is a judgement only the receiving
application can make.
Your endpoint has 10 seconds to answer; anything slower is a failed attempt. Failed deliveries are retried with backoff — ten attempts over about five hours — and the attempt log is in the dashboard, so “you never sent it” is a question either of us can answer.
Delivery is at-least-once, so dedupe on id. Every payload carries one:
{ "id": "evt_7Qk2...", "type": "email.delivered", "created_at": 1730000000, "data": { … } }
A receiver that commits the event and then fails to answer — a timeout after
the write, a proxy dropping the response — looks exactly like one that never
received it, so we retry and you see the event twice. Every retry of the same
event carries the same id, so recording it and ignoring a repeat is all
the protection you need.
Do not dedupe on anything else. webhook-timestamp is re-signed on each
attempt, so it differs between retries of one event; and hashing the body
collides, because two opens of the same message in the same second are
byte-identical apart from the id.