Scheduled SMS alerts for a fintech marketplace should be treated as a durable workflow, not a delayed send() call. The deciding constraint is delivery reliability: persist intent, make every transition idempotent, and let status polling reconcile what the provider actually accepted.
A timer in the application process is the tempting first draft. It also disappears during a deploy, retries twice after a timeout, and makes cancellation a race. I use a database-backed outbox plus a small worker instead. The choice is less glamorous, but it gives the seller one explainable record for an order notification.
Ship it carefully.
How should a transactional app backend handle scheduled SMS alerts, cancellation, and status polling?
Start with an intent row keyed by orderId and eventType. Store the recipient's normalized number, the US or EU routing region, the template version, sendAt, and a unique idempotency key. A worker claims due rows with a short lease. The lease is not a promise of delivery; it only prevents two workers from sending at the same moment.
Here is the shape of the boundary in a Node.js service. The transport is deliberately generic, so the rest of the application does not depend on one SDK or response vocabulary.
type AlertState = "scheduled" | "submitted" | "delivered" | "failed" | "cancelled";
type SmsRequest = {
idempotencyKey: string;
to: string;
body: string;
region: "US" | "EU";
};
type SmsReceipt = {
providerId: string;
state: "accepted" | "rejected";
};
interface SmsTransport {
submit(request: SmsRequest): Promise<SmsReceipt>;
lookup(providerId: string): Promise<"accepted" | "delivered" | "failed">;
}
async function dispatch(row: {
id: string;
orderId: string;
to: string;
body: string;
region: "US" | "EU";
}, transport: SmsTransport): Promise<void> {
const receipt = await transport.submit({
idempotencyKey: `order:${row.orderId}:seller-alert`,
to: row.to,
body: row.body,
region: row.region,
});
await saveTransition(row.id, receipt.state === "accepted" ? "submitted" : "failed", receipt.providerId);
}
async function saveTransition(id: string, state: AlertState, providerId?: string): Promise<void> {
// The database update is conditional on the current state and lease owner.
void id; void state; void providerId;
}
The important detail is the conditional write, not the interface name. If a request times out after the remote system accepted it, retrying with the same idempotency key must return the same logical submission. Without that property, a seller can receive two messages for one order. That is a ledger problem before it is an SMS problem. In a real order path, the timeout can happen after DNS resolution, after the carrier accepts the payload, or while the response is crossing a regional network boundary; those cases look identical to the caller, so the database must preserve an unknown observation until reconciliation proves otherwise. I also keep the original template hash and attempt timestamp, because support needs to explain what was sent when a seller reports an unexpected reminder.
Where do cancellation and status polling fit in the workflow?
Cancellation is a state transition with a deadline. Before sendAt, mark the row cancelled under the same lock used by the worker. After a worker has submitted it, cancellation can only stop future retries; it cannot recall a message already handed to a carrier. The UI should say that plainly.
Polling fills the gap between accepted and delivered. Keep the provider message ID, poll with exponential backoff, and stop after a defined horizon. A delivered event is useful, but absence of that event is not proof of failure. Record the last observed state, timestamp, and reason code so support can distinguish a handset problem from a routing rejection.
I once treated a 202 response as delivery. That was wrong. It meant the request had entered a queue, and our dashboard called the seller notified before any handset acknowledgement existed. The fix was a separate submitted state and a reconciliation job. Small change. Big difference.
What changes for US and EU transactional traffic?
Region is data, not a formatting hint. Keep consent evidence, purpose, sender identity, and opt-out history with the alert record. US traffic often depends on application-to-person registration and carrier filtering; EU traffic adds country-specific sender rules and privacy obligations. Do not infer permission from an order alone.
Use a template registry with an immutable version, and render the exact body before enqueueing. Limit sensitive order data in the message, redact numbers in logs, and give support a correlation ID rather than the full text. For one-click email unsubscribe, RFC 8058 specifies the POST-based mechanism; the analogous SMS lesson is to make opt-out handling explicit and auditable.
WebOTP is a browser API for receiving one-time codes, not a general delivery receipt mechanism. It can help a web checkout read a code with user consent, but it does not replace server-side status reconciliation.
Which reliability tests should run before shipping?
Test the unhappy paths as first-class behavior: worker crash after submission, duplicate queue delivery, clock skew around sendAt, cancellation racing with a lease, and a status response that moves backward. A property test can assert that one idempotency key creates at most one provider submission. A replay test can feed the same webhook or polling result ten times and expect one state change.
Measure the things a seller experiences: time from scheduled to submitted, submitted to delivered, cancellation success before the deadline, duplicate rate, and unknown-status age. Break those metrics down by US/EU route and template version. Your mileage may vary across carriers; I am not sure a single global delivery target is meaningful until those dimensions are visible.
The catch is operational overhead. A database outbox, lease management, and reconciliation worker are not suitable for a tiny internal tool that can tolerate a missed reminder. Stick with a managed scheduler when the message is non-critical and an occasional duplicate has no material cost. For a marketplace order, keep the durable workflow and make the trade-off explicit.
Before copying this design, run a week of shadow measurements: queue delay, carrier acceptance, handset delivery, and opt-out latency. Reliability is the decision axis, but it is only credible when each transition has a timestamp and an owner.
Top comments (0)