Land in the inbox. Stay there.
Bounce handling, complaint handling, the suppression list, webhook config, and the hygiene practices that keep your sender reputation healthy.
1
The suppression list
Workspace-scoped. Hard wall — once an address is on it, production sends to it fail with a 422 regardless of sender or template (only test-sends bypass it). Goal: never send to an address that has bounced or complained, both for the recipient's benefit and for your sender reputation.
Entries land on the list:
- Automatically on hard bounce (mailbox doesn't exist, domain rejects) reported back by your delivery route.
- Automatically on complaint (recipient hits “Mark as spam”) — treated as a permanent suppression.
- Manually via
POST /workspaces/:ws/suppression(your unsubscribe form posts here).
Soft bounces (mailbox full, temporary deferral) are deliberately not suppressed — your provider already retries them, and suppressing a recipient over a transient hiccup hurts more than it helps.
2
Listing
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/suppression \
-H 'authorization: Bearer pcft_live_...'{
"data": [
{
"id": "3b187ca9-...",
"workspace_id": "24fab978-...",
"email": "bounced@example.com",
"reason": "hard bounce (550: user unknown)",
"source": "bounce",
"added_at": "2026-04-12T..."
}
]
}Most-recent first, capped at 500 rows; no filter or pagination parameters. source is one of manual / bounce / complaint; reason is free text.
3
Manual add (unsubscribe)
When a recipient hits unsubscribe on your form, POST to the suppression list. Idempotent upsert — re-adding an address updates its reason and timestamp instead of creating a duplicate row (you get a 201 either way).
curl https://api.mail.productcraft.co/v1/workspaces/<ws>/suppression \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"email": "userwhowantsout@example.com",
"reason": "unsubscribed via preferences page"
}'reason is optional free text (≤255 chars); manual adds are recorded with "source": "manual".
4
Remove from suppression
Rarely needed. A customer changes their mind, an address is wrongly suppressed (false-positive bounce), an internal test address. 204 on success, 404 if the address isn't on the list.
curl -X DELETE https://api.mail.productcraft.co/v1/workspaces/<ws>/suppression/userwhowantsback@example.com \
-H 'authorization: Bearer pcft_live_...'5
Webhooks — learn what happened
Mail's send is async. Webhooks are how your system learns about delivery, bounces and complaints. Add an endpoint and subscribe it to the events you care about.
The email events (the full catalogue, domain and suppression events included, is at GET /workspaces/:ws/webhooks/event-types):
email.sent— the message was accepted and we are delivering it to the recipient's mail server. This is hand-off, not arrival.email.delivered— the recipient's mail server accepted the message. Emails sent before delivery tracking started, or through your own SMTP relay, never produce it: a relay's SMTP response proves hand-off, not delivery. We would rather tell you the number is unknown than tell you a number that is wrong.email.delivery_delayed— a temporary failure: greylisting, a rate limit or a reputation block, and the message is being retried. It never suppresses the recipient, but a sender collecting these is a sender in trouble.email.bounced— permanent delivery failure. The address goes on your suppression list.email.complained— the recipient marked the message as spam. The address is suppressed with sourcecomplaint.email.failed— the message was refused before sending. Nothing went out.email.suppressed— a send was refused because the recipient is on the suppression list.
The old message.* names (message.sent, message.deferred, message.bounced, …) are still accepted as aliases for one release, and an endpoint subscribed under an alias receives that alias as the event type. Switch to the email.* names.
curl -X POST https://api.mail.productcraft.co/v1/workspaces/<ws>/webhooks/endpoints \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-H 'idempotency-key: create-bounces-hook' \
-d '{
"url": "https://acme.example.com/webhooks/mail",
"event_types": ["email.delivered", "email.bounced", "email.complained"]
}'A workspace has up to 10 endpoints, each with its own event list (event_types; leave it empty for every event, including ones added later; the older name events is still accepted), an optional domain_id filter and an on/off switch. The response includes the whsec_… signing secret, shown once. Verifying the signature, Send test, automatic retries, auto-disable, the event log, replay and secret rotation work the same in every ProductCraft product and are in the Webhooks guide, with Node code you can paste.
Deprecated: the older single-slot routes (/webhooks/default, /webhooks/domains/:domain_id and GET /webhooks) still work, show up in the endpoint list as legacy slots, and answer with Deprecation: true and Sunset: Fri, 15 Jan 2027 00:00:00 GMT. After 2027-01-15 they return 410. Move to /webhooks/endpoints: a legacy slot is already an endpoint there, so you can manage it by its id today.
6
Webhook payload + signature
Every delivery is signed with Standard Webhooks; the Webhooks guide shows how to verify it. Endpoints created before 2026-10-17, and slots set through the deprecated routes, also receive the older X-Mail-Signature header until 2027-01-15. New endpoints get only the Standard Webhooks headers, so verify those.
POST https://acme.example.com/webhooks/mail
webhook-id: evt_5b2c1a3e-...
webhook-timestamp: 1778499378
webhook-signature: v1,...
{
"id": "evt_5b2c1a3e-...",
"type": "email.bounced",
"created_at": "2026-05-07T09:36:18.412Z",
"workspace_id": "24fab978-...",
"domain_id": "9c1d...",
"data": {
"email_id": "3f0c...",
"from": "noreply@acme.com",
"to": "bounced@example.com",
"bounce_type": "Permanent",
"occurred_at": "2026-05-07T09:36:17.998Z"
}
}A delivery succeeds on any 2xx within 15 seconds. A failed delivery is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (7 attempts in all); each event shows when the next attempt is due. Deliveries go out within a few seconds of the event. Every event is kept for 30 days: list them with GET /workspaces/:ws/webhooks/events and send one again with POST /workspaces/:ws/webhooks/events/:id/replay. After 20 consecutive failures, or 24 hours of unbroken failure, the endpoint is auto-disabled (status: auto_disabled). Fix the receiver and switch it back on with PATCH { "enabled": true }.
7
Reputation hygiene
- Don't send to addresses you haven't verified. Customer signs up → ask them to verify before sending anything beyond the verification email itself. Bounces on unverified addresses dent your domain's reputation across receivers.
- Watch your bounce AND deferral rates. Above 2% bounces is the widely-cited threshold where receivers start filtering, but deferrals move first: a reputation block shows up as repeated
email.delivery_delayedlong before anything hard-bounces. Count both webhook event types against sends, or callGET /workspaces/:ws/deliverability, which rolls the same rows up into a verdict. - Read
unconfirmedas “unknown”, not “fine”. The deliverability verdict returnsunconfirmedwhen your mail has no delivery tracking (sent before tracking started, or through your own SMTP relay) and nothing has come back in either direction. That is the honest state of the world for a sender with no arrival evidence — and it is also exactly the shape a totally blackholed sender makes, which is why we refuse to call it healthy. - Don't reuse a long-dormant domain without warming it up. If you stop sending for months then resume at full volume, receivers treat the spike as suspicious. Ramp over 7–14 days.
- One sender intent per address.
noreply@for operational mail,support@for inbound,billing@for receipts. Mixing intents on one address (transactional + marketing + replies) confuses receivers' reputation tracking. - Marketing mail needs an unsubscribe link. Mail doesn't inject one for you — put it in your template (or use
{{> bp.footer}}with footer text that includes it) and wire it to the suppression POST above. The lint flags bodies without one (body.no_unsubscribe); transactional mail is exempt.
8
When things go wrong
Symptoms + first-look:
- “My emails are landing in spam” — check the DMARC report (sent to your
rua=address). If SPF or DKIM are failing for any of your sends, that's the cause. Most often: missing or wrong DKIM record. - “Send is returning 422 ‘recipient is on this workspace's suppression list’” — list
GET /workspaces/:ws/suppressionand find the row to see thesourceandreason. - “Bounces aren't firing webhooks” — check
GET /webhooks/endpointsfor an endpoint withstatus: auto_disabled(sustained failures) or without the event in itsevents, andGET /webhooks/eventsfor what Mail sent and what your endpoint answered; replay from there. If you send through your own SMTP relay, Mail only learns about bounces the relay rejects during the SMTP session; a bounce your provider reports later never reaches Mail, so no event fires. - “A specific recipient never gets mail” — they might be on the suppression list, OR their corporate filter is rejecting silently. Use the message log (Chapter 6) to confirm delivery status before suspecting the customer.