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. The webhook is how your system learns about delivery and bounces. Subscribe to the events you care about.
Events:
message.delivered— receiver accepted the message.message.bounced— permanent delivery failure. Spam complaints surface here too (as a permanent bounce with"smtp_response": "spam complaint"). Soft bounces don't fire events — the provider is still retrying.
curl -X PUT https://api.mail.productcraft.co/v1/workspaces/<ws>/webhooks/default \
-H 'authorization: Bearer pcft_live_...' \
-H 'content-type: application/json' \
-d '{
"url": "https://acme.example.com/webhooks/mail",
"event_types": ["message.delivered", "message.bounced"]
}'The response includes signing_secret — shown once, store it now; afterwards only the last-4 hint is retrievable. Rotate via POST /workspaces/:ws/webhooks/default/rotate-secret (the previous secret stays valid for 24h so you can roll without dropping events). The URL must be public https:// — private/internal targets are rejected with a 422 at config time.
Per-domain configs (PUT /workspaces/:ws/webhooks/domains/:domain_id) override the workspace default for that domain — useful if you want staging's bounce events to land somewhere different than prod's.
6
Webhook payload + signature
Deliveries are signed like Auth webhooks: HMAC-SHA256 over <timestamp>.<raw body> keyed by the signing secret, in a t=<unix>,v1=<hex> header. The verification snippet in Auth guide 08 is drop-in compatible — just read X-Mail-Signature instead of X-Auth-Signature, and reject timestamps older than 5 minutes.
POST https://acme.example.com/webhooks/mail
x-mail-event-type: message.bounced
x-mail-event-id: evt_5b2c1a3e-...
x-mail-signature: t=1778499378,v1=...
{
"id": "evt_5b2c1a3e-...",
"type": "message.bounced",
"created_at": "2026-05-07T09:36:18.412Z",
"workspace_id": "24fab978-...",
"domain_id": null,
"data": {
"from": "noreply@acme.com",
"to": "bounced@example.com",
"smtp_code": "550",
"smtp_response": "user unknown",
"bounced_at": "2026-05-07T09:36:17.998Z"
}
}Non-2xx responses are retried at 1m, 5m, 30m, 2h, 6h, 24h (7 attempts, ~32h total). After 20 consecutive failures — or 24h of unbroken failure — the config is auto-disabled (disabled_at set); fix your endpoint and re-PUT the config to re-enable. Attempt history: GET /workspaces/:ws/webhooks/attempts.
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 rate. Above 2% is the widely-cited threshold where receivers start filtering. Count
message.bouncedwebhook events against sends, or pull the message timeline (Chapter 6). - 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/defaultfordisabled_at(auto-disable after sustained failures) andGET /webhooks/attemptsfor what Mail saw from your endpoint. If you send through your own SMTP provider, also confirm its bounce notifications (pointed atPOST /v1/webhooks/bounces/:ws) are still configured — no inbound bounce, no outbound event. - “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.