Skip to content
Closed
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
71 changes: 69 additions & 2 deletions docs/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ sequenceDiagram

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
- **Coordinating external side effects** - Use Trigger.dev idempotency to prevent duplicate task triggers, and use the external provider's idempotency mechanism when available to make retries of side-effecting requests safe
- **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

Expand Down Expand Up @@ -63,6 +62,74 @@ export const myTask = task({

You can use the `idempotencyKeys.create` SDK function to create an idempotency key before passing it to the `options` object.

<Warning>
Trigger.dev idempotency keys deduplicate task-trigger requests. They do not make arbitrary side effects inside `run()` exactly-once.

For external side effects, such as payments, also use the external provider's idempotency mechanism. A task can retry or crash after the provider accepts a request but before the task completes.
</Warning>

## Coordinating payment operations

For a payment operation, use a deterministic business-operation ID to derive idempotency keys for both layers. Trigger.dev deduplicates child task triggers, while your payment provider deduplicates requests for the same operation.

```ts trigger/refund.ts
import { task } from "@trigger.dev/sdk";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export const refundPayment = task({
id: "refund-payment",
run: async (payload: {
paymentIntentId: string;
amount: number;
operationId: string;
}) => {
await stripe.refunds.create(
{
payment_intent: payload.paymentIntentId,
amount: payload.amount,
},
{
// Stripe uses this key to make retries of the same request idempotent.
idempotencyKey: payload.operationId,
Comment on lines +94 to +95

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target excerpt ---'
sed -n '1,145p' docs/idempotency.mdx
printf '%s\n' '--- relevant references ---'
rg -n -C 3 'idempotency|operationId|refund|exactly.?once|Stripe' docs/idempotency.mdx

Repository: triggerdotdev/trigger.dev

Length of output: 27150


🌐 Web query:

Stripe API idempotent requests retention at least 24 hours reused key after pruning official documentation

💡 Result:

In the Stripe API, idempotency keys are retained for at least 24 hours [1][2]. After this retention period, Stripe automatically prunes or removes the keys from its system [1][2]. If you reuse an idempotency key after the original request's data has been pruned (i.e., after the 24-hour window), Stripe will treat the subsequent request as a new, unique request rather than a retry of the original [1][3]. When reusing an idempotency key, the Stripe idempotency layer performs a validation check: it compares the parameters of the incoming request against those of the original request [1][4]. If the parameters do not match the original request, Stripe will return an idempotency error to prevent accidental misuse [1][5]. If the parameters do match, the system returns the cached result of the original request, provided the key has not yet been pruned [1][2].

Citations:


Document Stripe’s idempotency retention limit.

Stripe retains API idempotency keys for at least 24 hours and can prune them afterward. If this task replays after pruning, reusing payload.operationId can create a second refund. State this limit. If a refund can replay later, persist the business operation and Stripe refund ID, then reconcile with Stripe before issuing another request.

}
);
},
});
```

```ts trigger/initiate-refund.ts
import { task } from "@trigger.dev/sdk";
import { refundPayment } from "./refund";

export const initiateRefund = task({
id: "initiate-refund",
run: async (payload: {
refundId: string;
paymentIntentId: string;
amount: number;
}) => {
// Use an ID that identifies this business operation, not a random value.
const operationId = `refund:${payload.refundId}`;

await refundPayment.trigger(
{
paymentIntentId: payload.paymentIntentId,
amount: payload.amount,
operationId,
},
{
// Trigger.dev deduplicates this child task trigger when this task retries.
idempotencyKey: operationId,
}
);
Comment on lines +116 to +126

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Raw-string idempotency key in the child trigger is run-scoped, which limits the claimed deduplication

The example passes operationId as a raw string to refundPayment.trigger(...). As documented later in the same page (docs/idempotency.mdx:172-174), a raw string defaults to "run" scope, i.e. hashed with the parent run ID. So the deduplication only holds across retries of the same initiateRefund run — if initiateRefund is triggered twice for the same refundId, two child runs are created and only Stripe's own idempotency key prevents a double refund. The inline comment ("deduplicates this child task trigger when this task retries") is accurate, but the section intro at docs/idempotency.mdx:73 ("use a deterministic business-operation ID ... Trigger.dev deduplicates child task triggers") reads as if it dedupes globally. Consider using idempotencyKeys.create(operationId, { scope: "global" }) or stating the scope caveat explicitly.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

},
});
```
Comment on lines +75 to +129

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Two related file examples are shown as separate code blocks instead of a grouped multi-file example

The new two-file refund example is written as two stand-alone code blocks (starting at docs/idempotency.mdx:75) instead of the documented multi-file grouping component, so readers see the files as unrelated snippets rather than one example.
Impact: The docs render inconsistently with the rest of the site for multi-file examples, making the two-file walkthrough harder to follow.

Documentation convention for multi-file examples

docs/CLAUDE.md explicitly lists <CodeGroup> as the component to use for "Multi-language/multi-file code examples". The PR adds trigger/refund.ts (docs/idempotency.mdx:75-100) and trigger/initiate-refund.ts (docs/idempotency.mdx:102-129) back-to-back with filename tags, which is exactly the multi-file case; they should be wrapped in a <CodeGroup> block.

Prompt for agents
The two new code blocks in docs/idempotency.mdx (trigger/refund.ts and trigger/initiate-refund.ts) form a single multi-file example. Per docs/CLAUDE.md, multi-file code examples should be wrapped in a <CodeGroup> component so Mintlify renders them as tabs. Wrap the two consecutive fenced blocks in <CodeGroup> ... </CodeGroup>.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


This does not provide a universal exactly-once guarantee for arbitrary distributed side effects. Trigger.dev deduplicates task triggers, while Stripe uses the idempotency key to safely retry the same refund request.

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

```ts
Expand Down