Skip to main content
Idempotency keys deduplicate task triggers. If you trigger a task with the same idempotencyKey twice, the second request does not create a new run. It returns the original run’s handle, so you can track the existing run’s progress. The key applies to the trigger call and nothing else. It deduplicates trigger(), triggerAndWait(), and batchTrigger(); it does not make the code inside a task’s run() function idempotent. See Side effects inside run() for what that means when a task calls an external API.

Why use idempotency keys?

The most common use case is preventing duplicate child tasks when a parent task retries. Without idempotency keys, each retry of the parent would trigger a new child task run: Other common use cases include:
  • Preventing duplicate emails - Trigger the email task once, even if the parent task retries
  • Avoiding double-charging customers - Trigger the payment task once, even if the parent task retries. The payment API call itself still needs the provider’s own idempotency key, see Side effects inside run()
  • One-time setup tasks - Trigger initialization or migration tasks once
  • Deduplicating webhook processing - Trigger the handler task once per webhook event, even if the event is delivered multiple times

Side effects inside run()

An idempotency key stops a task from being triggered twice. It does not stop the code inside that task from running twice. When a run retries, run() executes again from the top, and every API call, database write, or email inside it happens again unless that operation is itself idempotent. This matters most for payments. A refund issued directly inside a retryable task is issued again on every retry, even when the retry is caused by an unrelated failure later in the same function. Moving the refund into a child task and triggering it with an idempotency key does not close the gap either: the child is triggered once, but its own run() can retry after the provider has accepted the request. Only the provider can deduplicate its own API call. Pass the provider’s idempotency key on every call that must happen at most once. Derive it from a value that stays the same across every attempt of the run. The run ID, ctx.run.id, always qualifies: retries are attempts of the same run, so the ID does not change between them. This is the same value the default run scope mixes into a Trigger.dev idempotency key.
/trigger/refund-order.ts
The run ID protects against retries of one run. If the same task can be triggered more than once for the same order, either trigger it with a Trigger.dev idempotency key so repeated triggers resolve to the same run and the same run ID, or key the provider call on a business ID from your payload instead, such as refund-${payload.orderId}, which deduplicates across separate runs too. Use a Trigger.dev idempotency key to stop a retry triggering a task twice, and the provider’s idempotency key to stop a retry calling the provider twice. For a side effect with no idempotency support, record that you performed it in your own database and check that record before performing it again.

idempotencyKey option

You can provide an idempotencyKey when triggering a task:
You can use the idempotencyKeys.create SDK function to create an idempotency key before passing it to the options object. We automatically inject the run ID when generating the idempotency key when running inside a task by default. You can turn it off by passing the scope option to idempotencyKeys.create:
If you are triggering a task from your backend code, you can use the idempotencyKeys.create SDK function to create an idempotency key.
You can also pass a string to the idempotencyKey option, without first creating it with idempotencyKeys.create.
When you pass a raw string, it defaults to "run" scope (scoped to the parent run). See Default behavior for details on how scopes work and how to use global scope instead.
Make sure you provide sufficiently unique keys to avoid collisions.
Idempotency keys are limited to 2048 characters. Keys produced by idempotencyKeys.create() are 64-character hashes and always fit; this limit only matters if you pass a long raw string. Requests above the limit return 400.
You can pass the idempotencyKey when calling batchTrigger as well:

Understanding scopes

The scope option determines how your idempotency key is processed. When you provide a key, it gets hashed together with additional context based on the scope. This means the same key string can produce different idempotency behaviors depending on the scope you choose.

Available scopes

run scope (default)

The run scope makes the idempotency key unique to the current parent task run. This is the default behavior for both raw strings and idempotencyKeys.create().
With run scope, if you trigger processOrder twice with different run IDs, both will send emails because the idempotency keys are different (they include different parent run IDs).

attempt scope

The attempt scope makes the idempotency key unique to each attempt of the parent task. Use this when you want child tasks to re-execute on each retry.

global scope

The global scope makes the idempotency key truly global across all runs. Use this when you want to ensure a task only runs once ever (until the TTL expires), regardless of which parent task triggered it.
Even with global scope, idempotency keys are still isolated to the specific task and environment. Using the same key to trigger different tasks will not deduplicate - both tasks will run. See Environment and task scoping for more details.

Default behavior

Understanding the default behavior is important to avoid unexpected results:

Passing a raw string

When you pass a raw string directly to the idempotencyKey option, it is automatically processed with run scope:
Breaking change in v4.3.1: In v4.3.0 and earlier, raw strings defaulted to global scope. Starting in v4.3.1, raw strings now default to run scope. If you’re upgrading and relied on the previous global behavior, you must now explicitly use idempotencyKeys.create("key", { scope: "global" }).
This means raw strings are scoped to the parent run by default. If you want global behavior, you must explicitly create the key with scope: "global":

Triggering from backend code

When triggering tasks from your backend code (outside of a task), there is no parent run context. In this case, run and attempt scopes behave the same as global since there’s no run ID or attempt number to inject:
When triggering from backend code, the scope doesn’t matter since there’s no task context. All scopes effectively behave as global.

idempotencyKeyTTL option

The idempotencyKeyTTL option defines a time window during which a task with the same idempotency key will only run once. Here’s how it works:
  1. When you trigger a task with an idempotency key and set idempotencyKeyTTL: "5m", it creates a 5-minute window.
  2. During this window, any subsequent triggers with the same idempotency key will return the original task run instead of creating a new one.
  3. Once the TTL window expires, the next trigger with that idempotency key will create a new task run and start a new time window.
idempotency-key-ttl By default idempotency keys are stored for 30 days. You can change this by passing the idempotencyKeyTTL option when triggering a task:
You can use the following units for the idempotencyKeyTTL option:
  • s for seconds (e.g. 60s)
  • m for minutes (e.g. 5m)
  • h for hours (e.g. 2h)
  • d for days (e.g. 3d)

Failed runs and idempotency

When a run with an idempotency key fails, the key is automatically cleared. This means triggering the same task with the same idempotency key will create a new run. However, successful and canceled runs keep their idempotency key. If you need to re-trigger after a successful or canceled run, you can:
  1. Reset the idempotency key using idempotencyKeys.reset():
  1. Use a shorter TTL so the key expires automatically:

Payload-based idempotency

We don’t currently support payload-based idempotency, but you can implement it yourself by hashing the payload and using the hash as the idempotency key.

Resetting idempotency keys

You can reset an idempotency key to clear it from all associated runs. This is useful if you need to allow a task to be triggered again with the same idempotency key. When you reset an idempotency key, it will be cleared for all runs that match both the task identifier and the idempotency key in the current environment. This allows you to trigger the task again with the same key.

API signature

Resetting keys created with idempotencyKeys.create()

When you pass an IdempotencyKey created with idempotencyKeys.create(), the scope and original key are automatically extracted, making it easy to reset:

Resetting global keys

Global keys are the simplest to reset since they don’t require any run context:

Resetting run-scoped keys

Keys created with "run" scope (the default) include the parent run ID in the hash. When resetting from inside the same task, the run ID is automatically available:
When resetting from outside a task, you must provide the parentRunId if the key was created within a task context:
If you triggered the task from backend code, all scopes behave as global (see Triggering from backend code). Use scope: "global" when resetting.

Resetting attempt-scoped keys

Keys created with "attempt" scope include both the parent run ID and attempt number. When resetting from outside a task, you must provide both:
If you try to reset a "run" or "attempt" scoped key from outside a task without providing the required parentRunId (and attemptNumber for attempt scope), it will throw an error.

Resetting from the dashboard

You can also reset idempotency keys directly from the Trigger.dev dashboard:
  1. Navigate to the run that has the idempotency key you want to reset
  2. In the run details panel, find the “Idempotency key” section
  3. Click the “Reset” button
This is useful when you need to manually allow a task to be re-triggered without writing code. Idempotency section in the run details pane showing the key, scope, and expiration time
Resetting an idempotency key only affects runs in the current environment. The reset is scoped to the specific task identifier and idempotency key combination.

Important notes

Environment and task scoping

Idempotency keys, even the ones scoped globally, are actually scoped to the task and the environment. This means that you cannot collide with keys from other environments (e.g. dev will never collide with prod), or to other projects and orgs. If you use the same idempotency key for triggering different tasks, the tasks will not be idempotent, and both tasks will be triggered. There’s currently no way to make multiple tasks idempotent with the same key.

How scopes affect the key

The scope determines what gets hashed alongside your key:
  • Same key + "run" scope in different parent runs = different hashes = both tasks run
  • Same key + "global" scope in different parent runs = same hash = only first task runs
  • Same key + different scopes = different hashes = both tasks run
This is why understanding scopes is crucial: the same string key can produce different idempotency behavior depending on the scope and context.