From b645a8370f56d569f7495ce80a60e9eeefb0b967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Sat, 17 Oct 2026 10:01:48 -0300 Subject: [PATCH 1/2] README lists the email.scheduled, email.failed and email.suppressed webhook events They sit in server order: email.scheduled before email.sent, and email.failed and email.suppressed right after email.complained. The README notes data.failed.reason and data.suppressed (type, message, reason) as open sets, source_id on suppression.added and suppression.removed, and that the three need MillionSend v0.15.1 or later, in the webhooks section and the server versions table. Co-Authored-By: Claude Opus 5.5 --- README.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6e70846..07d2f5a 100644 --- a/README.md +++ b/README.md @@ -458,8 +458,9 @@ During the overlap window (`overlap_hours`, 0–72, default 24) every delivery c signatures, so a receiver holding either verifies; `Webhooks.get` reports the window's end as `previous_secret_expires_at` (`nil` when none is open). -Events: `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, -`email.complained`, `email.opened`, `email.clicked`, `contact.created`, `contact.updated`, +Events: `email.scheduled`, `email.sent`, `email.delivered`, `email.delivery_delayed`, +`email.bounced`, `email.complained`, `email.failed`, `email.suppressed`, `email.opened`, +`email.clicked`, `contact.created`, `contact.updated`, `contact.deleted`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`, `contact.topic_opt_out`, `suppression.added`, `suppression.removed`, `deliverability.warning`, `deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`, `email.received`, and @@ -471,6 +472,12 @@ endpoint gets them only when its `events` list names them. A shared-domain inbox 90 unused days sends `inbox.expired`, then `inbox.deleted`, and inbox events from a send in the dashboard carry `source: "dashboard"` in their `data`. +`email.failed` says why in `data.failed.reason`, and `email.suppressed` in `data.suppressed` +(`type`, `message` and `reason`); those values are an open set, so treat them as strings, not a +closed enum. `suppression.added` and `suppression.removed` carry `data.source_id`, the email whose +bounce or complaint created the entry (`nil` otherwise). On servers before v0.15.1, a webhook that +names `email.scheduled`, `email.failed` or `email.suppressed` raises `ValidationError`. + ### API keys ```ruby @@ -597,6 +604,7 @@ On a self-hosted instance, each feature needs at least this MillionSend version: | v0.12.0 | `username`; inboxes that never expire by default; sends from shared-domain inboxes; name review (`NameReviewUnavailableError`, the daily budget); `statusCode`/`name` on contacts-batch errors | | v0.13.0 | Newest-first lists; `since` on `Codes.latest`; `quarantined` on thread items; inbox names up to 320 characters; `NotFoundError` at once for an unknown `inbox_id`; `unread` on inbox-scoped keys; JSON 404 for unknown routes | | v0.14.0 | The shared row in `Domains.list` (`shared`, `sending_mode`, `sending_mode_reason`); `domain_id` on `Inboxes.create`; `subject` up to 998 characters | +| v0.15.1 | The `email.scheduled`, `email.failed` and `email.suppressed` webhook events; `source_id` on `suppression.added` and `suppression.removed` | An older server ignores options it doesn't know: before v0.14.0 `domain_id` (check the created inbox's `email_address`), before v0.13.0 `since` (a code already used can come back), and before From f4898b5a944c4b018a7c16b074797055ec39466e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=A9lio=20Costa?= Date: Sat, 17 Oct 2026 10:31:54 -0300 Subject: [PATCH 2/2] README names the opt-in email.prefetched event and the email kept on AllRecipientsSuppressedError email.prefetched joins the webhook events after email.clicked, with email.received right after it, as in the server's order, and like the inbox events it reaches only endpoints that name it. The AllRecipientsSuppressedError note and the v0.15.1 row say that an Emails.send the suppression list alone refuses still keeps a metadata-only suppressed email, which fires email.suppressed, and that each retry keeps another, even with the same idempotency_key. Co-Authored-By: Claude Opus 5.5 --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 07d2f5a..00fe6a0 100644 --- a/README.md +++ b/README.md @@ -460,17 +460,17 @@ as `previous_secret_expires_at` (`nil` when none is open). Events: `email.scheduled`, `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, `email.complained`, `email.failed`, `email.suppressed`, `email.opened`, -`email.clicked`, `contact.created`, `contact.updated`, +`email.clicked`, `email.prefetched`, `email.received`, `contact.created`, `contact.updated`, `contact.deleted`, `contact.unsubscribed`, `contact.resubscribed`, `contact.topic_opt_in`, `contact.topic_opt_out`, `suppression.added`, `suppression.removed`, `deliverability.warning`, -`deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`, `email.received`, and +`deliverability.paused`, `quota.warning`, `quota.reached`, `quota.paused`, and the inbox events `inbox.created`, `inbox.updated`, `inbox.deleted`, `inbox.expired`, `inbox.thread.created`, `inbox.thread.folder.updated`, `inbox.thread.labels.updated`, `inbox.email.received`, `inbox.email.sent`, `inbox.email.quarantined`, `inbox.draft.created`, -`inbox.draft.updated`, `inbox.draft.sent`, `inbox.draft.deleted`. Inbox events are opt-in: an -endpoint gets them only when its `events` list names them. A shared-domain inbox deleted after -90 unused days sends `inbox.expired`, then `inbox.deleted`, and inbox events from a send in the -dashboard carry `source: "dashboard"` in their `data`. +`inbox.draft.updated`, `inbox.draft.sent`, `inbox.draft.deleted`. `email.prefetched` and inbox +events are opt-in: an endpoint gets them only when its `events` list names them. A shared-domain +inbox deleted after 90 unused days sends `inbox.expired`, then `inbox.deleted`, and inbox events +from a send in the dashboard carry `source: "dashboard"` in their `data`. `email.failed` says why in `data.failed.reason`, and `email.suppressed` in `data.suppressed` (`type`, `message` and `reason`); those values are an open set, so treat them as strings, not a @@ -575,6 +575,9 @@ before v0.13.0 answered it with text, which raises `ApplicationError` with statu `Emails.send` and `Batch.send` raise `AllRecipientsSuppressedError` (`all_recipients_suppressed`, 422) when every `to` recipient is on the suppression list or opted out of the send's `topic_id`. +An `Emails.send` that the suppression list alone refuses still keeps a metadata-only `suppressed` +email, which fires `email.suppressed`, and each retry keeps another, even with the same +`idempotency_key` (a refused send doesn't hold its key). ### Retries @@ -604,7 +607,7 @@ On a self-hosted instance, each feature needs at least this MillionSend version: | v0.12.0 | `username`; inboxes that never expire by default; sends from shared-domain inboxes; name review (`NameReviewUnavailableError`, the daily budget); `statusCode`/`name` on contacts-batch errors | | v0.13.0 | Newest-first lists; `since` on `Codes.latest`; `quarantined` on thread items; inbox names up to 320 characters; `NotFoundError` at once for an unknown `inbox_id`; `unread` on inbox-scoped keys; JSON 404 for unknown routes | | v0.14.0 | The shared row in `Domains.list` (`shared`, `sending_mode`, `sending_mode_reason`); `domain_id` on `Inboxes.create`; `subject` up to 998 characters | -| v0.15.1 | The `email.scheduled`, `email.failed` and `email.suppressed` webhook events; `source_id` on `suppression.added` and `suppression.removed` | +| v0.15.1 | The `email.scheduled`, `email.failed` and `email.suppressed` webhook events; `source_id` on `suppression.added` and `suppression.removed`; a `suppressed` email kept on `AllRecipientsSuppressedError` | An older server ignores options it doesn't know: before v0.14.0 `domain_id` (check the created inbox's `email_address`), before v0.13.0 `since` (a code already used can come back), and before