Short answer: use a durable event record with asynchronous delivery workers; prefer webhooks for provider callbacks, and keep bounded polling as a reconciliation path. For an edtech app sending a generated report as an email attachment, the compliance decision is about proving what happened to each notification, not choosing the longest feature list.
I care about the boring evidence: the event ID, report digest, recipient decision, provider request, callback, and final disposition. A green HTTP response is not delivery proof. It is one observation in a longer chain.
The incident lesson: delivery is a state machine
A report job can finish while its notification is still queued. A worker can retry after a timeout even though the provider accepted the request. That is how duplicate emails happen. I've been paged for missed jobs and duplicate deliveries, and the useful postmortem question was never “which API call looked successful?” It was “which transition did we fail to record?”
An SMS adds a different trap: its character encoding and length can change the number of segments, so a message that looks short in a test can have a different operational shape in production. Twilio documents the GSM-7 and UCS-2 distinction and its effect on segmentation.
The invariant is simple: assign one durable notification ID before sending, and make every transition append-only or otherwise auditable. Store created, send_attempted, accepted, delivered, bounced, and failed as distinct facts. Do not overwrite an accepted event with a later timeout. A timeout means the client lacks an observation; it does not prove rejection.
The preventative path can stay small. The important part is the idempotency key and the boundary around the provider call:
package notifications
import "fmt"
type Notification struct {
ID string
EventID string
Kind string
Digest string
Status string
}
func prepare(n Notification) (Notification, error) {
if n.ID == "" || n.EventID == "" || n.Digest == "" {
return Notification{}, fmt.Errorf("missing audit identity")
}
if n.Kind != "email" && n.Kind != "sms" {
return Notification{}, fmt.Errorf("unsupported channel")
}
n.Status = "send_attempted"
return n, nil
}
The example assumes the caller has already persisted the record and that the sender uses Notification.ID as its idempotency key. A real implementation also needs an outbox or equivalent transaction boundary so a committed report cannot lose its notification event.
Should event notifications use webhooks or polling for email and SMS app alerts?
Webhooks are the efficient primary signal when a provider can report accepted, delivered, bounced, or failed states. Verify the callback signature, retain the raw payload, record the receipt time, and make the callback handler idempotent. Return quickly; perform evidence enrichment outside the request path. A callback is an input to your state machine, not permission to trust arbitrary status text.
Polling is useful when a callback is delayed, unavailable, or incomplete. It gives you a reconciliation job: find notifications stuck in an observable intermediate state, query the provider's status endpoint, and record the result with the query time and source. Polling every record forever is wasteful and can create rate-limit pressure. Poll only a bounded window, use exponential backoff, and stop when the retention policy says the evidence is complete. I'm not sure a 60-second polling window is acceptable for your workload; the audit owner has to set that limit from the real reporting requirement.
The two approaches cover different failure modes. Webhooks reduce detection latency but depend on an exposed receiver, signature validation, and provider retry behavior. Polling is easier to reason about from a firewall-restricted worker, but it creates delay and can miss information that the provider no longer retains. Keep both only when the compliance requirement justifies the operational surface.
What should an email and SMS API comparison measure?
Start with evidence requirements, then compare interfaces. For email attachments, check how the API represents attachment bytes or references, what request and message identifiers it returns, how delivery events are signed, and how long event data remains queryable. Resend's documentation is a useful example of the API and onboarding detail to inspect, but its feature set should not become your acceptance checklist.
For SMS, test encoding explicitly. A Unicode character can move a message into UCS-2, changing segmentation; preserve the exact rendered body and encoding decision in the audit record. Also measure sender registration requirements, country coverage, opt-out handling, delivery-event detail, and regional data-processing constraints. US, EU, and Taiwan routes can have different regulatory and carrier behavior, so a generic “global” label is not evidence.
A practical comparison table looks like this:
| Criterion | Why it matters for compliance evidence | Test artifact |
|---|---|---|
| Request identity | Connects an app event to a provider attempt | Stored idempotency key and response ID |
| Callback authenticity | Prevents forged delivery state | Signature verification test and raw payload |
| Reconciliation | Covers lost or late callbacks | Bounded polling report |
| Content fidelity | Proves what the recipient was meant to receive | Report digest, attachment metadata, rendered SMS body |
| Retention | Determines whether an audit can be reconstructed | Documented TTL and export check |
| Regional behavior | Avoids assuming one route has one policy | Per-region integration test and routing record |
A provider with a polished API can still be a poor fit if it cannot supply the evidence your reviewers require.
Building the runbook around retries and proof
Treat retries as a data problem. A worker should claim an outbox item, send with the stable notification ID, and classify the result as accepted, rejected, or unknown. Retry rejected requests only when the error is explicitly transient. For unknown results, reconcile before creating a new provider request; otherwise a timeout becomes a duplicate delivery.
Alert on age and missing evidence, not just process crashes. Useful signals include notification age by status, callback-to-accept latency, reconciliation volume, duplicate-key conflicts, bounce rate, and SMS segment distribution. Treat an HTTP 429 as a retry and scheduling signal, not as permission to create another notification. Keep a per-report audit view that lets an operator answer: which artifact was sent, to whom, through which channel, with which consent decision, and what evidence supports the final status?
The catch is that this architecture is not suitable when you need a tiny, synchronous prototype with no compliance or retry obligations. Use the simplest direct API call there, and accept its weaker audit trail. Stick with a webhook-only design when provider callbacks are authenticated, retained long enough, and covered by a tested replay or reconciliation process. Choose a polling-heavy design when callbacks cannot meet your network or evidence constraints, while budgeting for delay and rate limits.
Three words: prove the transition.
References
- Resend official documentation: https://resend.com/docs/introduction
- Twilio, SMS character limits and segmentation (GSM-7/UCS-2): https://www.twilio.com/docs/glossary/what-sms-character-limit
Further reading
- Resend official documentation: https://resend.com/docs/introduction
- Twilio, SMS character limits and segmentation (GSM-7/UCS-2): https://www.twilio.com/docs/glossary/what-sms-character-limit
Top comments (0)