---
title: "Idempotency store"
description: "The IdempotencyStore interface, its record states, the reserve and finalize contract, and memoryIdempotencyStore()."
---

The client uses an `IdempotencyStore` to remember what happened to each `idempotencyKey`. Use the built-in memory store for one process, or implement the interface on a shared database. Guides: [Idempotency](/sending/idempotency), [Deploy with multiple instances](/guides/multiple-instances).

## `memoryIdempotencyStore()`

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

const store = memoryIdempotencyStore();
const sms = createSmsClient({ adapters: [memory()], idempotency: { store } });

await sms.send({ to: "+14155550123", body: "Hi", idempotencyKey: "welcome:42" });
console.log(store.size, (await store.get("welcome:42"))?.state); // 1 "accepted"
```

Returns a `MemoryIdempotencyStore`, which is an `IdempotencyStore` with two extra members.

| Member | Meaning |
| --- | --- |
| `delete(key): Promise<void>` | Removes a record, for example after you reconcile an unknown outcome. |
| `size: number` | Number of records, including expired ones not yet removed. |

It lives in one process. Records are lost on restart and not shared between instances, workers, or serverless invocations. Accepted and rejected records expire after `ttlSec`. `unknown` and `reserved` records never expire on their own. Exported from both `@opencoredev/sms-sdk` and `@opencoredev/sms-sdk/testing`.

## `IdempotencyStore`

```ts ignore="type listing"
interface IdempotencyStore {
  get(key: string): Promise<IdempotencyRecord | null>;
  reserve(input: { key: string; fingerprint: string; now: number }): Promise<ReserveResult>;
  finalize(input: { key: string; reservationId: string; record: FinalIdempotencyRecord; ttlSec: number; now: number }): Promise<FinalizeResult>;
}
```

### `get(key)`

Returns the current record, or `null` when there is none or it has expired.

### `reserve({ key, fingerprint, now })`

Claims the key atomically, in one compare-and-set operation such as `INSERT ... ON CONFLICT` or Redis `SET NX`.

- Succeeds, returning `{ kind: "reserved", reservationId }`, only when there is no record, the record has expired, or the record is `rejected` with the same `fingerprint`. The new record is `{ state: "reserved", fingerprint, reservationId, reservedAt: now }`.
- Otherwise returns `{ kind: "existing", record }` without changing anything.
- A reservation must never expire into a claimable state. An abandoned reservation means the outcome is unknown.

`now` is epoch milliseconds.

### `finalize({ key, reservationId, record, ttlSec, now })`

Replaces the reservation with a final record, only if `reservationId` still owns the key.

- Returns `{ kind: "finalized" }` on success, `{ kind: "not_owner" }` otherwise.
- Keep `accepted` and `rejected` records for `ttlSec` seconds.
- Keep `unknown` records until an operator clears them.

If `finalize` throws, the client ignores the error because the send's outcome is already decided. The key stays `reserved`. Later sends read it as in progress, then as an unknown outcome, so the message is never resent.

## Records

```ts ignore="type listing"
type IdempotencyRecord =
  | { state: "reserved"; fingerprint: string; reservationId: string; reservedAt: number }
  | { state: "accepted"; fingerprint: string; result: SmsSendResult }
  | { state: "rejected"; fingerprint: string; error: SerializedSmsError }
  | { state: "unknown"; fingerprint: string; error: SerializedSmsError };

type FinalIdempotencyRecord = Exclude<IdempotencyRecord, { state: "reserved" }>;
```

| State | Written when | A later send with the same key and payload |
| --- | --- | --- |
| `reserved` | A send starts | `IdempotencyInProgressError`, or `HandoffUnknownError` (`stale_reservation`) once older than `staleReservationSec` |
| `accepted` | A provider accepted | Returns `result` with `replayed: true`, no request |
| `rejected` | Every attempt was rejected, or `beforeSend` rejected or threw | Sends again |
| `unknown` | The outcome was unknown | `HandoffUnknownError` (`replayed_unknown`), never resends |

Records hold no message body. `result` and `error` are the redacted `SmsSendResult` and `SerializedSmsError`, plain JSON-serializable objects that any JSON column or key-value store can hold.

### Fingerprint

`fingerprint` is the SHA-256 (hex) of the message's `to`, `from`, `body`, `mediaUrls`, `sendAt`, `validityPeriodSec`, and `webhookUrl`, plus the parsed `providerOptions` entries for configured adapters, keys sorted, when any has fields. Empty entries and entries for adapters the client does not have are left out. A different fingerprint for an existing key throws `IdempotencyConflictError`. Store it as an opaque string.

## Types

`IdempotencyStore`, `IdempotencyRecord`, `FinalIdempotencyRecord`, `ReserveResult`, `FinalizeResult`, and `MemoryIdempotencyStore` are exported as types from `@opencoredev/sms-sdk`.
