# Idempotent enqueue

> Use scoped enqueue keys so repeated acceptance returns the original durable job.

A user double-clicks "Place order". Your API handler runs twice, two jobs get enqueued, and the
customer gets two confirmation emails. Or a webhook provider redelivers, or a retrying client
repeats a request that already succeeded. Enqueue idempotency stops the second job from being
created at all: the repeated request returns the original job's ID and writes nothing.

This solves duplicate acceptance. Handler delivery remains at least once — the other half of the
problem, covered at the end of this page.

## Choose a stable key

Build the key from the business operation that should happen once: an order ID, an invoice number,
a webhook delivery ID. A timestamp or random value cannot identify a replay, so it defeats the
mechanism.

<Tabs items={["TypeScript", "Python", "Go"]}>
  <Tab value="TypeScript">
    ```ts
    const jobId = await queue.enqueue("invoice.capture", { invoiceId }, {
      queue: "billing",
      idempotency: { key: `capture:${invoiceId}`, scope: "invoice-capture" },
    });
    ```
  </Tab>
  <Tab value="Python">
    ```python
    job_id = queue.enqueue(
        "invoice.capture",
        {"invoiceId": invoice_id},
        EnqueueOptions(
            queue="billing",
            idempotency=Idempotency(key=f"capture:{invoice_id}", scope="invoice-capture"),
        ),
    )
    ```
  </Tab>
  <Tab value="Go">
    ```go
    jobID, err := queue.Enqueue(ctx, "invoice.capture", map[string]any{
        "invoiceId": invoiceID,
    }, workhorse.EnqueueOptions{Queue: "billing", Idempotency: &workhorse.Idempotency{
        Key: "capture:" + invoiceID, Scope: "invoice-capture",
    }})
    ```
  </Tab>
</Tabs>

If the same scope and key still exist, an equivalent request returns the original job ID.
PostgreSQL adds no second job, no event, no queue placement, no notification — the call just tells
you which job already owns the key.

Scopes keep unrelated features apart. Two features might both want the key `order-123`; separate
scopes make them separate registrations. An omitted scope uses the shared `"default"` scope, which
works well when every key already carries a feature prefix.

## Read what PostgreSQL did

Use `enqueueWithResult` when application behavior or diagnostics need more than the stable job ID:

<Tabs items={["TypeScript", "Python", "Go"]}>
  <Tab value="TypeScript">
    ```ts
    const result = await queue.enqueueWithResult(
      "invoice.capture", { invoiceId }, { idempotency: { key, scope: "invoice-capture" } },
    );
    ```
  </Tab>
  <Tab value="Python">
    ```python
    result = queue.enqueue_with_result(
        "invoice.capture",
        {"invoiceId": invoice_id},
        EnqueueOptions(idempotency=Idempotency(key=key, scope="invoice-capture")),
    )
    ```
  </Tab>
  <Tab value="Go">
    ```go
    result, err := queue.EnqueueWithResult(ctx, "invoice.capture", map[string]any{
        "invoiceId": invoiceID,
    }, workhorse.EnqueueOptions{Idempotency: &workhorse.Idempotency{
        Key: key, Scope: "invoice-capture",
    }})
    ```
  </Tab>
</Tabs>

`result.outcome` is an `EnqueueOutcome`: `accepted` for a new job, `replayed` for an equivalent
idempotency request, `replaced` or `non_replaceable` for [debounce](/docs/debounce), and `coalesced`
for [throttle](/docs/throttle). `enqueue` returns the same `jobId` while hiding that explanation.

## What counts as the same request?

Returning the existing job is only safe when the repeat really is the same request. PostgreSQL
fingerprints what was accepted: the queue, type, payload, concurrency key, sorted tags, attempt
budget, retry policy, deadline, execution timeout, key retention, and an explicit `runAt` value.

If those fields match, the call is a replay and converges. If they differ, the same key is being
reused for materially different work — a mistake, not a duplicate — so `queue.enqueue` throws
`EnqueueIdempotencyConflictError` and rolls back the statement rather than letting either request
silently win.

Every SDK raises a typed idempotency conflict: `EnqueueIdempotencyConflictError` in TypeScript and
Python, and `EnqueueIdempotencyConflictError` through `errors.As` in Go. The error names the
existing job and conflicting fields without accepting the changed request.

An omitted `runAt` remains omitted in the fingerprint. A later replay of an immediate job
therefore does not conflict with the original acceptance time.

## Read the enqueue decision

Use `queue.enqueueWithResult` when the caller needs to distinguish a new acceptance from a replay,
debounce replacement, debounce refusal, or throttled request. It returns the retained `jobId` and
an `outcome` describing PostgreSQL's decision.

If debounce returns `non_replaceable`, the result also carries a `reason`. That reason tells the
caller whether the key belongs to an incompatible mode, the retained job is no longer pending, or
the pending window elapsed. Other outcomes omit `reason`, so TypeScript narrows the result from its
`outcome`.

## Keys expire

Each binding lives for a retention window — 24 hours by default, configurable per request through
`ttlMs`. After expiry, the same key can own a new job. That makes enqueue idempotency a replay
window rather than a permanent business registry: it catches the double-click and the redelivery,
not a repeat a year later. Size `ttlMs` to your longest realistic replay — a webhook provider's
maximum redelivery horizon, for example.

Purging a ready or scheduled job releases its binding immediately, and maintenance removes expired
bindings on its own cadence.

## Your raw key is never stored

PostgreSQL stores the scope and a cryptographic hash of the key. Events, conflict errors, and
operator views show only a bounded preview and a digest. You can safely build keys out of internal
identifiers without them appearing on an operator's screen or in audit data.

## Why do handlers still need idempotency?

One accepted job can run again: a worker dies mid-attempt, the lease expires, and another worker
claims the same job. Enqueue idempotency never sees that — it deduplicates acceptance, not
execution.

When repeating an external effect would be harmful, pass the job ID or a domain ID as the
provider's own idempotency key. `HandlerContext.checkpoint` can reuse a completed stage, but a
crash can still land after the effect and before the checkpoint commit — only the system
performing the effect can close that final gap.

Use a stable domain value such as `orderId` as the key. TypeScript can call
`Queue.enqueueWithResult` and read `result.jobId` from its `EnqueueResult`; `Queue.enqueue` returns
only the accepted job identifier when the outcome details are unnecessary.

## Next

- [Enqueue and transactions](/docs/enqueue) — place the key on accepted work
- [Durable execution](/docs/durable-execution) — reuse completed stages inside one job
- [Dead letters](/docs/dead-letters) — separate idempotency for redrive requests

---

Exact key bounds, fingerprint fields, expiry, and conflict diagnostics:
[architecture reference](https://github.com/stablemates/workhorse/blob/main/docs/architecture.md#enqueue_idempotency).
