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

Idempotency

Use idempotency keys and a store so a retried job does not send the same SMS twice, and know where exactly-once ends.

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() },
});

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. Bump the version suffix to deliberately send the same event again.

What happens on a repeat key

Stored record Same payload Different payload or sender
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
reserved, younger than staleReservationSec IdempotencyInProgressError (another process is sending) IdempotencyConflictError
reserved, older than staleReservationSec HandoffUnknownError (reason: "stale_reservation"), never resends IdempotencyConflictError

“Same payload” means the same to, from, body, mediaUrls, sendAt, validityPeriodSec, and webhookUrl, plus any providerOptions that apply to a configured adapter, compared by a SHA-256 fingerprint.

Two concurrent send() calls with the same key on one client share one request, even without a store. The second gets the same result with replayed: true. Without a store, nothing is remembered once the first call finishes.

Options

import { createSmsClient, memoryIdempotencyStore } from "@opencoredev/sms-sdk";
import { memory } from "@opencoredev/sms-sdk/testing";

const sms = createSmsClient({
  adapters: [memory()],
  idempotency: {
    store: memoryIdempotencyStore(),
    ttlSec: 7 * 24 * 60 * 60, // keep accepted and rejected records for 7 days
    staleReservationSec: 120, // treat a reservation older than 2 minutes as unknown
  },
});
Option Default Meaning
store required The IdempotencyStore.
ttlSec 86400 (24 hours) How long accepted and rejected records are kept. Set it longer than your longest job retry window. Unknown records are kept until you delete them.
staleReservationSec 300 Age after which an unfinished reservation counts as an unknown outcome. Set it longer than timeoutMs times the number of requests one send can make.

Reconcile an unknown outcome

When a send throws HandoffUnknownError, the store records the key as 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");

delete exists on the memory store. Your own store needs some way for an operator to clear a key.

Next steps