Documentation
Idempotency
Idempotency-Key replay protection for unsafe endpoints, with an application-owned durable storage contract.
Nuxt Endpoints can protect a mutation such as posting a comment, granting
points, or incrementing a counter against duplicate execution. It coordinates
the standard Idempotency-Key request header with validated input, response
replay, the generated client, and OpenAPI.
Prefer a natural domain key and a database uniqueness constraint when the operation already has one. Use this protocol when there is no natural key, the original response must be replayed, or retries need to coordinate through a shared claim.
Declare it on a route
The normal route API is one line:
export default defineEndpoint({
request: {
body: z.object({ text: z.string() }),
},
idempotency: true,
handler: (event) => saveComment(event.validated.body),
})
idempotency: true means that Idempotency-Key is required. NE normalizes it
to internal metadata containing enabled: true, the standard header name, and
required: true. Those fields drive client generation, OpenAPI, runtime
interception, and the typed 400, 409, and 422 branches; application code
does not write that metadata itself.
Method groups use the same declaration inside the relevant method:
export default defineEndpoint({
post: {
request: {
body: z.object({ text: z.string() }),
},
idempotency: true,
handler: (event) => saveComment(event.validated.body),
},
})
Omit the property to disable idempotency. idempotency: false and
{ enabled: false } are rejected because this is a per-route opt-in, not an
inherited setting that needs to be cancelled.
Use an object only to differ from the defaults. A custom header remains
required unless required: false is explicit:
idempotency: {
headerName: 'X-Request-Key'
}
Optional mode is explicit:
idempotency: {
required: false
}
With optional mode:
- a request with no key still passes the authorization policy;
- storage, scope, fingerprint, and claim are skipped when the key is absent;
- the handler then executes normally;
- a request carrying a key uses the full claim and replay protocol;
- the generated client does not create a key unless the caller supplies
idempotencyKey: trueor a string.
Runtime policy
The route declaration is portable metadata. Storage connections, trusted
scope, authorization, fingerprints, and TTLs are server-only and belong in
server/endpoints/runtime.ts.
Public endpoints
An operation that returns no caller-specific secrets can choose one shared namespace explicitly:
// server/endpoints/runtime.ts
import { defineEndpointRuntime } from 'nuxt-endpoints/runtime'
import { storage } from '../utils/idempotency-storage'
export default defineEndpointRuntime({
idempotency: {
storage: () => storage,
scope: 'global',
authorization: 'public',
},
})
scope: 'global' means all callers share one idempotency namespace.
authorization: 'public' says that replay needs no access-control callback.
These are separate declarations because namespace isolation and authorization
are separate decisions.
A UUID makes accidental key collisions unlikely, but it does not isolate one
caller's recorded response from another caller. Use global only for public
operations whose stored responses contain no caller-specific secrets. For an
anonymous operation with caller-specific data, derive scope from a
server-trusted anonymous session identifier instead.
Authenticated endpoints
Use a trusted user, tenant, or session identity as the scope. If middleware has already completed authorization, state that separately:
export default defineEndpointRuntime({
idempotency: {
storage: () => storage,
scope: ({ event }) => event.context.auth.userId,
authorization: 'middleware',
},
})
authorization may also be a callback. It runs on every request, including an
optional request without a key and a completed replay. This prevents replay
from becoming a path around ordinary access control.
Neither scope nor authorization has an implicit default. Startup fails if
an idempotent route cannot resolve storage, scope, and authorization from
its runtime configuration.
Route-specific runtime settings
Fingerprint, replay-status, and TTL exceptions can be keyed by generated route template and lowercase HTTP method. Route settings win over common policy:
export default defineEndpointRuntime({
idempotency: {
storage: () => storage,
scope: 'global',
authorization: 'public',
leaseTtlMs: 5 * 60000,
},
routes: {
'/api/slow-operation': {
post: {
idempotency: {
leaseTtlMs: 15 * 60000,
},
},
},
},
})
Route entries may set fingerprint, replayStatuses, leaseTtlMs, and
replayTtlMs. Storage, scope, and authorization remain common application
policy. Contract declarations reject all of these runtime-only keys.
The default fingerprint contains validated params, query, and body, plus
the negotiated response media type when applicable. A bodyless idempotent route
must supply an explicit route fingerprint. Multipart input containing File
also needs an explicit serializable projection.
Client usage
For a required route, the generated client creates a UUID when the
$endpoint() request object is created:
const request = $endpoint('/api/comments', {
method: 'post',
body: { text: 'Hello' },
})
That key belongs to the logical request object. Re-executing a Pinia Colada mutation built from the same object sends a fresh HTTP request with the same key; creating a new request object creates a new key:
const mutation = useMutation(mutationOptions(request))
For an optional route, pass idempotencyKey: true to ask NE to generate a key,
or pass a string when the key must survive a reload, process restart, or queue
handoff.
HTTP behavior
- Required and absent, or any present but malformed key:
400 Bad Request. - Same key and a different request fingerprint:
422 Unprocessable Content. - Same key and fingerprint while another request owns the lease:
409 Conflict. - Same key and a completed fingerprint: replay the recorded response.
- Optional and absent: authorize, bypass the idempotency store, then execute.
These framework-generated failures use application/problem+json and appear
in the generated route type and OpenAPI document.
Lease lifecycle and limits
The default in-flight leaseTtlMs is 60 seconds. The default completed-response
replayTtlMs is 24 hours. Both may be changed in the common runtime policy or
for one route; the route value has priority.
For a replayable response, NE snapshots the handler result and calls
complete() before sending it to the client. If the handler throws, response
snapshotting fails, or the status is not replayable, NE calls release().
There is no persistent error state today. An expired in-flight or completed
record can be acquired by the next atomic claim().
If a process crashes before release or completion, lease expiry is the recovery
boundary. A storage adapter must therefore treat an expired record as
atomically reclaimable. If a handler runs beyond its lease TTL, another request
may acquire a new lease and execute the handler concurrently. The lease token
prevents the older owner from applying a stale complete(), but it cannot undo
database writes or other external side effects that have already happened.
Choose a TTL comfortably above the maximum duration of an ordinary mutation.
For long work, prefer 202 Accepted with a job identifier and polling, and
move the work out of the request lifecycle. Nitro Tasks can help run such work,
but they do not by themselves guarantee a durable queue.
NE intentionally does not implement lease heartbeat today. A safe heartbeat
would require an atomic storage renew() operation guarded by storage key,
fingerprint, and lease token; stopping renewal when the request ends; explicit
renew-failure and lease-loss behavior; handling process and event-loop stalls;
and support from every storage adapter, including serverless and edge runtimes
where background timers are constrained. A timer or cron that blindly extends
records can keep leases alive after their owners have died, so it is not a safe
substitute. Nitro Tasks and scheduledTasks are not request-scoped heartbeats.
Storage responsibility
NE owns:
- reading and validating the idempotency header;
- generating the default fingerprint and the scope-aware storage key;
- calling
claim(), interpreting in-flight/conflict/completed results, and replaying completed responses; - snapshotting successful responses, calling
complete(), and callingrelease()on failure or a non-replayable status; - reusing a key for the same logical client request.
The application owns:
- a durable storage adapter with atomic
claim(),complete(), andrelease()transitions; - avoiding process-local memory storage in production;
- coordinating the business mutation and idempotency record transactionally when the operation requires that guarantee;
- protecting external side effects that cannot share that transaction.
Individual handlers do not read or write idempotency records. The
createMemoryIdempotencyStorage() adapter is for development and tests only.
Redis and PostgreSQL implementation guidance is in the
storage recipes.
Even with a correct durable store, this protocol does not guarantee exactly once external effects. A process can complete an external effect and crash before recording the response. Use the downstream service's own idempotency key, a transactional outbox, or domain-level deduplication when that boundary matters.