Idempotency

Prevent duplicate events and retries from repeating payments, messages, or provisioning.

Keep a duplicate checkout event from starting another payment run. Use an event or function key to control whether a run starts, and make the payment safe to repeat if its step runs again.

What is idempotency?

Idempotency, by definition, describes an operation that can occur multiple times without changing the result beyond the initial execution. In the world of software, this means that a functions can be executed multiple times, but it will always have the same effect as being called once. An example of this is an "upsert."

Decide where to deduplicate

  • At the event producer. Set a stable event id for one business occurrence, such as one checkout. Inngest uses it to avoid starting functions again for a duplicate event received within 24 hours. The duplicate still appears in event history. Reusing an ID for a genuinely new occurrence suppresses work, so choose the boundary carefully.
  • At the function. Use the function's idempotency expression when only one consumer of an event should run once for a business key. The expression reads event data and prevents another execution with the same value for 24 hours. Other functions triggered by that event can still run.
  • At the external service. Send a stable operation key to a payment, email, database, or provisioning API. A request can succeed before Inngest records the step result. A retry can then call the API again. Event and function deduplication do not remove that risk.

Choose an idempotency key

Start with the operation whose effect must occur once. Use a checkout ID for a payment, an order and message type for an email, or a customer and resource ID for provisioning. Include every field needed to distinguish valid repeats. Do not use a new retry attempt ID as the external idempotency key.

For example, send the same id when retrying delivery of a checkout event:

await inngest.send({
  id: `checkout-completed-${checkoutId}`,
  name: "cart/checkout.completed",
  data: { checkoutId },
});

If several functions receive that event but only the payment function needs deduplication, set that function's idempotency expression from event.data.checkoutId instead of deduplicating the whole event. For the exact TypeScript, Go, or Python configuration, use the platform and language reference tab.


Idempotency at the event level (the producer)

Each event that is received by Inngest will trigger any functions with that matching trigger. If an event is sent twice, Inngest will trigger the function twice. This is the default behavior as Inngest does not know if the event is the same event or a new event.

Example: Using an e-commerce store as an example, a user can add the same t-shirt to their cart twice because they want to buy two (2 unique events). That same user may check out and pay for all items in their cart but click the "pay" button twice (2 duplicate events).

To prevent an event from being handled twice, you can set a unique event id when sending the event. This id acts as an idempotency key over a 24 hour period and Inngest will check to see if that event has already been received before triggering another function.

const cartId = 'CGo5Q5ekAxilN92d27asEoDO';
await inngest.send({
  id: `checkout-completed-${cartId}`, // <-- This is the idempotency key
  name: 'cart/checkout.completed',
  data: {
    email: 'taylor@example.com',
    cartId: cartId
  }
})
Event IDTimestampFunction
checkout-completed-CGo5Q5ekAxilN92d27asEoDO08:00:00.000✅ Functions are triggered
checkout-completed-CGo5Q5ekAxilN92d27asEoDO08:00:00.248❌ Nothing is triggered

As you can see in the above example, setting the id allows you to prevent duplicate execution on the producer side, where the event originates.

Some other key points to note:

  • Event IDs will only be used to prevent duplicate execution for a 24 hour period. After 24 hours, the event will be treated as a new event and will trigger any functions with that trigger.
  • Inngest will store the second event and it will be visible in your event history, but it will not trigger any functions.
  • Events that fan-out to multiple functions will trigger each function as they normally would.

Tip - If you are using Inngest's webhook transforms, you can set the id in the transform to ensure that the event is idempotent.

Event idempotency is ignored by some features:

  • Debouncing
  • Event batching
  • Function pausing. While a function is paused, event idempotency is ignored. So if a replay is created after unpausing, it may have "skipped" runs that ignored event idempotency.

Idempotency at the function level (the consumer)

You might prefer to ensure idempotency at the function level or you may not be able to control the event that is being sent (from a webhook). The function's idempotency config option allows you to do this.

Each function's idempotency key is defined as a CEL expression that is evaluated with the event payload's data. The expression is used to generate a unique string key which idempotently prevents duplicate execution of the function.

Each unique expression will only trigger one function execution per 24 hour period. After 24 hours, a new event that generates the same unique expression will trigger another function execution.

Example

We'll use the same example of an e-commerce store to demonstrate how this works. We have an event here with no id set (see above), but we want to ensure that the send-checkout-email function is only triggered once for each cartId to prevent duplicate emails being sent.

Event payload
{
  "name": "cart/checkout.completed",
  "data": {
    "email": "blake@example.com",
    "cartId": "s6CIMNqIaxt503I1gVEICfwp"
  },
  "ts": 1703275661157
}
Function definition with idempotency key
export const sendEmail = inngest.createFunction(
  {
    id: 'send-checkout-email',
    // This is the idempotency key
    idempotency: 'event.data.cartId',
    // Evaluates to: "s6CIMNqIaxt503I1gVEICfwp"
    // for the given event payload
    triggers: { event: 'cart/checkout.completed' },
  },
  async  ({ event, step }) => { /* ... */ }
})

Writing CEL expressions

While CEL can do many things, we'll focus on how to use it to generate a unique string key for idempotency. The key things to know are:

  • You can access any of the event payload's data using the event variable and dot-notation for nested properties.
  • You can use the + operator to concatenate strings together.

Combining two or more properties together is a good way to ensure the level of uniqueness that you need. Here are couple of examples:

  • User signup: You only want to send a welcome email once per user, so you'd set idempotency to event.data.userId in case there your API sends duplicate events.
  • Organization team invite: A user may be part of multiple organizations in your app. You only want to send a team invite email once per user/organization combination, so you'd set idempotency to event.data.userId + "-" + event.data.organizationId.

For more information on writing CEL expressions, read our guide.

💡 If you want to control when a function is executed over a period of time you might prefer:

  • rateLimit - Limit the number of function executions per period of time
  • debounce - Delay function execution for duplicate events over a period of time

Idempotency keys and fan-out

One reason why you might want to use idempotency at the function level is if you have an event that fans-out to multiple functions. Let's take the following fan-out example:

FunctionEvent triggerHow often
Track requestsai/generation.requestedEvery time
Run generationai/generation.requestedOnce per request

In this case, you would want to set idempotency on the "Run generation" function to ensure that it runs once, for example, for every unique prompt that is sent. You may want to do this as you don't want to re-run the same exact prompt and waste compute resources/credits. However, you still might want to track the number of requests that each user submitted, so you would not want to set idempotency on the "Track requests" function. You can see the code for both functions below.

View the function code

Both functions use the same event trigger, ai/generation.requested which contains a promptHash and a userId in the event payload.

Track requests function
const trackRequests = inngest.createFunction(
  { id: 'track-requests', triggers: { event: 'ai/generation.requested' } },
  async ({ event, step }) => {
    // Track the request
  }
)
Run generation function
const runGeneration = inngest.createFunction(
  {
    id: 'run-generation',
    // Given the event payload sends a hash of the prompt,
    // this will only run once per unique prompt per user
    // every 24 hours:
    idempotency: `event.data.promptHash + "-" + event.data.userId`,
    triggers: { event: 'ai/generation.requested' },
  },
  async ({ event, step }) => {
    // Track the request
  }
)

Check feature interactions

The current event-ID deduplication behavior does not apply to debouncing, event batching, or events received while a function is paused. Design and test those paths separately. Rate limits and debounce control when work starts; they do not replace an idempotency key for an external effect.

Verify the behavior

Send the same event ID twice in a test environment and inspect the event history and function runs. Then test two valid business occurrences with distinct IDs. Test an external call that succeeds just before the step reports a failure, and confirm the provider accepts the repeated operation key.