Suppression and delivery events
How bounces and complaints are handled automatically, and how to subscribe your own webhook to delivery, bounce, and quota events.
The suppression list
When a recipient's mail server permanently rejects a message ("this mailbox doesn't exist") or the recipient marks it as spam, orboto Mail automatically adds that address to your account's suppression list and stops sending to it. This protects your sending reputation - mail providers penalize senders who keep hitting invalid addresses or generating complaints, and one bad list can hurt deliverability for every other recipient too.
Every send checks the suppression list first. A suppressed recipient
gets skipped with a 422 recipient_suppressed response (see
Sending email) and, importantly,
does not consume your quota - you're never charged for a send that
was blocked.
| Source | When it's added |
|---|---|
hard-bounce | The receiving server permanently rejected the message (invalid address, domain doesn't exist, mailbox disabled). |
complaint | The recipient marked the message as spam/junk. |
manual | You added it yourself - via the API or the dashboard - typically for an explicit unsubscribe request. |
Managing entries
# Check one address
curl https://mail.orboto.io/api/v1/suppression/user@example.com \
-H "Authorization: Bearer oms_live_your_key_here"
# Add one manually (e.g. an explicit opt-out request)
curl https://mail.orboto.io/api/v1/suppression \
-X POST \
-H "Authorization: Bearer oms_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "email": "user@example.com", "reason": "manual" }'
# Remove a false positive
curl https://mail.orboto.io/api/v1/suppression/user@example.com \
-X DELETE \
-H "Authorization: Bearer oms_live_your_key_here"
# List, paginated, optionally filtered by reason
curl "https://mail.orboto.io/api/v1/suppression?limit=20&reason=hard-bounce" \
-H "Authorization: Bearer oms_live_your_key_here"Adding an address that's already suppressed is a no-op (not an error) - safe to call repeatedly. Removing an address that was auto-added by a hard bounce is appropriate only when you're confident the underlying problem is fixed (a typo the recipient corrected, a mailbox that was temporarily over quota and is now active again); otherwise the next send will very likely bounce again and re-add it.
Delivery events (webhooks)
Suppression handles the send-time consequence of a bad address automatically. If you want to react to what happens to a message after it's sent - update a user's profile when their email bounces, alert your team when quota is running low - subscribe a webhook.
curl https://mail.orboto.io/api/v1/webhooks \
-X POST \
-H "Authorization: Bearer oms_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example.com/webhooks/orboto-mail",
"label": "production",
"eventFilters": ["bounce.permanent", "complaint", "delivery"]
}'The response includes secret - a signing secret shown exactly once.
Omit eventFilters (or pass an empty array) to receive every event
type instead of a subset.
Event types
| Event | Fires when |
|---|---|
delivery | The recipient's mail server accepted the message. |
bounce.permanent | A hard bounce - the address is also added to your suppression list. |
bounce.transient | A soft bounce (mailbox full, server temporarily unavailable) - not added to suppression; the address may succeed on a later send. |
complaint | The recipient marked the message as spam - also added to your suppression list. |
email.opened | The recipient's mail client loaded the tracking pixel, if open tracking is enabled on the sending domain. Fires once, on the first open. |
quota.soft-warn-80 / quota.soft-warn-95 | Monthly usage crossed 80% / 95% of your base quota. |
quota.exhausted-base | Base monthly quota is used up. |
quota.exhausted-cap | Above-quota (overage) usage hit its account cap too. |
Each delivery is POSTed as JSON:
{
"event": "bounce.permanent",
"timestamp": "2026-08-30T12:00:00.000Z",
"data": { "messageId": "...", "to": "user@example.com", "bounceType": "Permanent", "suppressionAdded": true }
}with three headers on every request:
X-OMS-Webhook-Id: <your webhook subscription id>
X-OMS-Event: bounce.permanent
X-OMS-Signature: t=1735000000, v1=<hex hmac-sha256>Verifying the signature
v1 is HMAC_SHA256(your_signing_secret, "<t>.<raw request body>"),
hex-encoded. Recompute it on your side and compare:
import { createHmac, timingSafeEqual } from 'node:crypto';
function isValidOmsWebhook(secret, signatureHeader, rawBody, toleranceSeconds = 300) {
const tMatch = /t=(\d+)/.exec(signatureHeader);
const vMatch = /v1=([0-9a-f]+)/.exec(signatureHeader);
if (!tMatch || !vMatch) return false;
const t = Number(tMatch[1]);
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const got = vMatch[1];
return got.length === expected.length &&
timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'));
}Use the raw request body (before any JSON parsing) when computing
the HMAC - re-serializing a parsed object can produce different bytes
(key order, whitespace) and break the signature check. The t= value
also doubles as replay protection: reject anything older than a few
minutes, as the example above does.
Managing subscriptions
| Method + path | Purpose |
|---|---|
GET /v1/webhooks | List your subscriptions (signing secret never included). |
GET /v1/webhooks/:id | Get one. |
PATCH /v1/webhooks/:id | Update url, label, eventFilters, or enabled (set enabled: false to pause deliveries without deleting the subscription). |
DELETE /v1/webhooks/:id | Remove it. |
POST /v1/webhooks/:id/rotate-secret | Issue a new signing secret - the old one stops working immediately. Returns the new plaintext secret once. |
url must be https:// (plain http:// is only accepted for
localhost/127.0.0.1, for local development). orboto Mail retries a
failing delivery with backoff; GET /v1/webhooks/:id reports
lastSuccessAt, lastFailureAt, and lastFailureReason so you can see
whether deliveries are actually landing.
Troubleshooting
| Symptom | Fix |
|---|---|
A known-good address keeps getting recipient_suppressed | It was likely added by an earlier bounce or complaint. Check GET /v1/suppression/:email for the reason and addedAt, then DELETE it if the underlying issue is genuinely fixed. |
| My webhook isn't receiving anything | Confirm enabled is true and the event actually happened - eventFilters defaults to nothing sent unless you either list the event explicitly or leave the array empty to receive everything. Check lastFailureReason on GET /v1/webhooks/:id for delivery errors on your endpoint. |
| Signature verification always fails | Almost always caused by re-serializing the JSON body before hashing it - hash the exact raw bytes you received, not a re-JSON.stringify'd copy. |
| I rotated my secret and now nothing verifies | Expected - a rotation immediately invalidates the previous secret for every future delivery. Update your verification code with the new secret returned from the rotate call. |
| I want to stop bounces from generating a webhook call but keep suppression | Not separable per-event today - suppression and its webhook fire together. PATCH your subscription's eventFilters to drop bounce.permanent/complaint if you only want the suppression side-effect without the notification. |
Sending domains and DNS
Verify your own domain for sending - the DKIM, SPF, and DMARC records to publish, and how to check verification status.
Using orboto Mail from an orboto workspace
Connect an orboto workspace's outbound email - invites, notifications, password resets - to orboto Mail in one click.