TL;DR: For a marketplace seller notification, a Node.js service sending SMS event notifications using common APIs should use the least complex dependable design: an outbox record, one idempotent notification key, a queue, and a small adapter around a provider's documented HTTP API. Treat the API response as acceptance of a send attempt, not proof that a handset displayed anything. Keep the event, attempt metadata, and delivery status on different retention clocks.
That answer matters because the bill is mostly driven by message attempts, while the operational cost of an SMS feature is driven by the records and retries kept around those attempts. For 10,000 new orders, a policy of one initial SMS means 10,000 first attempts. A retry policy can add another 10,000 attempts if every first attempt times out; the exact charge depends on the chosen service and destination, so I would model volume as orders x notification policy x attempts, then verify the provider's current terms separately. Do not build the architecture around a price table that can change.
The retention decision is less glamorous and more consequential. Keep a compact event identity, destination classification, attempt result, provider message identifier when returned, and timestamps long enough to reconcile delivery. Avoid retaining the full order payload in the SMS worker. I would deliberately stop keeping message bodies and phone numbers in verbose application logs after the debugging window; that makes a later incident harder to reconstruct, but it reduces the blast radius of leaked logs.
What should happen after a seller places an order?
The order transaction should write the notification intent to an outbox in the same database transaction as the order state. A dispatcher publishes that intent to a queue. A worker claims it, checks the idempotency key, sends one request through a narrow adapter, and records the result. Delivery callbacks, if the selected service exposes them, update the notification record through the same state machine.
The important boundary is the send attempt. A timeout leaves the worker with an ambiguous result: the remote service may have accepted the message even though the response never arrived. Retrying with a new key can create two seller alerts. Retrying with the same key can prevent duplication only if the remote API actually defines idempotency for that operation. If it does not, local deduplication still prevents two workers from sending the same known attempt, but it cannot make an uncertain network outcome disappear.
I use a state model with explicit transitions rather than a boolean called sent: pending, accepted, delivered, failed, and unknown are different operational facts. accepted means the API acknowledged the request. delivered requires a later delivery signal. unknown deserves review because it is where reliability arguments usually become wishful thinking.
How should nodejs handle SMS event notifications using common APIs?
A provider-neutral adapter needs very little: an HTTPS endpoint, authentication supplied through the runtime environment, a destination, a message body, an idempotency key if supported, and a response parser. The worker should not know the provider's field names. That keeps a future migration from changing order processing, queue semantics, or reconciliation.
Here is a deliberately generic Python example for the adapter boundary. A Node.js service can implement the same contract with its standard HTTP client; the contract is the part worth preserving, not a vendor-specific SDK. The endpoint and field names below are placeholders, so they cannot be mistaken for a working provider integration.
import json
import os
import urllib.request
def send_sms(destination: str, body: str, idempotency_key: str) -> dict:
request_body = json.dumps({
"to": destination,
"text": body,
}).encode("utf-8")
request = urllib.request.Request(
os.environ["SMS_API_URL"],
data=request_body,
method="POST",
headers={
"Authorization": f"Bearer {os.environ['SMS_API_TOKEN']}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
with urllib.request.urlopen(request, timeout=5) as response:
payload = json.load(response)
return {
"accepted": 200 <= response.status < 300,
"provider_id": payload.get("id"),
"status_code": response.status,
}
The five-second timeout is an example boundary, not a universal reliability setting. Measure request latency, queue age, and callback delay before changing it. A retry needs bounded attempts, backoff with jitter, and a dead-letter path. A permanent rejection should not be retried as if it were a transient transport error.
Why do retries create duplicate seller alerts?
Because distributed systems cannot infer intent from silence. A connection reset after the request leaves two possibilities: the API rejected it, or it accepted it and the response was lost. The worker must record that uncertainty, preserve the original key, and let reconciliation determine the outcome where the API provides a lookup or callback.
The practical failure modes are predictable:
| Failure mode | Unsafe response | Better control |
|---|---|---|
| Database commit succeeds, publish fails | Send directly from the order request | Transactional outbox and replayable dispatcher |
| Worker crashes after remote acceptance | Create a fresh notification on restart | Stable event and attempt keys |
| HTTP timeout | Retry forever | Bounded retry budget and unknown state |
| Callback arrives twice | Increment counters twice | Idempotent callback handling |
| Logs contain phone numbers and text | Retain everything for convenience | Redaction plus short detailed-log retention |
The queue is not a magic durability guarantee.
It is useful because it makes work visible, replayable, and rate-limited. The database still needs a recovery story, and the notification record needs a unique constraint that expresses the business rule, such as one order_id plus one notification type per seller. A useful correction in design reviews is to stop treating “the request returned 202” as the end of the story: the worker may still crash before recording that response, the callback may arrive before the job is marked accepted, and a later reconciliation pass may have to join all three records by the same key. I would test a crash at every boundary: after the outbox insert, after dequeue, after the HTTP write, and after the callback update.
What should be measured and retained?
Reliability needs a denominator. Track attempted notifications, API acceptances, known deliveries, permanent failures, unknown outcomes, queue age, retry count, and callback lag. Report them by destination region and notification type when policy allows; one aggregate success rate can hide a broken route or a bad phone-number normalization rule.
Retention should follow those questions. Keep enough metadata to answer “did we enqueue it, attempt it, and receive a delivery signal?” Keep the raw body only when there is a defined support or compliance reason, protect it separately, and delete it on a documented schedule. SPF is relevant to email sender authorization, but it does not establish SMS delivery; that distinction is easy to lose when email and SMS share one communications service.
For a new implementation, I would run a failure-injection test before load testing. Drop responses after the request is written, deliver the same callback twice, delay the queue consumer, and revoke credentials. The pass condition is not “the message was sent.” It is that the order remains correct, duplicate alerts are bounded by the stated contract, and an operator can identify every unresolved notification without reading application logs.
The decision rule is therefore narrow: choose the simplest API adapter that supports the delivery evidence and idempotency semantics your marketplace actually needs. If those semantics are absent, record that limitation explicitly and design reconciliation around it. Do not label acceptance as delivery, and do not keep a permanent copy of every message merely because debugging is uncomfortable.
Further reading
- RFC 7208, Sender Policy Framework (SPF): https://datatracker.ietf.org/doc/html/rfc7208
- RFC 9110, HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110
- MDN, HTTP request methods: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Methods
Top comments (0)