orbotodocs
orboto Mail

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.

SourceWhen it's added
hard-bounceThe receiving server permanently rejected the message (invalid address, domain doesn't exist, mailbox disabled).
complaintThe recipient marked the message as spam/junk.
manualYou 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

EventFires when
deliveryThe recipient's mail server accepted the message.
bounce.permanentA hard bounce - the address is also added to your suppression list.
bounce.transientA soft bounce (mailbox full, server temporarily unavailable) - not added to suppression; the address may succeed on a later send.
complaintThe recipient marked the message as spam - also added to your suppression list.
email.openedThe 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-95Monthly usage crossed 80% / 95% of your base quota.
quota.exhausted-baseBase monthly quota is used up.
quota.exhausted-capAbove-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 + pathPurpose
GET /v1/webhooksList your subscriptions (signing secret never included).
GET /v1/webhooks/:idGet one.
PATCH /v1/webhooks/:idUpdate url, label, eventFilters, or enabled (set enabled: false to pause deliveries without deleting the subscription).
DELETE /v1/webhooks/:idRemove it.
POST /v1/webhooks/:id/rotate-secretIssue 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

SymptomFix
A known-good address keeps getting recipient_suppressedIt 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 anythingConfirm 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 failsAlmost 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 verifiesExpected - 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 suppressionNot 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.

On this page