A failed send throws an error your code can catch; a bounce happens minutes later, in someone else's mail server, after your API call already returned success. That gap is where outreach agents quietly rot. The agent fires off a campaign, every send returns 200, the dashboard's green — and a chunk of those messages died at addresses that no longer exist. Without bounce handling you never learn which ones, so the agent keeps emailing dead mailboxes, and every retry chips away at the sender reputation your deliverable mail depends on.
The fix is event-driven: bounces arrive as webhooks, and your agent's job is to listen and adapt.
When a recipient's server rejects a message, the provider generates a Non-Delivery Report — that "Mail Delivery Subsystem" email humans glance at and archive. Nylas watches for NDRs in the sender's mailbox and converts them into a structured message.bounce_detected
webhook, with the failed address, the reason, and the SMTP code already parsed out.
Subscribe to it like any other trigger by adding message.bounce_detected
to your webhook's trigger_types
:
curl --request POST \
--url "https://api.us.nylas.com/v3/webhooks" \
--header "Authorization: Bearer <NYLAS_API_KEY>" \
--header "Content-Type: application/json" \
--data '{
"trigger_types": ["message.bounce_detected", "message.send_failed"],
"callback_url": "https://yourapp.example.com/webhooks/nylas"
}'
For connected mailboxes, detection works on 4 providers — Google, Microsoft, iCloud, and Yahoo — because it depends on the provider issuing an NDR; generic IMAP and Exchange (EWS) accounts don't produce these events. If your outreach runs from a Nylas Agent Account (the hosted agent mailboxes, currently in beta), the platform owns the SMTP path end-to-end, so message.send_success
, message.send_failed
, and message.bounce_detected
give you send-side visibility on every outbound message.
Five fields carry the signal, per the bounce handling recipe:
{
"type": "message.bounce_detected",
"data": {
"grant_id": "<NYLAS_GRANT_ID>",
"object": {
"bounced_addresses": "no-such-user@example.com",
"bounce_reason": "The email account that you tried to reach does not exist.",
"type": "mailbox_unavailable",
"code": "550",
"bounce_date": "Mon, 08 Jun 2026 14:21:00 +0000"
}
}
}
bounced_addresses
is the address that failed, bounce_reason
is the human-readable explanation, type
is a category like mailbox_unavailable
, and code
is the SMTP status — note it's a string, so compare against "550"
, not 550
. The payload also includes origin
, the original message, which is how you tie the bounce back to the campaign and contact record that triggered it.
The code
field splits bounces into two categories with opposite responses:
For an outreach agent specifically, the suppression list should sit upstream of the send, as a pre-send check in the agent's pipeline. The flow becomes: agent selects contacts → filters against suppressions → sends → bounce webhooks feed new suppressions back in. The loop closes itself, and the list only grows more accurate.
Beyond per-address suppression, a bounce stream enables campaign-level reflexes that a send-and-forget script can't have:
Back off on bounce spikes. If a batch of sends produces a cluster of hard bounces, that's a data-quality signal — a stale list, a bad enrichment source. An agent that s the campaign and flags the list for review protects the sending domain; one that plows through the remaining contacts compounds the damage. This matters doubly on agent infrastructure, where sender reputation is shared across every account on the domain.
Annotate, don't just suppress. Write the bounce_reason
and type
back to your CRM or contact store. "Suppressed: mailbox_unavailable, 550, June 2026" tells a future human (or agent) why this contact went dark.
Expect latency. Detection is asynchronous — the NDR can land minutes after the send. Don't design a flow that assumes bounce status is known immediately after the API call returns; reconcile on the webhook, not inline.
One scoping note: this covers mailbox sends. If you're sending through the transactional Email API instead, the equivalent signal is message.transactional.bounced
— one of 4 transactional deliverability events alongside complaint, delivered, and rejected.
If your outreach runs on an Agent Account, there's an enforcement layer behind your suppression list. Nylas tracks each account's rolling hard-bounce rate — soft bounces don't count — against its recent send volume, per the send limits docs:
| Bounce rate | Account state | What happens |
|---|---|---|
| Under 2% | Healthy | Normal sending. |
| 5% or above | Under review | Sending continues; sustained elevated bounces lead to a . |
| 10% or above | Sending d | Outbound send requests fail until Nylas clears the . |
Two details make this worth designing around rather than discovering. "Under review" is silent — your code sees nothing until the hits, at which point sends start returning 400
errors. And s don't clear on a timer: resuming requires contacting support with the cause and the fix. An agent that suppresses hard bounces aggressively stays comfortably under 2% and never meets this table; an agent that ignores them works its way down it. Complaint rates get the same treatment with much tighter numbers — review at 0.1%, d at 0.5% — so honor unsubscribes immediately too.
The minimal viable version is genuinely small: one webhook subscription, one suppressions
table with address
, code
, reason
, and date
columns, and one WHERE NOT IN
clause on your send query. You can add retry budgets and spike detection later; the suppression check alone stops the reputation bleed.
Pull your outreach agent's sends from the last month and check how many went to addresses that had already hard-bounced. If the answer isn't zero, that's your sprint.