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

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 rate. Above 2% is the widely-cited threshold where receivers start filtering. Count message.bounced webhook 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/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.