Prevent duplicate sends
Use idempotency keys and a store so a retried job does not send the same SMS twice, and know what to do when the outcome is unknown.
A retried job should not text the customer twice. Give each message an idempotencyKey and give the client a store. A repeat send with the same key returns the first result instead of sending again.
Add a key and a store
import { createSmsClient, memoryIdempotencyStore } from "@opencoredev/sms-sdk";
import { twilio } from "@opencoredev/sms-sdk/twilio";
const sms = createSmsClient({
adapters: [
twilio({
accountSid: process.env.TWILIO_ACCOUNT_SID ?? "",
authToken: process.env.TWILIO_AUTH_TOKEN ?? "",
from: "+15550100001",
}),
],
idempotency: {
store: memoryIdempotencyStore(),
ttlSec: 7 * 24 * 60 * 60, // remember sends for 7 days
},
});
const first = await sms.send({ to: "+14155550123", body: "Order 123 shipped.", idempotencyKey: "order:123:shipped:v1" });
const second = await sms.send({ to: "+14155550123", body: "Order 123 shipped.", idempotencyKey: "order:123:shipped:v1" });
console.log(second.replayed, second.providerId === first.providerId); // true true
The second call makes no request. It returns the stored result with replayed: true.
A key names the message, not the attempt: order:123:shipped:v1, appointment:88:reminder:24h. Keys are 1 to 255 characters. To send the same event again on purpose, bump the version suffix.
idempotency option |
Default | Meaning |
|---|---|---|
store |
required | Where records live. |
ttlSec |
86400 (24 hours) |
How long accepted and rejected sends are remembered. Set it longer than your job queue’s longest retry window, or a late retry sends again. Unknown outcomes are kept until you delete them. |
staleReservationSec |
300 |
How long a send may stay unfinished before it counts as an unknown outcome. Set it longer than your slowest possible send. |
Without a store, keys still help a little: two send() calls with the same key running at the same time on one client share one request, and the second gets replayed: true. Nothing is remembered after the first call finishes.
What happens on a repeat key
The SDK compares the new send with the stored one by a SHA-256 fingerprint of to, from, body, mediaUrls, sendAt, validityPeriodSec, webhookUrl, and any providerOptions for adapters the client has.
| Stored record | Same message | Different message |
|---|---|---|
| None | Sends | Sends |
| Accepted | Returns the stored result, replayed: true, no request |
IdempotencyConflictError |
| Rejected | Sends again | IdempotencyConflictError |
| Unknown | HandoffUnknownError (reason: "replayed_unknown"), never resends |
IdempotencyConflictError |
Still sending, younger than staleReservationSec |
IdempotencyInProgressError: another process is sending. Retry later. |
IdempotencyConflictError |
Still sending, older than staleReservationSec |
HandoffUnknownError (reason: "stale_reservation"), never resends |
IdempotencyConflictError |
IdempotencyConflictError means you reused a key for a different message. Nothing was sent, and retrying will not help: use a new key.
Reconcile an unknown outcome
When a send throws HandoffUnknownError, the store marks the key unknown, and every later send with that key throws again. To resolve it:
- Look the message up with the provider: its console, its message list API, or a status webhook for that recipient around that time.
- If the provider has the message, stop. It went out.
- If it does not, delete the record and send again.
import { memoryIdempotencyStore } from "@opencoredev/sms-sdk";
const store = memoryIdempotencyStore();
// After confirming with the provider that the message was never created:
await store.delete("order:123:shipped:v1");
The memory store has delete. A store you write needs some way for an operator to clear a key.