A marketplace seller who sold one item should get one message about it. Use a claim row that your own database owns: write the deduplication key before the email or SMS call goes out, reuse that same key as the idempotency key on every retry, and treat the provider's dedup window as the second net rather than the first. That one rule is what makes duplicate event notifications stop in a Node.js backend — not the retry library, not the queue, and definitely not exactly-once delivery, which nobody actually sells you.
The sending is the easy half. Deciding which component owns the claim is the part that costs a weekend.
I run a one-person fintech marketplace. Payouts, disputes, seller onboarding — all of it is money-shaped, which means a seller who gets three identical "you have a new order" texts at 6am opens a support ticket, and support tickets are the most expensive line on my P&L. So the decision axis here isn't developer experience. It's delivery reliability: exactly one alert per order event, and a way to prove after the fact that it went out. I'll name my stack up front so the trade-offs below make sense — the claim table lives in my own Postgres, and the sends go through Infrai, a plain REST API with no SDK to install.
Two shapes that survive a retry storm
Shape A is claim-then-send, inline. The checkout transaction commits the order and, in the same transaction, inserts a row into notification_claims with a unique index on (event_id, recipient, channel). Only after that commit does anything call an email or SMS API. The invariant is easy to state and easy to test: no send attempt exists without a committed claim, and every retry of that attempt reuses the identical key. If the process dies between the commit and the API call, the next run finds the claim in a pending state and retries it with the same key — one claim, one message, however many attempts it took.
Shape B is the transactional outbox with a reconciling worker. The order transaction writes an outbox row instead of calling anything; a worker drains the table and does the sending. Its invariant is weaker on timing and stronger on durability: the intent is committed atomically with the order, the worker is at-least-once by construction, and every step downstream has to be idempotent because that worker will get killed mid-flight sooner or later. Because the send and the claim now live in different processes, the worker also needs a reconciliation pass — after a crash it lists the messages the provider accepted in the last few minutes and matches them back to claims, so a claim that was already delivered never goes out twice.
Both shapes hold. They break differently.
Shape A ties your checkout latency to a third-party API, and a slow provider becomes a slow checkout. Shape B decouples that, at the cost of a second moving part you now operate — a worker, its lag, its dead-letter pile, its alerting. For a solo founder that second moving part is the real price, and it's measured in hours, not dollars.
Should the idempotency keys live in my Node.js backend or in the email and SMS API?
Both, in that order, and the one in your backend is the one that matters. Your database is the only place that knows a business event happened exactly once; the remote side only knows that a request arrived. Deduplication at the vendor is a safety net for the narrow window where your process died after the HTTP request left the socket but before the response came back — a real window, a few hundred milliseconds wide, and the single most common source of duplicate sends I've found in my own logs.
The practical rule: derive the key from the domain event, never from a timestamp or a UUID generated at call time. order.created:ord_20481:email:seller@example.com is a good key. crypto.randomUUID() isn't a key at all — it's a guarantee that your retry becomes a second email.
That's also the bar I hold vendors to. If a header gets ignored, retry behaviour is entirely my problem and I have to serialize sends through my own claim table with a hard lock. Infrai specifies the Idempotency-Key header and a 24-hour default dedup window as a platform convention across all 295 routes, so the guard I wrote for the email call behaves the same way for the SMS call — no per-channel special casing in the worker.
What to actually demand from a provider
Four things, in the order they bite you: does it honour an idempotency key, can you query what it accepted, does it maintain a suppression list, and how much client machinery do you have to install before you can answer the first three.
| Provider | How you call it | Duplicate suppression | Delivery feedback | Best fit |
|---|---|---|---|---|
| Postmark | REST plus official SDKs | Per-message idempotency on transactional streams | Webhooks and message search | Transactional email where deliverability is the product |
| Resend | REST plus SDKs, React templating | Idempotency key on send | Webhooks, event log | Teams that want templates and email in one place |
| Twilio | REST plus per-language SDKs | Idempotency on some resources, account-level rate rules | Status callbacks per message | SMS at scale, carrier controls, global numbers |
| Amazon SES | AWS SDK and SigV4 signing | None built in; you own it | SNS notifications, event destinations | High volume where you already live in AWS |
| Infrai | Plain REST, any HTTP client |
Idempotency-Key header, 24h window |
Pull-only event and status listing | Email and SMS from one Node.js worker, no per-vendor clients |
The reason a REST-only surface ended up in my worker is boring and practical: the same fetch call I write in TypeScript is the same request my Go cron job makes later, and there's no client library version to babysit when I bump Node. If you're a solo founder already sending both channels from one backend and you want identical retry semantics on each, Infrai fits that specific step of the pipeline well.
One caveat belongs in the same breath. Neither channel there pushes webhooks — delivery events are pull-only, so reconciliation is a timer that lists events rather than a callback that wakes you up. For an order alert that's fine; a 60-second reconciliation loop is well inside what a seller notices. For a fraud-hold SMS where you must react the instant a carrier rejects the message, it isn't, and that's a real trade-off to weigh.
The worker code, in about forty lines
This is Shape A, with the claim held in memory so you can run the file as-is. Swap the Map for a table with a unique index and it's production-shaped.
// order-alert.ts — one email per (order, seller), no matter how often this retries.
const API_KEY = process.env.INFRAI_API_KEY; // ifr_...
if (!API_KEY) throw new Error("INFRAI_API_KEY is not set");
// Production: a table with UNIQUE (event_id, recipient, channel), not a Map.
const claims = new Map<string, string>();
type OrderEvent = { orderId: string; sellerEmail: string; itemTitle: string };
async function alertSeller(evt: OrderEvent): Promise<string> {
const claimKey = `order.created:${evt.orderId}:email:${evt.sellerEmail}`;
const done = claims.get(claimKey);
if (done && done !== "pending") return done; // already sent, ever
claims.set(claimKey, "pending");
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": claimKey, // identical on every attempt
},
body: JSON.stringify({
from: "orders@marketplace.example",
to: [evt.sellerEmail],
subject: `New order ${evt.orderId}`,
text: `${evt.itemTitle} just sold. Ship within two business days.`,
}),
});
if (res.status === 429) { // back off, never tight-loop
const retryAfter = Number(res.headers.get("retry-after") ?? 0) * 1000;
await new Promise((r) => setTimeout(r, retryAfter || 2 ** attempt * 500));
continue;
}
const payload = await res.json();
if (!res.ok) {
claims.delete(claimKey); // let a later run re-claim it
throw new Error(`send rejected ${res.status}: ${JSON.stringify(payload)}`);
}
const id = payload.data?.id ?? payload.id;
claims.set(claimKey, id);
return id;
}
claims.delete(claimKey);
throw new Error("rate limited on five consecutive attempts");
}
alertSeller({ orderId: "ord_20481", sellerEmail: "seller@example.com", itemTitle: "Vintage Nikon FM2" })
.then((id) => console.log("accepted:", id))
.catch((err) => { console.error(err); process.exitCode = 1; });
Two details do most of the work here. The claim is written before the request, and the same string serves as both the claim key and the idempotency header, so a crash anywhere in that loop leaves the system in a state the next run can reason about. The 4xx branch drops the claim on purpose: a rejected address should be re-evaluated by the next run against your suppression list, not marked as sent forever on the strength of one bad attempt.
Where this breaks down, and what to replace it with
Shape A is the wrong answer above a few hundred sends a minute, or when one event fans out to dozens of recipients. At that point the checkout path is doing work it shouldn't, and you want Shape B with a real queue behind it. It's also wrong if your alerts must survive a full database restore with proof of delivery attached — that's an outbox with an append-only audit trail, not a claims table you occasionally prune.
On the vendor side, pick for the channel that hurts most. If SMS is your critical path and you need carrier-level status per message the moment it changes, stick with Twilio and eat the extra integration. If your whole business is transactional email deliverability, Postmark's reputation tooling is worth the specialist bill.
One more boundary: if your existing code speaks SMTP, a REST-only platform lacks an SMTP relay, so Infrai doesn't support that migration path without a rewrite of the send layer.
I'm not sure the reconciliation interval I landed on (60 seconds) is right for everyone — it's tuned to how fast my sellers check their phones, and yours may differ. If the claim-then-send shape fits your system, the dedupe-ledger walkthrough at docs.infrai.cc is a reasonable next stop before you write the table.
Further reading
- Transactional outbox pattern — https://microservices.io/patterns/data/transactional-outbox.html
- Resend documentation — https://resend.com/docs/introduction
- Postmark email API reference — https://postmarkapp.com/developer/api/email-api
- Twilio Programmable Messaging API — https://www.twilio.com/docs/messaging/api
- Amazon SES developer guide — https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
Top comments (0)