The difficult part of marketplace event notifications is not choosing an email or SMS API. It is deciding who owns the sentence that a support agent receives, and proving that the sentence matched the routing decision. I keep the contact-form event, queue policy, and template version in application-owned records; delivery services remain adapters around that decision.
TL;DR: compare providers by the evidence they return and by the policy boundaries they leave visible. A good implementation can answer four questions for every alert: which form event created it, which queue was selected, which template version rendered it, and what delivery state was observed. The provider request ID answers only one smaller question.
Who should own the message a support queue receives?
For a marketplace, a contact form is an operational event before it is a message. A listing report may belong to trust and safety, an order question to order support, and a seller payout question to seller operations. The queue choice should be reviewable without opening a communications dashboard.
Template ownership follows the same rule. I would keep the canonical template and its version beside the routing policy, with review tied to the fixture that exercises it. A communications editor can still propose copy, but the active version needs an immutable identifier and an approval trail. Otherwise a wording edit can silently change the meaning of an alert while the routing code remains unchanged.
This is a governance choice with a technical consequence: the delivery adapter accepts a fully formed notification. It does not decide the queue, infer urgency from free text, or fetch the only copy of a template from a remote editor. That boundary makes an evaluation useful before any network call exists.
That is the boundary.
What does the smallest useful implementation look like?
The data flow is deliberately plain: accept the form event, map its category to a queue, choose a channel-specific template, render a bounded payload, and hand that payload to an adapter. Delivery observations return later and are joined to the business event, rather than replacing it.
Here is the application-owned portion. It is runnable Python and intentionally has no provider SDK in the testable path.
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class ContactEvent:
event_id: str
category: str
summary: str
@dataclass(frozen=True)
class Notification:
event_id: str
queue: str
channel: str
destination: str
template_version: str
body: str
class DeliveryAdapter(Protocol):
def send(self, notification: Notification) -> str:
"""Return an opaque delivery request ID."""
ROUTES = {
"listing_report": "trust-and-safety",
"order_question": "order-support",
"seller_payout": "seller-operations",
}
TEMPLATES = {
("trust-and-safety", "email"): "Report {event_id}: {summary}",
("order-support", "email"): "Order question {event_id}: {summary}",
("seller-operations", "sms"): "Seller case {event_id}: {summary}",
}
def build_notification(
event: ContactEvent, channel: str, destination: str
) -> Notification:
queue = ROUTES[event.category]
template = TEMPLATES[(queue, channel)]
return Notification(
event_id=event.event_id,
queue=queue,
channel=channel,
destination=destination,
template_version="2026-02",
body=template.format(event_id=event.event_id, summary=event.summary),
)
The adapter interface exposes one deliberate omission: it does not return “seen by a human.” An accepted request ID is evidence of a handoff, not proof of delivery or reading. That distinction prevents an operator from closing a support case on the strength of the wrong identifier.
How can a team evaluate providers without handing them template control?
I start with fixtures, not a feature matrix. Six cases cover most of the dangerous edges for this workflow: an ordinary order question, a duplicate submission, an unknown category, an invalid destination, an opt-out transition, and a long seller summary. The long summary is especially useful because it forces the email and SMS renderers to make different decisions instead of sharing a body by accident.
Each candidate adapter receives the same internal notification. The test records queue, channel, template version, redaction decision, and normalized status. Provider request IDs, raw callback names, and timestamps remain available for diagnosis, but they are not comparison keys. This is the notebook-to-prod handoff I want: the fixture is readable in a notebook, then runs unchanged in CI and in a staging replay.
I initially treated the delivery request ID as the reconciliation key. Later I separated it from the business event ID. The event ID identifies the support action; the request ID identifies one delivery attempt. That small correction matters when a callback arrives late or a worker retries after a timeout.
For example, imagine two submissions with the same client idempotency key arriving 40 seconds apart. The first worker creates evt_1842 and waits for an observation. The second worker should preserve the duplicate relationship instead of creating a second support alert just because the first delivery is unresolved. If the first attempt later fails with a documented transient condition, a retry can create a new delivery attempt while still pointing to evt_1842. A late success callback must update the old attempt; it must not erase the duplicate record or silently turn the retry into a second business event. That distinction is easy to lose when the provider request ID is stored as the only identifier, and it is exactly the sort of case that belongs in the fixture set before production traffic arrives.
from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True)
class DeliveryObservation:
event_id: str
provider: str
provider_request_id: str
status: str
observed_at: datetime
raw_event_type: str
def is_terminal(status: str) -> bool:
return status in {"delivered", "failed", "suppressed", "opted_out"}
The raw callback type stays available for diagnosis while the application uses a small vocabulary for retries and reports. I would make every fixture assert that a duplicate event maps to the same business action, while a retryable delivery attempt gets its own attempt record. A late callback updates an observation; it does not create permission to send a second support alert.
Which provider differences change the ownership decision?
The comparison question is useful only after the boundary is explicit. Twilio documents messaging resources and status callbacks. SendGrid documents an Event Webhook for email activity. Mailgun documents email event webhooks, including accepted, delivered, failed, and complained events. These are different evidence surfaces, not interchangeable definitions of “delivered.”
| Candidate | Documented evidence surface | Ownership implication to test |
|---|---|---|
| Twilio | Messaging resources and status callbacks | Keep SMS policy and email policy independently testable |
| SendGrid | Email Event Webhook | Supply a separately owned SMS adapter when the workflow needs SMS |
| Mailgun | Email event webhooks | Ensure email activity reporting does not obscure the SMS path |
The matrix is a starting point, not a ranking. For each candidate, test sender identity requirements, callback authentication, regional support, retention controls, idempotency behavior, and the process for a blocked sender. Also test whether an operator can trace one marketplace event without depending on a dashboard that stores the only copy of the template.
The dashboard is not the source of truth.
There is a real trade-off here. A split setup gives each channel a clearer policy boundary, but it increases fixture coverage, callback normalization, and ownership conversations. A single integration can reduce the number of adapters while still leaving two different opt-out regimes and two different rendering constraints. Count the policies and failure states, not just the SDKs.
What changes when email and SMS share one event?
The event can be shared; the rendered message should not be assumed identical. A trust-and-safety email may contain a case link and a redacted summary. An operations SMS may need only an event ID and a short instruction to open the internal case. Keeping those templates separate makes length, urgency, and sensitive-data review visible.
Email unsubscribe handling also deserves its own test. For applicable messages, verify that the one-click mechanism follows RFC 8058. SMS opt-out transitions need regional test cases and a recorded state change. Neither policy should be hidden inside a generic “notification sent” flag.
The same discipline applies to cost. Token or character length can be a useful engineering signal, but price should not drive the ownership model. A shorter payload that leaks a private summary is a failed design at any price. I would log a redacted payload hash, template version, channel, and event ID, while avoiding rendered personal data in ordinary logs.
Before changing a default adapter, review the template diff and its fixture changes together. Confirm routing, redaction, unsubscribe behavior, SMS opt-out, callback authentication, deduplication, status normalization, and event lookup. Then inspect mismatches from the same fixtures in both paths. The checklist is a release gate, not a post-incident memory aid.
The durable comparison for 2026 event notifications is therefore about control and evidence. Keep queue selection and template versions owned by the application, let delivery services provide channel-specific observations, and choose the adapter whose boundaries your team can test and operate. That produces a provider-neutral decision without pretending email and SMS are the same system.
Sources
- https://datatracker.ietf.org/doc/html/rfc8058
- https://www.twilio.com/docs/messaging/guides/webhook-request
- https://www.twilio.com/docs/messaging/api/message-resource
- https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/event
- https://documentation.mailgun.com/docs/mailgun/user-manual/events/webhooks
Top comments (0)