Stop duplicate notifications: a dedupe ledger for email and SMS
Exactly-once delivery isn't purchasable. Here's the Postgres claim table, deterministic key derivation and Node 22 worker that make a repeated send harmless.
You can’t buy exactly-once delivery, and no provider sells it honestly. What you can build is at-least-once delivery with an idempotent effect: the same event may be processed twice, but the second pass finds a row saying the message already went out and does nothing. Infrai’s SMS routes give you two of the three pieces — a batch idempotency_key and free status reads — and the third piece, the ledger, belongs in your database because only you know what “the same notification” means.
Start by dropping the word “exactly-once” from the design. A network call that times out has three possible truths — never arrived, arrived and worked, arrived and failed — and no response tells you which. The ledger’s job is to make all three converge on the same user-visible outcome.
The claim table, not a sent flag
A boolean sent column set after the call is the classic mistake: the process can die between the send and the update. Claim the work first, in its own transaction, then do the side effect, then confirm.
CREATE TABLE notification_claim (
dedupe_key text PRIMARY KEY,
event_id text NOT NULL,
channel text NOT NULL,
state text NOT NULL DEFAULT 'claimed',
message_id text,
claimed_at timestamptz NOT NULL DEFAULT now(),
confirmed_at timestamptz,
attempts int NOT NULL DEFAULT 0
);
CREATE INDEX notification_claim_stuck
ON notification_claim (claimed_at)
WHERE state = 'claimed';
The primary key does the deduplication. A second worker handling the same event hits a unique violation on insert and stops — no locking, no coordination, no distributed consensus.
That partial index is the operational half. Rows stuck in claimed for longer than your send timeout are the ambiguous cases, and they need a human-designed answer rather than a blind retry.
Derive the key, never generate it
If the key comes from randomUUID() it isn’t a dedupe key, it’s a receipt. It has to be a pure function of the event.
// dedupe-key.mjs — Node 22, ESM
import { createHash } from "node:crypto";
/**
* Same event + same channel + same recipient => same key, forever.
* Deliberately excludes timestamps and retry counters.
*/
export function dedupeKey({ eventId, channel, recipient }) {
if (!eventId || !channel || !recipient) {
throw new Error("dedupeKey needs eventId, channel and recipient");
}
return createHash("sha256")
.update(`${eventId}�${channel}�${recipient}`)
.digest("hex")
.slice(0, 40);
}
Truncating to 40 hex characters keeps the value readable in logs while leaving collision probability far below anything that matters at notification volumes.
One event, two channels, two rows
Fanning a single domain event out to email and SMS means two independent claims. Treating them as one is how a delivery failure on one channel silently suppresses the other.
| Event | Channel | Dedupe key input | Failure isolation |
|---|---|---|---|
order_shipped:A-2291 | event + email + address | Bounced address doesn’t block the text | |
order_shipped:A-2291 | sms | event + sms + E.164 number | Suppressed number doesn’t block the mail |
order_shipped:A-2291 | sms, retry after timeout | identical key | Unique violation; no second message |
Both channels run on the same account and the same key, so the fan-out is two calls rather than two vendor integrations — and the cost of both lands in one usage view rather than two invoices you’d have to join by hand.
The worker
// notify.mjs — Node 22, ESM. Requires: npm i pg
import pg from "pg";
import { dedupeKey } from "./dedupe-key.mjs";
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY");
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
async function claim(key, eventId, channel) {
const { rowCount } = await pool.query(
`INSERT INTO notification_claim (dedupe_key, event_id, channel)
VALUES ($1, $2, $3) ON CONFLICT (dedupe_key) DO NOTHING`,
[key, eventId, channel],
);
return rowCount === 1;
}
export async function notifyBySms(event) {
const key = dedupeKey({ eventId: event.id, channel: "sms", recipient: event.phone });
if (!(await claim(key, event.id, "sms"))) {
return { skipped: true, reason: "already claimed", dedupeKey: key };
}
const res = await fetch(`${API}/v1/sms/batch/send`, {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify({
messages: [{ to: event.phone, body: event.text, from: "AcmeShip" }],
idempotency_key: key,
}),
});
const payload = await res.json();
if (!res.ok) {
await pool.query(
`UPDATE notification_claim
SET state = 'failed', attempts = attempts + 1
WHERE dedupe_key = $1`,
[key],
);
throw new Error(payload?.error?.message ?? `HTTP ${res.status}`);
}
const messageId = payload.data.results[0]?.message_id ?? null;
await pool.query(
`UPDATE notification_claim
SET state = 'sent', message_id = $2, confirmed_at = now(), attempts = attempts + 1
WHERE dedupe_key = $1`,
[key, messageId],
);
return { skipped: false, messageId, dedupeKey: key };
}
Passing the same value as both the ledger key and the wire idempotency_key is the whole trick: the two layers agree by construction, and a replay that slips past your database still gets caught at the gateway.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/sms/batch/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"to": "+14155550142", "body": "Order A-2291 shipped.", "from": "AcmeShip"}
],
"idempotency_key": "6f1c9d2a4b8e0357af12"
}'
Now the honest boundary. The documented body for POST /v1/sms/send is to, body and from — there’s no idempotency field on the single-message route, so if you want gateway-side protection you send a one-item batch, as above. Hookdeck’s webhook idempotency guide walks the same reasoning from the receiving side, and the conclusion matches: the key must be derived, and it must be presented on the retry.
Resending on purpose
A duplicate you chose isn’t a bug. When support says the customer never got the text, that’s a different route:
curl -sS -X POST "https://api.infrai.cc/v1/sms/resend/msg_01JT2N8K5RC7YB4XQ0VZ3AWPMD" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
POST /v1/sms/resend/{id} bills as a new message, which is correct — a second SMS really is a second SMS. Record it in the ledger with a distinct key so your reconciliation doesn’t later count it as an accidental duplicate.
Verify with the free read:
curl -sS "https://api.infrai.cc/v1/sms/status/msg_01JT2N8K5RC7YB4XQ0VZ3AWPMD" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
An id that doesn’t exist comes back as SMS_MESSAGE_NOT_FOUND with HTTP 404 — a useful assertion in the test that proves your ledger stores real message ids and not empty strings.
What a duplicate costs
Reads are free; every message that leaves is billed. A send is $0.007475 per message, verified 2026-07-26 and flagged approximate because the underlying vendor mix shifts, and a new account’s $2 of free credit covers roughly 267 of them. So a dedupe bug is cheap in isolation and expensive at fan-out — 4,000 shipping alerts sent twice is about $30 and a lot of annoyed customers. Read the current rate yourself:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq -r '.capabilities[] | select(.module == "sms") | select(.billing.is_billable) | "\(.id) \(.billing.price_usd) \(.billing.unit)"'
Rates drift down and campaigns run, so the live number may be lower than the one printed here.
When to use something else
Twilio’s Messaging Services and Vonage’s dispatch APIs carry more of this bookkeeping for you, and if your team would rather not own a claim table, that’s a fair reason to pay for it. The catch is that a provider-side dedupe window is scoped to that provider — the moment your notification also writes a row, files an artefact or emits a metric, you’re back to owning the ledger anyway, and you might as well own one that spans every channel on the account.