Skip to content

Commit 8d3fd9b

Browse files
committed
docs: use the run ID as the idempotency key for external services
The run ID is stable across every attempt of a run, so it is the natural key to hand to a payment provider when the task has no business ID to use. The refund example now keys Stripe on ctx.run.id and notes when a payload ID is the better choice.
1 parent adc4979 commit 8d3fd9b

1 file changed

Lines changed: 8 additions & 6 deletions

File tree

docs/idempotency.mdx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ An idempotency key stops a task from being *triggered* twice. It does not stop t
4444

4545
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.
4646

47-
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 retries, such as an ID from your payload:
47+
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.
4848

4949
```ts /trigger/refund-order.ts
5050
import { idempotencyKeys, task } from "@trigger.dev/sdk";
@@ -56,20 +56,22 @@ const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
5656
export const refundOrder = task({
5757
id: "refund-order",
5858
retry: { maxAttempts: 3 },
59-
run: async (payload: { orderId: string; paymentIntentId: string; amount: number }) => {
60-
// Stripe returns the original refund for a repeated key instead of creating a second one
59+
run: async (payload: { orderId: string; paymentIntentId: string; amount: number }, { ctx }) => {
60+
// The run ID is stable across retries, so Stripe returns the original refund instead of creating a second one
6161
await stripe.refunds.create(
6262
{ payment_intent: payload.paymentIntentId, amount: payload.amount },
63-
{ idempotencyKey: `refund-${payload.orderId}` }
63+
{ idempotencyKey: `refund-${ctx.run.id}` }
6464
);
6565

66-
// The Trigger.dev key deduplicates the trigger, so a retry does not create a second email run
67-
const idempotencyKey = await idempotencyKeys.create(`refund-email-${payload.orderId}`);
66+
// The Trigger.dev key is run-scoped by default, so a retry does not create a second email run
67+
const idempotencyKey = await idempotencyKeys.create("refund-email");
6868
await sendRefundEmail.trigger({ orderId: payload.orderId }, { idempotencyKey });
6969
},
7070
});
7171
```
7272

73+
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.
74+
7375
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.
7476

7577
## `idempotencyKey` option

0 commit comments

Comments
 (0)