Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 322 additions & 9 deletions docs/idempotency.mdx
Original file line number Diff line number Diff line change
@@ -1,14 +1,44 @@
---
title: "Idempotency"
description: "An API call or operation is idempotent if it has the same result when called more than once."
description: "An API call or operation is idempotent if it has the same result when called more than once."
---

We currently support idempotency at the task level, meaning that if you trigger a task with the same `idempotencyKey` twice, the second request will not create a new task run.
We currently support idempotency at the task level, meaning that if you trigger a task with the same `idempotencyKey` twice, the second request will not create a new task run. Instead, the original run's handle is returned, allowing you to track the existing run's progress.

## 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:

```mermaid
sequenceDiagram
participant Parent as Parent Task
participant SDK as Trigger.dev
participant Child as Child Task

Note over Parent: Attempt 1
Parent->>SDK: trigger(childTask, {idempotencyKey})
SDK->>Child: Creates child run
Child-->>SDK: Running...
Parent->>Parent: Fails, will retry

Note over Parent: Attempt 2 (retry)
Parent->>SDK: trigger(childTask, {idempotencyKey})
Note over SDK: Same key, returns existing run
SDK-->>Parent: Returns original child run

Note over Child: Child task only runs once
```

Other common use cases include:

- **Preventing duplicate emails** - Ensure a confirmation email is only sent once, even if the parent task retries
- **Avoiding double-charging customers** - Prevent duplicate payment processing during retries
- **One-time setup tasks** - Ensure initialization or migration tasks only run once
- **Deduplicating webhook processing** - Handle the same webhook event only once, even if it's delivered multiple times

## `idempotencyKey` option

You can provide an `idempotencyKey` to ensure that a task is only triggered once with the same key. This is useful if you are triggering a task within another task that might be retried:
You can provide an `idempotencyKey` when triggering a task:

```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";
Expand Down Expand Up @@ -72,6 +102,10 @@ import { myTask } from "./trigger/myTasks";
await myTask.trigger({ some: "data" }, { idempotencyKey: myUser.id });
```

<Note>
When you pass a raw string, it defaults to `"run"` scope (scoped to the parent run). See [Default behavior](#default-behavior) for details on how scopes work and how to use global scope instead.
</Note>

<Note>Make sure you provide sufficiently unique keys to avoid collisions.</Note>

You can pass the `idempotencyKey` when calling `batchTrigger` as well:
Expand All @@ -87,6 +121,149 @@ await tasks.batchTrigger("my-task", [
]);
```

## 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

| Scope | What gets hashed | Description | Use case |
| --- | --- | --- | --- |
| `"run"` | `key + parentRunId` | Key is combined with the parent run ID | Prevent duplicates within a single parent run (default) |
| `"attempt"` | `key + parentRunId + attemptNumber` | Key is combined with the parent run ID and attempt number | Allow child tasks to re-run on each retry of the parent |
| `"global"` | `key` | Key is used as-is, no context added | Ensure a task only runs once ever, regardless of parent |

### `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()`.

```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const processOrder = task({
id: "process-order",
retry: { maxAttempts: 3 },
run: async (payload: { orderId: string; email: string }) => {
// This key is scoped to this specific run of processOrder
// If processOrder retries, the same key still refers to the same child run
const idempotencyKey = await idempotencyKeys.create(`send-confirmation-${payload.orderId}`);

// sendEmail will only be triggered once, even if processOrder retries multiple times
await sendEmail.trigger(
{ to: payload.email, subject: "Order confirmed" },
{ idempotencyKey }
);

// ... more processing that might fail and cause a retry
},
});
```

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.

```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const syncData = task({
id: "sync-data",
retry: { maxAttempts: 3 },
run: async (payload: { userId: string }) => {
// This key changes on each retry attempt
const idempotencyKey = await idempotencyKeys.create(`fetch-${payload.userId}`, {
scope: "attempt",
});

// fetchLatestData will run again on each retry, getting fresh data
const result = await fetchLatestData.triggerAndWait(
{ userId: payload.userId },
{ idempotencyKey }
);

// Process the fresh data...
},
});
```

### `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.

```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const onboardUser = task({
id: "onboard-user",
run: async (payload: { userId: string; email: string }) => {
// This key is global - the welcome email will only be sent once per user,
// even if onboardUser is triggered multiple times from different places
const idempotencyKey = await idempotencyKeys.create(`welcome-email-${payload.userId}`, {
scope: "global",
});

await sendWelcomeEmail.trigger(
{ to: payload.email },
{ idempotencyKey, idempotencyKeyTTL: "7d" }
);
},
});
```

<Note>
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](#environment-and-task-scoping) for more details.
</Note>

## 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:

```ts
// These two are equivalent when called inside a task:
await childTask.trigger(payload, { idempotencyKey: "my-key" });
await childTask.trigger(payload, { idempotencyKey: await idempotencyKeys.create("my-key") });
```

<Warning>
**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" })`.
</Warning>

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"`:

```ts
// For global idempotency, you must use idempotencyKeys.create with scope: "global"
const idempotencyKey = await idempotencyKeys.create("my-key", { scope: "global" });
await childTask.trigger(payload, { idempotencyKey });
```

### 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:

```ts
// In your backend code (e.g., an API route)
import { idempotencyKeys, tasks } from "@trigger.dev/sdk";

// All three of these behave the same when called outside a task:
await tasks.trigger("my-task", payload, { idempotencyKey: "my-key" });
await tasks.trigger("my-task", payload, {
idempotencyKey: await idempotencyKeys.create("my-key", { scope: "run" }),
});
await tasks.trigger("my-task", payload, {
idempotencyKey: await idempotencyKeys.create("my-key", { scope: "global" }),
});
```

<Note>
When triggering from backend code, the scope doesn't matter since there's no task context. All scopes effectively behave as global.
</Note>

## `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:
Expand Down Expand Up @@ -131,6 +308,32 @@ You can use the following units for the `idempotencyKeyTTL` option:
- `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()`:

```ts
import { idempotencyKeys } from "@trigger.dev/sdk";

// Clear the idempotency key to allow re-triggering
await idempotencyKeys.reset("my-task", "my-idempotency-key");

// Now you can trigger the task again
await myTask.trigger(payload, { idempotencyKey: "my-idempotency-key" });
```

2. **Use a shorter TTL** so the key expires automatically:

```ts
// Key expires after 5 minutes
await myTask.trigger(payload, {
idempotencyKey: "my-key",
idempotencyKeyTTL: "5m"
});
```

## 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.
Expand Down Expand Up @@ -159,25 +362,135 @@ You can reset an idempotency key to clear it from all associated runs. This is u

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

```ts
idempotencyKeys.reset(
taskIdentifier: string,
idempotencyKey: string,
requestOptions?: ZodFetchOptions
): Promise<{ id: string }>
```

| Parameter | Description |
| --- | --- |
| `taskIdentifier` | The identifier of the task (e.g., `"my-task"`) |
| `idempotencyKey` | The idempotency key hash to reset (the 64-character hash string) |
| `requestOptions` | Optional fetch options for the API request |
Comment on lines +365 to +379
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for idempotencyKeys.reset implementation
rg -n "reset.*idempotencyKey" --type=ts --type=tsx -A 5 | head -50

# Search for IdempotencyKeysAPI or similar class definitions
rg -n "class.*IdempotencyKeys|interface.*IdempotencyKeys" --type=ts --type=tsx -A 10 | head -50

Repository: triggerdotdev/trigger.dev

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Find files that define idempotencyKeys
fd -e ts -e tsx | xargs rg -l "reset.*function|reset.*:.*\=>" | grep -E "(idempotency|reset)" | head -20

Repository: triggerdotdev/trigger.dev

Length of output: 190


🏁 Script executed:

#!/bin/bash
# Search more broadly for reset method in SDK files
find . -type f \( -name "*.ts" -o -name "*.tsx" \) | xargs grep -l "idempotencyKeys" | head -10

Repository: triggerdotdev/trigger.dev

Length of output: 613


🏁 Script executed:

#!/bin/bash
# Examine the idempotencyKeys server implementation
cat -n ./apps/webapp/app/runEngine/concerns/idempotencyKeys.server.ts | head -200

Repository: triggerdotdev/trigger.dev

Length of output: 5952


🏁 Script executed:

#!/bin/bash
# Check the API client that users interact with
cat -n ./packages/core/src/v3/apiClient/index.ts | head -150

Repository: triggerdotdev/trigger.dev

Length of output: 5123


🏁 Script executed:

#!/bin/bash
# Search for reset method in the API client
rg -n "reset" ./packages/core/src/v3/apiClient/index.ts -A 10 -B 2

Repository: triggerdotdev/trigger.dev

Length of output: 2308


🏁 Script executed:

#!/bin/bash
# Look at the ResetIdempotencyKeyResponse schema
fd -name "*.ts" -path "*/schemas/*" | xargs rg -l "ResetIdempotencyKeyResponse" | head -5

Repository: triggerdotdev/trigger.dev

Length of output: 392


🏁 Script executed:

#!/bin/bash
# Find the ZodFetchOptions definition
rg -n "type ZodFetchOptions|interface ZodFetchOptions" ./packages/core/src/v3/apiClient/ -A 15

Repository: triggerdotdev/trigger.dev

Length of output: 1240


🏁 Script executed:

#!/bin/bash
# Look at the ResetIdempotencyKeyResponse schema
cat -n ./packages/core/src/v3/schemas/api.ts | grep -A 3 -B 3 "ResetIdempotencyKeyResponse"

Repository: triggerdotdev/trigger.dev

Length of output: 474


🏁 Script executed:

#!/bin/bash
# Read the actual documentation file to see the examples
cat -n ./docs/idempotency.mdx | sed -n '365,480p'

Repository: triggerdotdev/trigger.dev

Length of output: 4583


🏁 Script executed:

#!/bin/bash
# Also search for how reset is actually being called in tests or server code
rg -n "resetIdempotencyKey" ./apps/webapp --type=ts -B 3 -A 3 | head -50

Repository: triggerdotdev/trigger.dev

Length of output: 6691


🏁 Script executed:

#!/bin/bash
# Search for the user-facing idempotencyKeys.reset implementation
fd -name "*.ts" -path "*sdk*" -o -path "*core*" | xargs grep -l "idempotencyKeys\|reset" | grep -v node_modules | head -20

Repository: triggerdotdev/trigger.dev

Length of output: 299


🏁 Script executed:

#!/bin/bash
# Look for the idempotencyKeys API wrapper in packages/sdk
find ./packages -name "*.ts" -type f | xargs grep -l "idempotencyKeys.*reset\|export.*idempotencyKeys" | head -10

Repository: triggerdotdev/trigger.dev

Length of output: 268


🏁 Script executed:

#!/bin/bash
# Check if there's a wrapper that transforms the reset call
find ./packages/sdk -name "*.ts" -type f 2>/dev/null | head -20

Repository: triggerdotdev/trigger.dev

Length of output: 51


🏁 Script executed:

#!/bin/bash
# Examine the trigger-sdk idempotencyKeys implementation
cat -n ./packages/trigger-sdk/src/v3/idempotencyKeys.ts | head -200

Repository: triggerdotdev/trigger.dev

Length of output: 359


🏁 Script executed:

#!/bin/bash
# Find the resetIdempotencyKey implementation in core
rg -n "export.*resetIdempotencyKey|function resetIdempotencyKey" ./packages/core/src/v3 -A 20

Repository: triggerdotdev/trigger.dev

Length of output: 980


🏁 Script executed:

#!/bin/bash
# Also check the exports from `@trigger.dev/core/v3`
cat -n ./packages/core/src/v3/index.ts | head -100

Repository: triggerdotdev/trigger.dev

Length of output: 3289


🏁 Script executed:

#!/bin/bash
# See the full idempotencyKeys.ts file to understand how scope is handled
cat -n ./packages/core/src/v3/idempotencyKeys.ts

Repository: triggerdotdev/trigger.dev

Length of output: 5639


Documentation examples pass incorrect parameters to idempotencyKeys.reset().

The API signature correctly shows requestOptions?: ZodFetchOptions as the third parameter, but the examples at lines 408, 437-440, and 451-455 incorrectly pass objects like { scope: "global" }, { scope: "run", parentRunId: "..." }, and { scope: "attempt", parentRunId: "...", attemptNumber: 1 }.

These objects do not match ZodFetchOptions (which contains retry, tracer, name, attributes, icon, onResponseBody, and prepareData). The scope parameter belongs to idempotencyKeys.create(), not reset() — scope is determined when the key is created, not when it's reset. Users following these examples will encounter API errors.

Update the examples to remove the scope, parentRunId, and attemptNumber parameters. Line 395's example (await idempotencyKeys.reset("child-task", key);) is correct and should be the pattern for all cases.

🤖 Prompt for AI Agents
In `@docs/idempotency.mdx` around lines 365 - 379, The docs show incorrect example
calls to idempotencyKeys.reset(...) passing `{ scope: "...", parentRunId: "...",
attemptNumber: ... }` which are not part of the third parameter type
ZodFetchOptions; remove scope/parentRunId/attemptNumber from all reset examples
(lines referenced: examples at 408, 437-440, 451-455) and make them follow the
correct pattern used at line 395 (e.g., await
idempotencyKeys.reset("child-task", key);), leaving only an optional
ZodFetchOptions object (retry, tracer, name, attributes, icon, onResponseBody,
prepareData) if needed; ensure any mention of scope remains with
idempotencyKeys.create(...) examples only.


### 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:

```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const parentTask = task({
id: "parent-task",
run: async (payload) => {
const key = await idempotencyKeys.create("my-key", { scope: "global" });
await childTask.trigger(payload, { idempotencyKey: key });

// Later in the same task, reset it - options extracted automatically
await idempotencyKeys.reset("child-task", key);
},
});
```

### Resetting global keys

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

```ts
import { idempotencyKeys } from "@trigger.dev/sdk";

// Reset an idempotency key for a specific task
await idempotencyKeys.reset("my-task", "my-idempotency-key");
// From anywhere - inside a task or from your backend code
await idempotencyKeys.reset("my-task", "my-key", { scope: "global" });
```

The `reset` function requires both parameters:
- `taskIdentifier`: The identifier of the task (e.g., `"my-task"`)
- `idempotencyKey`: The idempotency key to reset
### Resetting run-scoped keys

After resetting, any subsequent triggers with the same idempotency key will create new task runs instead of returning the existing ones.
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:

```ts
import { idempotencyKeys, task } from "@trigger.dev/sdk";

export const parentTask = task({
id: "parent-task",
run: async (payload) => {
// Create a run-scoped key (default)
const key = await idempotencyKeys.create("my-key");
await childTask.trigger(payload, { idempotencyKey: key });

// Reset works automatically inside the task - run ID is available from context
await idempotencyKeys.reset("child-task", key);
},
});
```

When resetting from outside a task (e.g., from your backend code), you must provide the `parentRunId`:

```ts
import { idempotencyKeys } from "@trigger.dev/sdk";

// From your backend code - you need to know the parent run ID
await idempotencyKeys.reset("my-task", "my-key", {
scope: "run",
parentRunId: "run_abc123"
});
```

### 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:

```ts
import { idempotencyKeys } from "@trigger.dev/sdk";

// From your backend code
await idempotencyKeys.reset("my-task", "my-key", {
scope: "attempt",
parentRunId: "run_abc123",
attemptNumber: 1
});
```
Comment on lines +400 to +456
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Examples don't match the API signature above.

All examples in these sections pass { scope: "...", parentRunId: "...", attemptNumber: ... } as the third parameter, but the documented signature on line 371 specifies requestOptions?: ZodFetchOptions.

If the SDK truly only accepts ZodFetchOptions for request configuration (as noted in a previous review), these examples need to be rewritten. The scope/parentRunId/attemptNumber would need to be incorporated into the key hash before calling reset(), not passed as options.

Alternatively, if the SDK was updated to accept these options, the API signature section needs updating.

🤖 Prompt for AI Agents
In `@docs/idempotency.mdx` around lines 400 - 456, The examples call
idempotencyKeys.reset("task", "key", { scope, parentRunId, attemptNumber }) but
the documented signature shows requestOptions?: ZodFetchOptions; fix by making
the docs and examples consistent with the SDK: either update the API signature
to accept a proper ResetOptions type (e.g., replace ZodFetchOptions with
ResetOptions including scope/parentRunId/attemptNumber) and describe that in the
signature, or change the examples to compute and pass the fully-qualified
key/hash (using idempotencyKeys.create or the SDK's key-derivation method) and
pass only ZodFetchOptions to reset; reference idempotencyKeys.reset and
idempotencyKeys.create in the examples and ensure the signature line for reset
reflects the chosen behavior.


<Warning>
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.
</Warning>

### 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](/images/idempotency-key-dashboard.png)

<Note>
Resetting an idempotency key only affects runs in the current environment. The reset is scoped to the specific task identifier and idempotency key combination.
</Note>

## 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.


Binary file added docs/images/idempotency-key-dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.