Mail guides
05 · Deliverability

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

GET /workspaces/:ws/suppression
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).

POST /workspaces/:ws/suppression
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.

DELETE /workspaces/:ws/suppression/:email
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.sent — our relay accepted the message and handed it downstream. This is the first hop, not arrival, and it is what the send path actually knows.
  • message.deferred — the destination has not taken the message and the relay is still retrying: greylisting, a rate limit, or a reputation block. It is not a failure and it never suppresses the recipient, but a sender accumulating these is a sender in trouble.
  • message.bounced — permanent delivery failure. Spam complaints surface here too (as a permanent bounce with "smtp_response": "spam complaint").
  • message.delivered — reserved for evidence the recipient actually received it. Nothing emits it today. No SMTP response anywhere in the chain means “delivered” — not our relay's 250 queued, and not the destination's own 250 OK, which large receivers return before discarding a message on reputation. We would rather tell you the number is unknown than tell you a number that is wrong.
PUT /workspaces/:ws/webhooks/default
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.sent", "message.deferred", "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.

Bounce event
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 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 message.deferred long before anything hard- bounces. Count both webhook event types against sends, or call GET /workspaces/:ws/deliverability, which rolls the same rows up into a verdict.
  • Read unconfirmed as “unknown”, not “fine”. The deliverability verdict returns unconfirmed when your mail left our 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/suppression and find the row to see the source and reason.
  • “Bounces aren't firing webhooks” — check GET /webhooks/default for disabled_at (auto-disable after sustained failures) and GET /webhooks/attempts for what Mail saw from your endpoint. If you send through your own SMTP provider, also confirm its bounce notifications (pointed at POST /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.