Short answer: own the order-message template and its version in the application, create one immutable notification intent per order, and treat an SMS timeout as an unknown outcome that must be reconciled before any retry can send again.
That choice is less convenient than passing free-form copy straight to a delivery adapter. It also gives a marketplace one stable place to answer the awkward questions: which wording did seller seller_1042 receive for order ord_84721, which data was rendered, and was a second attempt allowed? The evaluation constraint is duplicate prevention under an ambiguous network result, not raw request speed.
The simple design fails at exactly that boundary. An order handler renders a message, calls an SMS endpoint, waits, times out, and calls again. The first call may already have been accepted. A timeout describes what the caller observed; it does not prove that nothing happened downstream.
Keep those meanings separate.
Why template ownership decides the architecture
For a new-order alert, the marketplace owns the business statement: an order exists, the seller should open the order dashboard, and the content must correspond to a particular template revision. The transport owns delivery mechanics. Mixing the two makes a retry surprisingly dangerous because a later attempt might render changed copy, a changed link, or newly mutable order fields while still claiming to be the same notification.
I would store a template identifier and version beside the notification intent, then render from a small, validated data object. This is a ship-first choice — the schema can be boring — but it gives deployment rollback and audit work a clean boundary. If order-created-v3 changes tomorrow, today's queued order-created-v2 intent remains understandable.
There is a real trade-off. Application-owned templates add review, localization, and preview work to the codebase. They are not suitable when a nontechnical operations team must change approved copy several times a day without a deployment; in that case, keep template authoring in a controlled content system, but persist the exact template revision and input snapshot with each intent. Conversely, don't let a delivery provider become the only record of business wording when several channels must express the same order event.
The channel split matters too. SMS copy should be concise and transactional. If the same event also triggers commercial email, the email workflow has additional consent and unsubscribe concerns; RFC 8058 defines a one-click unsubscribe mechanism for relevant email messages, not for SMS. One event can feed both channels, but one undifferentiated template should not.
How should a Node.js Express service handle SMS timeout retry and status polling?
The HTTP handler should record the order event and notification intent, then return without holding the buyer's or seller's request open for delivery. A worker claims the intent and uses a deterministic idempotency key derived from stable business identity: seller, order, channel, and template version. The key answers “is this the same intended message?” It must not be a fresh random value on every attempt.
After submission, distinguish three states. A definite rejection can follow a documented retry policy when the condition is retryable. A definite acceptance moves to status observation. A timeout remains unknown; the worker should poll by the provider's returned message reference when one exists, or wait for the configured reconciliation window before making another submission decision. Don't translate unknown into failed just because that makes the state machine smaller.
Poll first.
| Observed state | Meaning | Next action |
|---|---|---|
queued |
Submission has a reference but no terminal result | Poll with backoff |
delivered |
Delivery is terminal | Stop |
rejected |
Submission is terminal and unsuccessful | Apply the documented retry policy |
unknown |
Acceptance cannot yet be proved or disproved | Reconcile before considering another submit |
Here is the focused TypeScript shape. The adapter deliberately hides vendor paths and response fields, while the application retains template ownership and retry policy.
type DeliveryState = "queued" | "delivered" | "rejected" | "unknown";
type OrderAlert = {
orderId: string;
sellerId: string;
phoneE164: string;
templateId: "order-created";
templateVersion: 3;
orderTotal: string;
};
type Submission = {
messageRef?: string;
state: DeliveryState;
};
interface SmsTransport {
submit(input: {
to: string;
body: string;
idempotencyKey: string;
}): Promise<Submission>;
getStatus(messageRef: string): Promise<DeliveryState>;
}
const intentKey = (alert: OrderAlert) =>
[alert.sellerId, alert.orderId, "sms", alert.templateId, alert.templateVersion].join(":");
function renderOrderAlert(alert: OrderAlert): string {
return `New order ${alert.orderId} for ${alert.orderTotal}. Open your seller dashboard.`;
}
async function advanceAlert(
alert: OrderAlert,
transport: SmsTransport,
previous?: Submission,
): Promise<Submission> {
if (previous?.messageRef && previous.state !== "rejected") {
return {
messageRef: previous.messageRef,
state: await transport.getStatus(previous.messageRef),
};
}
return transport.submit({
to: alert.phoneE164,
body: renderOrderAlert(alert),
idempotencyKey: intentKey(alert),
});
}
This example does not pretend status polling alone creates exactly-once delivery. It prevents the application from blindly resubmitting while it has a reference to reconcile, and the stable key lets an adapter use a transport's idempotency facility when available. The database still needs a unique constraint on the intent key and an atomic claim operation, or two workers can race before either saves the submission result.
The catch is transport capability. If a transport offers neither idempotent submission nor queryable status, the application cannot prove that an ambiguous request was not accepted. I'm not sure any generic timeout value solves that; only the transport's documented acceptance semantics and observed latency distribution can justify the reconciliation window. For high-consequence messages, use a channel or transport with an acceptance reference and status lifecycle rather than tuning retries by instinct.
The duplicate-send trap lives between records
An inbox-style outbox closes one important gap: the order transaction writes both the order state and a notification intent, so a process crash does not require reconstructing intent from logs. It does not close every gap. The critical sequence is claim, submit, save the external message reference. A crash after submit but before save leaves the same uncertainty as a timeout.
This is where the data model earns its keep. Put a unique constraint on (seller_id, order_id, channel, template_id, template_version). Store attempt_count, next_attempt_at, message_ref, delivery_state, and timestamps separately. An attempt is not a notification, and a notification is not a delivery receipt. Collapsing them into one sent boolean erases the evidence needed to decide safely. For ord_84721, imagine attempt 1 starts at 10:03:12, reaches the transport, and the connection times out before the response reaches the worker. The row remains unknown, not failed. If a late response or status lookup supplies msg_62a, the worker attaches that reference and observes it. Attempt 2 is permitted only by an explicit transition after reconciliation, and it reuses the same intent key. Those values are example data, not benchmark results; their purpose is to make the race visible.
No magic here.
Also make the rendering input immutable. If the seller changes a phone number or the order total is adjusted while an intent waits, decide whether that creates a replacement intent or cancels the old one. Silently mutating the payload under the same idempotency key makes deduplication semantically dishonest.
Status polling is a budgeted control loop
Polling should be finite, state-aware, and cheaper than creating uncertainty. Stop on terminal states. Back off between nonterminal checks. Apply jitter so a worker restart doesn't align every pending order on the same second, and cap concurrent checks so notification observation cannot starve new order processing.
Measure the loop before copying someone else's intervals: submission-to-acceptance latency, acceptance-to-terminal latency, percentage of unknown submissions, polls per terminal message, duplicate-intent conflicts, and age of the oldest unresolved intent. Segment by transport and message class. A single global average hides the tail that actually drives retries.
Cost belongs in this decision, but not as a vendor-price contest. Record cost per intent as submission attempts plus status checks and operational storage. A policy that sends fewer duplicates yet polls forever can still be wasteful; a policy that avoids polling may push expensive manual reconciliation onto support. Your mileage may vary because delivery latency and status granularity differ by transport, geography, and recipient network.
If an AI agent can initiate seller communication, keep it outside the delivery state machine. Give the agent a narrowly described tool that accepts an order identifier and requested action, then have deterministic application code load the approved template, validate authorization, and create the idempotent intent. Tool definitions guide model behavior, but they do not replace database constraints or delivery reconciliation.
What to verify before adopting this pattern
Run a controlled failure test around the submit boundary. Delay the response after acceptance, terminate a worker before it saves the message reference, and start two workers against one ready intent. The expected invariant is one canonical intent with a stable key; the system should preserve uncertainty rather than manufacture a clean-looking failure.
Then verify the less dramatic cases: template rollback keeps queued revisions renderable, redacted logs still correlate by intent and message reference, a terminal rejection does not poll forever, and retention rules remove phone numbers without destroying aggregate delivery metrics. Stick with synchronous delivery only when the caller truly needs the immediate transport result and duplicate risk is acceptable. For marketplace order alerts, decoupled intent processing is usually the more honest model because it exposes the asynchronous reality instead of burying it inside an Express timeout.
The final decision rule is plain: choose the design whose records can explain an ambiguous attempt without sending again to find out. Template ownership, idempotency, and polling are useful because they support that explanation. None of them, alone, guarantees exactly-once delivery.
Top comments (0)