Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 situationWhat you do
The domain is registered with usNothing. We publish every record it needs. There is no table to copy and nothing to mistype.
You have a domain used only for sendingPoint 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 domainCopy 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:

SettingValue
Hostsmtp.postrun.dev
Port465 (TLS), or 587 (STARTTLS)
Usernamepostrun — the same for everyone
Passwordyour 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

AddressWhat 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.

SettingValue
Hostsmtp.postrun.dev
Port465 or 587
Usernamepostrun
Passwordyour 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:

ProviderWhere
SendGridSuppressions → Global Unsubscribes, plus Bounces and Spam Reports
ResendSuppressions → export
PostmarkSuppressions tab, per message stream
MailgunSuppressions → 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.

nameHTTPMeaningRetry?
unauthorized401The key is missing, wrong or revokedNo
validation_error422The request is malformedNo
domain_not_found422from is not one of your sending domainsNo
domain_not_verified422The domain is yours but its DNS is not verified yetNo
key_not_permitted403This key may not do this: a sending-access key reading, or a key scoped to another domainNo
email_unconfirmed403Confirm your email address before sendingNo
account_suspended403The account may not sendNo
suppressed422Every recipient is on your suppression listNo
already_sent422The message has already left, so it cannot be changed or cancelledNo
not_found404No such message or contactNo
already_exists409on_conflict: "error" and that contact is already on your listNo
forbidden403The account may not do thisNo
failed_precondition422A state the request cannot argue with, e.g. re-subscribing an address that complainedNo
rate_limit_exceeded429Too fast. Retry-After says how longYes
quota_exceeded402An allowance spent: the daily send limit, or the plan’s contact ceilingNo — see below
internal_error500OursYes, 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>" }
FieldTypeNotes
fromstringRequired. Name <addr> or a bare address. The domain must be one you have verified.
tostring or arrayRequired.
subjectstringRequired.
htmlstringOne of html or text is required.
textstring
cc, bccstring or array
reply_tostring or array
headersobjectExtra headers to set.
attachmentsarray`{ filename, content (base64)
tagsobjectYours, for your own filtering.
scheduled_atstringRFC 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

Recipients50 per message, counting to, cc and bcc together
Body25 MB of html plus text
Attachments20 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.

ParameterDefaultNotes
limit50Clamped 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 } }
FieldNotes
emailRequired. Lowercased and trimmed before it is stored.
first_name, last_nameOptional. Available in a template as {{first_name}}.
propertiesA JSON object of merge fields. Anything you put here is usable in a template and in a segment rule.
on_conflictupsert (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”.

  • skip returns what is stored, untouched. Use it for an import that must not overwrite edits somebody made on the dashboard.
  • error answers 409 already_exists. Use it when you believe the address is new and would rather hear that it is not.

Refusals

StatusnameWhen
422validation_erroremail is not an address, or properties is not a JSON object.
409already_existson_conflict: "error" and the address is on the list.
402quota_exceededThe plan’s contact ceiling. It is metered on contacts stored, so this is the write that crosses it.
403key_not_permittedA 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.

ParameterDefaultNotes
limit50Clamped 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.
  • properties is merged key by key, so the other merge fields survive.
  • A merge field set to null is 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.

FieldNotes
email_idThe id POST /emails returned, as a string. This is what joins the event back to your own record.
fromThe envelope sender of the message.
toThe one recipient this event is about, as a string — not the message’s full recipient list.
subject
bounce_typePermanent, Transient or Undetermined, passed through from the receiving infrastructure unchanged. Empty on events that are not bounces.
detailThe 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.

FieldNotes
inbound_idThe received message’s id.
fromWho sent it.
toArray of recipients it was addressed to.
subject
in_reply_to_email_idThe 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.
textThe body with the quoted reply and signature block already stripped — the part an application actually wants.
authentication_resultsWhat 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.