Skip to content
SMS SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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:

  1. Look the message up with the provider: its console, its message list API, or a status webhook for that recipient around that time.
  2. If the provider has the message, stop. It went out.
  3. 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.

Next steps