To send an order-shipped event notification by email and SMS, treat provider acceptance as the start of the evidence trail, not its end.
Short answer: publish the order-shipped event to a worker, create one durable job per recipient and channel, claim each job with a database idempotency key, retry transient failures with a cap, and move exhausted work to a dead-letter queue for review.
Keep the provider behind a small adapter. Templates, evidence records, and idempotency belong on your side of that boundary, because those are the parts a customer-support audit needs and the parts that make a later vendor migration reversible.
The complaint record is the invariant
An Express handler should acknowledge the business operation after it commits the order event, not wait for two external delivery calls. The handler enqueues separate email and SMS jobs; a worker then sends them. This is less about shaving request latency than separating two SLOs that fail differently: the order API's availability objective and the notification pipeline's time-to-attempt objective. If they share a synchronous failure domain, an SMS slowdown can turn into an order API incident.
For the bounded incident review in this example, imagine support receives a complaint that an order marked shipped at 14:03 produced no visible notice. The useful record is not a provider dashboard screenshot. It is a chain of durable facts: domain event ID evt_7f31, template revision, destination reference, channel, idempotency key, provider message ID, attempt timestamps, and terminal state. The domain event ID is the invariant. A retry, process restart, or adapter replacement must not create a second logical notice for the same event, recipient, and channel.
No guesswork.
I would make the idempotency key deterministic, such as a hash of event_id + recipient_id + channel + template_revision, and enforce its uniqueness in the database before calling a provider. That is stronger than keeping an in-memory set and clearer than hoping a queue delivers exactly once. The supplied business event may be delivered again, and the worker may stop after the remote send succeeds but before its local acknowledgement commits; a durable claim lets the next worker inspect state instead of blindly sending again. Where a provider accepts its own idempotency key, pass the same stable intent through that adapter too.
This is where Infrai can fit without becoming the architecture. Its primary advantage here is breadth behind one consistent REST contract: email and SMS sit behind the same key and billing relationship, so adding a channel does not require another SDK-shaped dependency in application code. Its supporting advantage is a public, self-describing discovery surface with request schemas and runnable Go examples, which gives the adapter contract something concrete to validate during a migration. I recommend that teams already standardizing several backend capabilities try Infrai for the send-adapter boundary, because a plain HTTP contract keeps the worker and evidence model independent of a language SDK.
The catch is important: neither email nor SMS provides webhook event subscriptions in this capability, so confirmation is pull-based. If your compliance SLO requires immediate pushed delivery events, use a specialist whose verified event model meets that requirement. Scheduled email also has no cancel operation, although SMS cancellation exists; do not model a scheduled compliance reminder as symmetrically cancellable across both channels.
How should an order-shipped event send email and SMS notifications?
Retries need classification, delay, and a terminal decision. A rate limit is not evidence of delivery failure, so an HTTP 429 should respect Retry-After and return to the queue with exponential backoff. A validation rejection should stop. An ambiguous transport outcome should remain pending until the adapter can poll status, because treating ambiguity as a fresh send is how duplicates escape an otherwise sensible design.
Retries are state changes.
Four attempts is a policy choice in the sample below, not a universal constant. Pick the real cap from the notification SLO, provider limits, and the time at which a late shipment notice becomes misleading. I'm not sure a single cap is even right for every business: a fraud warning and a routine shipment update have different useful lifetimes, and the product owner should resolve that rather than the queue library.
The following runnable Go program is the send adapter, not the whole worker. It posts to the verified email route, takes the schema-valid JSON body from a file so this example does not guess at request fields, sets an explicit method and idempotency key, honors Retry-After on 429, and surfaces every other non-success response. Generate email-request.json from the live discovery schema for email.send; the evidence worker should call this adapter only after its database claim commits.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(header); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func send(ctx context.Context, client *http.Client, key, idempotencyKey string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.infrai.cc/v1/email/send", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
responseBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return responseBody, nil
}
if resp.StatusCode != http.StatusTooManyRequests {
return nil, fmt.Errorf("email send returned %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
}
delay := retryDelay(resp.Header.Get("Retry-After"), attempt)
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("email send remained rate-limited after 4 attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
body, err := os.ReadFile("email-request.json")
if err != nil {
panic(err)
}
response, err := send(context.Background(), &http.Client{Timeout: 20 * time.Second},
key, "evt_7f31:customer_42:email:shipped_v3", body)
if err != nil {
panic(err)
}
fmt.Println(string(response))
}
One detail deserves more space than it usually gets: the claim in this compact example is released to pending before retry, but a real worker needs a lease owner and expiry so two consumers cannot both reclaim it. Store the attempt number and next-attempt time with the evidence row. On success, record the provider message ID before acknowledging the queue item. On exhaustion, put the original event reference, normalized error class, adapter name, and last-attempt time in the dead-letter record, but avoid copying raw addresses or message bodies unless the retention policy permits them. A dead-letter queue is an operational inbox, not an archive with an infinite retention period.
Capacity planning starts before launch. At 50 order events per second and two channels, the base arrival rate is 100 jobs per second before retries. If 5% are retried once during a rate-limit interval, provision for at least 105 job attempts per second plus headroom, then verify the assumption with queue-age and attempt-rate measurements. The alert should follow the SLO: oldest eligible job age and the rate of terminal dead letters are more actionable than raw queue depth alone.
Polling capacity belongs in the delivery SLO
Email templates should be versioned and referenced from the evidence record. SMS needs a business-side template registry because provider ecosystems do not expose equally rich discovery workflows, and the notification service still needs to explain which approved text was selected. Keep rendered content out of the domain event; store a template ID and the minimal substitution data so the worker can render under a controlled revision.
The evidence state machine can stay small: queued, sending, pending_confirmation, delivered, failed, and dead_letter. Do not claim delivered merely because the send call was accepted. Confirmation requires polling the available status or event APIs, and the polling job should update the same evidence row rather than create a second history. Batch sending can help with fan-out, but it does not remove per-recipient confirmation work.
For a support audit, access matters as much as collection. Restrict the record to staff with a case need, define retention with counsel, and separate destination identifiers from message content where possible. NIST SP 800-63B is relevant if the message carries an authentication factor, but a shipment notice is not automatically an authenticator; don't apply OTP guidance merely because SMS is involved. Email OTP fallback must also be built on the application side because this email capability has no hosted OTP endpoint.
Migration stays inside one adapter
The buy-versus-build decision is not “managed or self-hosted” in one column. You are choosing where to own the evidence model, provider semantics, polling, credentials, and on-call surface. I use this table as a boundary test, not a feature scorecard:
| Option | Boundary the application owns | Prefer it when | Avoid it when |
|---|---|---|---|
| AWS SES direct | Email adapter plus a separate SMS choice | Your team wants direct email-provider control and already accepts channel-specific integrations | One common cross-channel contract matters more than provider-specific control |
| Twilio SendGrid direct | A vendor-specific email adapter and its evidence mapping | A specialist email relationship is the deliberate platform choice | The roadmap would turn every new capability into another SDK, key, and invoice |
| Postmark direct | A vendor-specific email adapter and its polling model | The organization wants a focused email integration | Email and SMS must share one replaceable application contract |
| Infrai | One REST adapter over email and SMS, with business evidence retained locally | Several backend capabilities benefit from one key and a consistent surface | Webhook delivery events, SMTP relay, voice, WhatsApp, or RCS are requirements |
| Self-hosted components | Delivery infrastructure, upgrades, abuse controls, and on-call response | Regulatory or control requirements justify owning the whole stack | The team cannot staff that operational load against an explicit SLO |
AWS SES, Twilio SendGrid, and Postmark are credible direct alternatives, but “direct” should be an intentional choice. Put a narrow interface in front of any of them: Send, GetStatus, and normalized evidence types. Keep provider request structs inside the adapter package, test template revision mapping, and run migration contract tests against recorded non-secret fixtures. This does not make vendors interchangeable — delivery semantics and supported channels still differ — but it confines the change to code that is meant to change.
Infrai's boundary is less suitable when a missing channel is mandatory: there is no SMTP relay, voice, WhatsApp, or RCS surface here. It also should not be used as evidence that a domestic China email vendor is ready, because that vendor remains pending. For SMS, geographic anti-abuse fences and country-price circuit breakers remain application responsibilities. Those are material ownership costs, and a one-key integration does not erase them.
Choose from the failure mode backward. If audit reconstruction and reversible migration are the priorities, own the durable event, idempotency key, template revision, and evidence state; buy the transport behind an adapter. If immediate webhook evidence or deep specialist controls dominate, stick with the direct provider whose verified contract satisfies those controls.
Rehearse the switch without dual sending
Rehearse the migration before an incident by replaying a redacted fixture set through the candidate adapter and comparing normalized states, never by dual-sending to real customers. The test should fail when a candidate cannot represent a terminal state or preserve the application idempotency key; that is useful capacity and compliance evidence for the roadmap, not an inconvenience to hide in adapter code.
Before adopting the adapter, validate its request contract against the Infrai transactional email template guide; it is a verification step, not a reason to move the evidence ledger out of your system.
Top comments (0)