Idempotency Keys

Exactly-once idempotency key ledger for Convex mutations: deduplicates retries, replays typed results, and self-heals crashed inflight claims via split TTLs.

Installation

npm install @vllnt/convex-idempotency

About Idempotency Keys

Idempotency Keys is an exactly-once ledger for Convex: call `begin` to mint an inflight claim or short-circuit a replay, then `complete` to record the outcome so later retries see `{ state: "done", result }` and skip the work entirely. Domain-neutral — payment intents, webhook deliveries, queue consumers, double-submit guards — any operation that must run at most once per `(scope, key)`. TTLs split between an inflight lease and a done grace period; expiry is server-sourced so a skewed client clock can't hijack a replay; keys live in sandboxed component tables, the host gates every write, and it runs identically on Convex Cloud and self-hosted.

Benefits

Use cases

how to prevent duplicate charges in Convex mutations

The @vllnt/convex-idempotency component provides a begin/complete ledger inside Convex mutations. Call idem.begin(ctx, requestId) at the start of a charge mutation: if the key already completed, it returns the prior result immediately without re-running doCharge. If a concurrent retry arrives while the first is inflight, begin returns an inflight state with a retryAfterMs backoff hint.

idempotency keys for Convex webhook handlers

@vllnt/convex-idempotency lets you record a delivery key when a webhook mutation fires, so a re-delivered event short-circuits before any state mutation runs. Pass the webhook provider's delivery ID as the key, call idem.begin, and return claim.result early if state is done. The done grace TTL defaults to 24 hours, covering typical provider retry windows.

exactly-once processing for Convex queue consumers

Use @vllnt/convex-idempotency inside a Convex mutation that processes queue messages by passing the message ID as the idempotency key. The component mints an inflight claim within the mutation transaction, so a concurrent retry from the same message ID is blocked until the lease expires or the claim completes. A 60-second inflight lease means a crashed worker's slot self-heals without manual intervention.

how to deduplicate double form submissions in Convex

Generate a client-side requestId when a form is submitted and pass it to a Convex mutation that calls idem.begin(ctx, requestId). If the user submits twice before the first response returns, the second mutation call hits the inflight state and receives a retryAfterMs hint rather than executing the write again. Once complete is called, any further replay returns the typed original result.

Frequently asked questions

What Convex function types can use @vllnt/convex-idempotency?

@vllnt/convex-idempotency is designed for use inside Convex mutations. The begin and complete methods are mutation-level calls that participate in the surrounding transaction, which is what makes the inflight claim atomic. The get method is a query. There is no React or client-side entry point; it is a pure backend infrastructure component.

How does the inflight lease prevent a crashed worker from blocking a key forever?

When idem.begin is called, the component records an inflight claim with an expiry derived from the server clock using a configurable inflight TTL that defaults to 60 seconds. If the worker crashes before calling idem.complete, the claim expires and a subsequent call to idem.begin for the same key mints a fresh claim rather than returning inflight. This self-healing behavior requires no manual intervention.

What happens if complete is called but the inflight row is missing or already expired?

idem.complete returns a discriminated union: either { recorded: true } on success, or { recorded: false, reason: 'missing' | 'expired' | 'already_done' } when the row could not be updated. This lets the host know the work executed but the idempotency record was not written, so it can log a warning or opt into upsertOnMissing to write the outcome anyway.

Can a caller supply their own timestamp to force a key to look expired or live?

No. @vllnt/convex-idempotency reads expiry from the Convex server clock internally. Callers cannot pass a now argument, so a client with a skewed or adversarial clock cannot manipulate whether a claim appears inflight, expired, or done. This is described in the component documentation as server-sourced expiry.

How are idempotency keys scoped and isolated from other tables in the Convex deployment?

@vllnt/convex-idempotency uses Convex component sandboxed tables, meaning the ledger tables are only accessible through the exported component functions and are not visible to the host app's raw database queries. Keys are global by default but can be namespaced with an explicit scope string to separate tenants or operation types without collisions.

Links