Module 2: Idempotency: Making Repeats Not Duplicate

5. Making an API call idempotent

Description

By the end of this lesson you'll be able to make an effect that lives in a third-party API idempotent —like Cumbre's charge at its payment gateway— using the Idempotency-Key header serious APIs offer exactly for this. You're going to know how to pass that header from the HTTP Request node, what the API does when it receives the same key twice, and what to do when the API doesn't offer that header: the check-before-create pattern, with the big warning that prepares you for lesson 6.

This matters because lesson 4's upsert solves the duplicate only when the effect is your database, where you control the uniqueness constraint. But half of a real workflow's dangerous effects live in systems you don't control: a payment gateway, an email provider, a cloud CRM. There you can't create a uniqueness constraint; you depend on the API giving you a mechanism. The Idempotency-Key header is that mechanism, and it's the one that fixes Cumbre's second charge —the most expensive harm in the whole case study—.

Connection to the module: this is the second of the two ways to apply lesson 3's key to an effect. The first was the upsert (lesson 4), for your own data; this is the header, for third-party data. Both consume the same idempotency_key you computed in lesson 3 —you reuse it as-is as the header's value—. Lesson 6 takes the weak pattern that shows up at the end of this lesson —check-before-create— and shows why it hides a race condition; and lesson 8's project applies this header to Cumbre's real charge.

The problem: you don't control the gateway's database

Cumbre's charge is a POST request to the payment gateway:

POST /charges
{
  "customer_id": "CUST-118",
  "amount": 1780,
  "currency": "MXN"
}

You already know from lesson 2 that POST isn't idempotent: every call creates a new charge. And you already know from lesson 4 how to fix that when you write to your own database: a uniqueness constraint and an upsert. But here there's a wall: the charges database belongs to the gateway, not to you. You can't get into their server and add a uniqueness constraint on "customer + amount." You don't have access. The upsert isn't an option when the effect lives on the other side of an API.

So how do you tell a system you don't control "this charge and the previous one are the same, don't charge twice"? You send it the key, and trust it knows how to use it. That's the deal with the idempotency header.

The Idempotency-Key header

Well-designed APIs —especially ones that move money, like payment gateways— offer a solution to the duplicate problem: a special HTTP header where you send them your idempotency key, and they take care of not repeating the effect.

The standard name for that header, in the gateways that implement it, is Idempotency-Key. You use it like this: in your POST request, besides the body, you add a header with your key:

POST /charges
Idempotency-Key: a3f1c9e2b8...        ← your idempotency key (the one from lesson 3)
{
  "customer_id": "CUST-118",
  "amount": 1780,
  "currency": "MXN"
}

And here's what the API does internally when it receives that header:

The first time it sees that Idempotency-Key, it doesn't recognize it. It processes the charge normally, creates the charge, and stores the result associated with that key: "key a3f1c9e2... produced charge ch_777."

The second time it sees the same Idempotency-Key —because the webhook's retry fired your workflow again, and your key is stable— it recognizes it. It doesn't create a second charge. Instead, it returns you the same result as the first time, charge ch_777, as if it had just processed it. For your workflow, both calls return success and the same charge. For the customer, there's a single charge.

You recognize the pattern from lesson 2, right? It's the DELETE that responds differently but leaves the same state. Here the first response says "created" and the second "here's the one I already had," responses that feel different, but the gateway's state is the same: a single charge. The API is giving you idempotency over a POST that by nature doesn't have it.

The analogy: it's a bank transfer's reference number. When you order a transfer and give it a reference number, and then —out of nerves, because the app froze— you order it again with the same reference number, the bank recognizes the reference and doesn't send the money twice; it tells you "I already made that transfer." The reference number is your Idempotency-Key, and the bank is the API that respects it.

Where the key you send comes from

A point that connects the whole module's chain: the key you put in the header is exactly the idempotency_key you computed in lesson 3. It isn't a new or different key. It's the same one. You computed it early, right after the webhook, and it's traveled with the item all the way here; now you read it and send it in the header.

This finally explains why in lesson 3 we were so strict about the key needing to be stable across retries. If you'd used a timestamp or a randomUUID(), the second call to the gateway would carry a different key, the gateway wouldn't recognize it, and it would create the second charge. The whole header's protection rests on your key being the same on both arrivals. The header is the mechanism; the key's stability is what makes it work.

There's a nuance serious gateways add that's worth knowing: if you send the same key but with a different body —same Idempotency-Key, but this time amount: 9999 instead of 1780— many APIs deliberately return an error, instead of processing it. It's a protection: the API assumes that if the key is the same, the operation should be the same, and a difference in the body smells like a bug on your side. Keep it in mind: the key and the content should go hand in hand.

Worked example: the header in the HTTP Request node

Let's make Cumbre's charge idempotent in the HTTP Request node. We assume idempotency_key already comes in the item (you computed it in lesson 3).

Step 1 — The HTTP Request node with its body. You configure the node to make the POST to the gateway's charges endpoint, with the usual body: customer_id, amount, currency. Up to here it's the same charge you already had, the one that duplicated.

Step 2 — Add the header. In the HTTP Request node there's an option to send headers —usually a toggle like Send Headers that, when turned on, lets you add name-value pairs—. You add a header:

  • Name: Idempotency-Key
  • Value: {{ $json.idempotency_key }}

The expression {{ $json.idempotency_key }} takes the key from the incoming item —the one you computed in lesson 3— and sets it as the header's value. Check the exact label for the headers toggle in your version; the concept doesn't change, the button text sometimes does.

Step 3 — Verify the header name against the API's documentation. This step isn't optional and it's easy to forget. The name Idempotency-Key is the one several major gateways use, but not every API calls it the same, and some don't offer it at all. Before trusting it, open the specific API's documentation and confirm three things: (a) that it supports idempotency, (b) the header's exact name, and (c) how long it remembers the key. As of this guide's writing, the reference gateway uses Idempotency-Key and remembers the key for about 24 hours; but that can change and it varies between providers, so the source of truth is always their current documentation, not this lesson.

Step 4 — Test the idempotency. Trigger the workflow with ORD-2041. Look at the charge created in the gateway (its dashboard or its query API). Now trigger the same ORD-2041 again, simulating the retry.

What to expect: the second call returns a successful response —not an error— and, when you check the gateway, there's still a single charge, the same one from the first time. If instead you see two charges, the cause is almost always one of two: the header isn't being sent (check that the toggle is on and the name is spelled correctly), or your idempotency_key isn't stable between the two arrivals (go back to lesson 3 and check it doesn't include anything time-related). Those two account for 90% of the failures.

Notice how elegant this is: you didn't have to build any table, any record, any mechanism of your own. You reused the key you already had and turned on a header. The gateway did all the heavy lifting of remembering and deduplicating. When the API cooperates, idempotency is almost free.

Worked example: the two calls, side by side

So you see exactly what your workflow receives, let's look at the two concrete requests when ORD-2041's webhook arrives twice. We assume your stable idempotency_key is a3f1c9e2b8....

First call (webhook's first arrival):

POST /charges
Idempotency-Key: a3f1c9e2b8...
{ "customer_id": "CUST-118", "amount": 1780, "currency": "MXN" }

What to expect in the response:

HTTP/1.1 200 OK
{ "id": "ch_777", "amount": 1780, "status": "succeeded" }

The gateway had never seen that key. It created charge ch_777 and stored "key a3f1c9e2... → charge ch_777." At this moment, the gateway's state is: one charge, ch_777.

Second call (webhook's retry, same key because it's stable):

POST /charges
Idempotency-Key: a3f1c9e2b8...
{ "customer_id": "CUST-118", "amount": 1780, "currency": "MXN" }

What to expect in the response:

HTTP/1.1 200 OK
{ "id": "ch_777", "amount": 1780, "status": "succeeded" }

Here's the magic. The response is a successful 200 OK, same as the first —your workflow doesn't even find out it was a retry—, but look at the id: it's ch_777, the same one as the first time. The gateway recognized the key, didn't create a new charge, and returned you the one it already had. The gateway's state is still one charge, ch_777.

ResponseGateway state afterward
1st call200 OK, charge ch_777 (created)1 charge: ch_777
2nd call200 OK, charge ch_777 (retrieved)1 charge: ch_777 (unchanged)

It's the same phenomenon as lesson 2's DELETE: the second call's response can feel the same or different, but what matters is that the state didn't change. A single charge. The customer sees a single card charge. That's idempotency working.

How long the API remembers the key

A detail that affects your design decisions: APIs don't remember keys forever. Storing every key's result costs space, so they forget them after a while —on the reference gateway, around 24 hours, but check the current number in their documentation—.

What does this mean in practice? That the header's protection covers retries that happen within that window. A webhook retry that arrives three seconds, three minutes, or three hours later is covered: the key is still in the API's memory. But if for some reason the "same" event got reprocessed days later —a manual replay of an old execution, for example—, the key would have already expired, the API would see it as new, and it would create a second charge.

For normal retries —which happen in seconds or minutes— the 24-hour window is more than enough and you don't have to think about this. But it's one more reason why an idempotent PUT (which sets a state and does so forever, with no expiration window) is sometimes preferable to the header, and why module 4's own record —which doesn't expire unless you decide it does— is the ultimate safety net for effects you can never afford to duplicate.

When the API doesn't offer the header

Not every API is that friendly. Many endpoints —especially older or simpler services— offer no idempotency header at all. You send "create a contact" twice and it creates two contacts, with no key you can send to avoid it. So what do you do?

The pattern everyone thinks of first is check before create: you first ask the API "does this resource already exist?", and only if it doesn't, you create it.

1. GET /contacts?email=luna@example.com   → does it already exist?
2. If NO → POST /contacts                 → create it
   If YES → do nothing

In an n8n workflow that would be two nodes: an HTTP Request that queries, an If that decides, and a second HTTP Request that creates only if the If says it didn't exist.

And here I have to be honest with you, because it's the heart of the next lesson: this pattern is better than nothing, but it's fragile, and its fragility is subtle. It works perfectly when executions happen one after another, with plenty of time between them. But when two executions run almost at the same time —exactly what happens when the webhook fires double and n8n processes both nearly together— both can ask "does it exist?", both see "no," and both create. The duplicate you wanted to avoid shows up anyway, because time passed between "checking" and "creating," and in that time the other execution also checked.

This problem has a name —the check-then-act trap—, it's the entire reason lesson 6 exists, and for now I just want you to leave this lesson with the hierarchy clear:

The order of preference for making an API effect idempotent:

  1. If the API offers Idempotency-Key (or an equivalent): use it. It's the robust solution. The API deduplicates atomically on the server side, with no race windows. It's what we did with Cumbre's charge.
  2. If it doesn't, but the resource has an identifier you control: try a PUT instead of a POST. Remember lesson 2: PUT /contacts/luna@example.com "puts the contact in this state" and is idempotent by nature, while POST /contacts creates. If the API lets you set by id, you win.
  3. Only if none of the above is possible, use check-before-create, knowing it has a race window and protecting it as best you can —ideally backing it up with idempotency on your own side (a table with a uniqueness constraint that records "I already called this API for this event," which is exactly what module 4 builds)—.

The biggest lesson in this unit isn't the header itself; it's this hierarchy. Prefer the server deduplicating (option 1). If not, set a state instead of creating (option 2). And treat "check and create" (option 3) as the last resort it is, not as the default solution.

There's a deep reason behind this hierarchy's order, and it's worth naming: the closer to the data the uniqueness guarantee lives, the stronger it is. The header and the PUT put the guarantee on the server that owns the data —the closest place possible—, and that's why they're atomic and have no gaps. Check-and-create puts the guarantee in your workflow, far from the data, coordinating two operations at a distance, and that's why it has a race window. The same idea explains why lesson 4's upsert is so robust: the guarantee lives inside the database, right next to the data. When you can choose where to put the uniqueness, put it as close to the data as you can; when you have to put it far away, you already know you're on fragile ground and need your own atomic referee to compensate.

An extra caution: email and other "one-way" effects

The charge isn't the only effect in Cumbre's HTTP Request. It also sends a confirmation email, and emails have an uncomfortable peculiarity: they're one-way. Once the email went out, it's out; there's no way to "un-send" it. You can't do an upsert on your customer's inbox.

For effects like this, idempotency rests on the same thing, with a nuance. Some transactional email providers do offer their own idempotency header or field —you send a key and they won't send the same message twice—; check your provider's documentation. When they don't offer it, the protection moves to your side: before sending, you check your own record of "did I already send ORD-2041's confirmation email?" and only send if not. That record is, again, a table with a uniqueness constraint —module 4's ledger—, and it's why that module exists: for effects no API deduplicates for you, the memory of "I already did this" has to live in your own system.

For now, Cumbre's practical conclusion: we make the charge idempotent with the gateway's header (option 1). The email, if the provider allows it, with its own header; and if not, leaning on the record module 4 is going to build. Not everything gets solved in this lesson, and that's fine: here we solve the charge, which is the most expensive harm.

There's a general principle behind this worth stating, because it's going to guide you on effects we haven't even mentioned. Rank your effects by reversibility, and protect them in that order. A charge can be reversed with effort (a refund); a sent email, can't; a WhatsApp message to a customer, can't either. The less reversible an effect is, the more it's worth investing in making it idempotent before triggering it, because you're not going to get a second chance to fix it afterward. In order-triage, if you had to pick a single effect to shield first, it would be the one you can't undo. Module 6 revisits this idea with "compensating actions" —how to undo what you couldn't avoid repeating—, but the best compensating action is the one you never need because the effect was idempotent from the start.

Common mistakes

Trusting a header name without verifying it (practical). What happens: someone reads that the header is called Idempotency-Key, adds it to an HTTP Request that calls an API that actually calls it something different —or doesn't support it at all— and assumes they're protected. In production, charges duplicate anyway because the API ignored a header it doesn't recognize. Why it happens: Idempotency-Key is the name several major gateways use, and it's easy to generalize that "that's what it's called everywhere." It isn't true: every API decides its own name, and some offer none. How to spot it: check the specific API's documentation you're calling and look for the idempotency section; if you can't find it, the API probably doesn't support it and a made-up header does nothing. How to fix it: use the exact name that API's documentation states. If it doesn't offer idempotency, move down the hierarchy —try a PUT, or back it up with your own record—; don't assume a plausibly named header is protecting you.

Sending a key that changes between arrivals (practical). What happens: the header is set correctly, with the right name, but the value is {{ $now }}, a randomUUID(), or a key that includes the time. The API receives a different key on every arrival, recognizes none as a repeat, and duplicates. Why it happens: it's lesson 3's mistake showing up here. The header only works if the key is the same on both arrivals of the same event. How to spot it: look at the header's value across two executions of the same event; if they're different, that's the bug. How to fix it: send the stable idempotency_key you computed in lesson 3 —derived from the content, with nothing time-related—. The correct header with an unstable key protects nothing.

Treating "check then create" as a robust solution (conceptual). What happens: the API offers no header, so the two-node pattern gets built —check if it exists, create if not—, it's tested by firing the event once, it works, and it's considered solved. In production, when the webhook fires double and both executions run almost together, the duplicate shows up. Why it happens: there's a time gap between the checking node and the creating node, and two concurrent executions can both check "doesn't exist" before either creates. Manual, sequential testing never opens that gap. How to spot it: ask yourself "what happens if two copies of this workflow run at the same time with the same event?" If the answer is "both check, both create," you have the bug. How to fix it: prefer the hierarchy's higher options (idempotency header, or a PUT); and when there's truly no other choice but check-and-create, back it up with atomic idempotency on your side —a uniqueness constraint on your table that prevents recording the same event twice—. Lesson 6 takes this trap apart in detail; don't let it catch you off guard.

Exercises

Exercise 1 — Choose the strategy per API. For each effect, say which hierarchy option you'd use (idempotency header, PUT instead of POST, or check-and-create as a last resort) and why:

(a) A charge at a payment gateway whose documentation describes an Idempotency-Key header. (b) Saving a customer's profile to a CRM that offers PUT /customers/{id} to "put the customer in this state." (c) Creating a task in an old tool that only offers POST /tasks and doesn't mention idempotency anywhere in its documentation.

See solution

(a) Idempotency header (option 1). The API explicitly offers it; it's the robust, server-side solution. Send your stable idempotency_key in the Idempotency-Key header and you're done. Don't invent anything more complicated.

(b) PUT instead of POST (option 2). The CRM lets you set the customer by their id. PUT /customers/CUST-118 "puts the customer in this state" and is idempotent by nature (lesson 2): do it ten times and the customer ends up the same. You don't need a header or a check; the verb itself gives you idempotency.

(c) Check-and-create, as a last resort, backed on your side (option 3). There's no header and no way to set by id, so there's no choice but to ask "does this task already exist?" and create if not. But knowing it has a race window, you back it up with your own idempotency: before calling the API, you record the event in a table of yours with a uniqueness constraint; if the record already existed, you don't call. That way your own database's atomicity covers the window the API doesn't cover. (That record is module 4's ledger.)

Why this works: you applied the hierarchy in order. The best available solution changes based on what the API offers, and recognizing that keeps you from both over-complicating case (a) and under-trusting case (c).

Exercise 2 — Diagnose the duplicate charge. A workflow sends the charge with an Idempotency-Key header correctly set, with the right name you confirmed in the gateway's documentation. Even so, duplicate charges show up in production. The header is sent on both arrivals. What would you check, and what's the most likely cause?

See solution

If the header is being sent with the correct name on both arrivals, the most likely cause is that the key's value is different on each arrival —that is, idempotency_key isn't stable—.

What to check: compare the Idempotency-Key header's value across the two executions of the same event. If they're different, that's the problem. Trace where that value comes from —lesson 3's Code node— and look for the usual poison: a timestamp (new Date(), Date.now(), a created_at generated at processing time), a randomUUID(), or any source of randomness inside the computation. The gateway receives two different keys, doesn't recognize the second as a repeat, and creates the second charge.

How to fix it: make the key depend only on the event's stable content —the natural order_id, or a hash of the fields that don't change between retries—. A perfect header with an unstable key is like putting the right reference number but changing it every time: the bank never recognizes it.

Why this works: you separated two things that get confused —"the header is set correctly" and "the key is stable"—. The header is the envelope; the stable key is the letter. A correct envelope with a different letter every time deduplicates nothing.

Exercise 3 — Rewrite the effect as idempotent. Cumbre calls a billing service with this POST, which creates a new invoice on every retry. The service, according to its documentation, offers both an Idempotency-Key header and a PUT /invoices/{invoice_number} endpoint. Propose two ways to make it idempotent and say which one you'd prefer.

POST /invoices
{
  "order_id": "ORD-2041",
  "customer_id": "CUST-118",
  "amount": 1780
}
See solution

Way A — idempotency header (option 1): you keep POST /invoices and add the Idempotency-Key header with your stable idempotency_key (which here can be order_id itself, ORD-2041, since it's a good natural key). The second arrival, with the same key, doesn't create a second invoice.

POST /invoices
Idempotency-Key: ORD-2041
{ "order_id": "ORD-2041", "customer_id": "CUST-118", "amount": 1780 }

Way B — PUT by identifier (option 2): instead of creating, you set the invoice by a number you control. If you use order_id as the invoice number, PUT /invoices/ORD-2041 "puts this order's invoice in this state," idempotent by nature.

PUT /invoices/ORD-2041
{ "customer_id": "CUST-118", "amount": 1780 }

Which to prefer: both are robust (both server-side, both atomic). Way B (PUT) is slightly cleaner conceptually, because the idempotency comes from the HTTP verb itself and doesn't depend on the gateway remembering the key for a certain time window —a PUT is idempotent forever, while an Idempotency-Key gets forgotten after a few hours—. If the service offers both, a PUT by a stable identifier like order_id is a very solid choice. Way A is just as valid and is the only option when the resource doesn't have an identifier you control.

Why this works: you recognized the same effect can be made idempotent through two paths in the hierarchy, and evaluated their nuances —the header depends on an API memory window; the PUT doesn't—. When you have both, understanding that difference is what lets you choose with judgment instead of by habit.

Summary and next step

In this lesson you solved the duplicate for Cumbre's most expensive effect —the charge— using the Idempotency-Key header: you send the gateway your stable key from lesson 3, and it takes care of not charging twice, storing the first call's result and returning it identically when the second arrives with the same key. You grounded it with a bank transfer's reference number, saw how to pass the header from the HTTP Request node with {{ $json.idempotency_key }}, and learned the step you can't skip: checking, in the specific API's documentation, the header's exact name and how long it remembers the key, because Idempotency-Key is common but not universal, and some APIs don't offer it. For those, you learned the preference hierarchy: first the header; if not, a PUT that sets a state instead of a POST that creates; and only as a last resort the check-before-create pattern, which is better than nothing but hides a race window.

Before moving on to lesson 6 you should be able to: add an Idempotency-Key header to an HTTP Request with lesson 3's key; explain why the header only works if the key is stable across arrivals; and rank the preference hierarchy for making an API effect idempotent.

That "race window" I mentioned twice —the gap between checking and creating— is so important and so treacherous it deserves its own lesson. Lesson 6 takes it apart: why "look it up, and if it doesn't exist, create it" isn't idempotent even though it seems to be, why it survives every one of your tests intact and only blows up in production with two concurrent executions, and why the correct solution doesn't live in two separate nodes but inside a single atomic operation —lesson 4's upsert and this lesson's header—. It's the module's subtlest mistake, and the one that most separates someone who understands idempotency from someone who just copied it.

Resources