Module 6: Retries, Alerts, and Recovery
3. Compensating actions: undoing what you can't avoid repeating
Description
By the end of this lesson you will be able to design a compensating action: when an effect has already happened and you can't make it idempotent or avoid it, you undo it with an opposite action. You will understand the "for every create, an undo" pattern, you will see how issue-refund becomes the compensation for a credit hold left orphaned, and you will learn why a perfect rollback almost never exists in a system that touches the real world — emails already sent, messages already delivered — and what it then means to leave the system in an acceptable state instead of an identical one. You will also see that the compensation itself has to be idempotent, because undoing twice can be just as damaging as doing twice.
This matters because the previous lesson gave you half the answer to failure: retrying, when the effect is idempotent. But many effects can't be protected with a key, or involve several steps that can't happen atomically all at once. check-credit places the hold, and inventory-sync, one step later, discovers there's no stock. The hold is already placed. There's no checkbox that "un-places" it. The only honest way out is undoing it, and that's a compensating action. Without this pattern, your system accumulates half-finished effects: holds blocking credit for orders that never shipped, inventory reservations for orders that got cancelled. It's the difference between a system that cleans up after itself and one that leaves trash everywhere.
Connection to the module: lesson 2 was "when you can repeat without harm." This one is "when you can't, you undo." Together they're the two possible responses to an effect's failure. This lesson leans on Module 5's outbox pattern — you'll see that deciding to compensate and executing the compensation are two separate steps, exactly like in the outbox — on Module 4's ledger — where what got done and what got compensated stays recorded — and on Module 2's idempotency, because the compensation also has to be idempotent. Lesson 4 picks up what happens after: when a failed compensation deserves an alert.
What a compensating action is
Let's define it well, because it's a concept that often gets used loosely.
A compensating action is an operation that undoes the effect of a previous operation, executed as just another normal step of the system. It isn't a magic "undo" function that erases the past. It's a real action, with its own effect: if "place a hold" was the operation, "release the hold" is its compensation. If "charge" was the operation, "refund" is its compensation. For every action that creates something, you define ahead of time the action that cancels it.
The clearest analogy is a travel booking. You book a flight and pay. A minute later, you try to book the hotel for the same dates and there's no availability. You already paid for the flight. You can't travel back in time to un-pay it. What the agency does is issue a refund for the flight: a new, real action, that leaves your card as it was — or almost. The refund is the compensating action for the charge. It's not that the charge "never happened"; it's that there was a charge and then a refund, and the net result is acceptable.
Notice two things about that analogy, because both come back throughout what follows. First: the compensation is a forward action, not a rewind. The system doesn't rewind; it does something new that counteracts the previous thing. Second: the result isn't identical to "nothing ever happened." Your bank statement has two transactions — a charge and a credit — not zero transactions. For almost every purpose that's fine, but it isn't the same as if you'd never paid. That difference between acceptable and identical is the subject of a whole section further down.
Why it exists: not everything can be made atomic
To understand why you need compensations, you have to see what you don't have in an integration system.
In a database, there's something called a transaction: you group several writes and tell the database "either all of them happen, or none does." If something fails halfway, the database reverts — rolls back — and leaves everything as it was, with no trace. It's atomic: all or nothing. Module 4 touched on this for the ledger.
The problem is that magic only works inside a database. Cumbre's system doesn't live inside one single database: it places a hold by calling the credit API, reserves inventory somewhere else, issues refunds through a third API. Those are different systems, each with its own database, and there's no transaction spanning all three. You can't tell the credit API and the inventory system "either both things happen or neither does," because they don't know each other and don't share an all-or-nothing mechanism.
Think of it this way: if three different people each have to do a favor for a plan to work, and there's no boss coordinating them, you can't guarantee all three do it or none does. What you can do is this: if the first one did their part and the second one couldn't, you ask the first one to undo what they did. That's a compensation, and it's the best you can achieve when there's no transaction spanning everyone.
This pattern — a sequence of steps, each with its own compensation, so that if something fails halfway the already-done steps get undone — has a name in systems engineering: it's called a saga. You don't need the term to do the work, but it's worth knowing, because it's exactly what you're building when you give check-credit its hold release and a charge its refund. A saga is a transaction that doesn't fit in a single database, held together by hand with compensations.
The pattern: for every create, an undo
The concrete discipline is simple to state and takes work to apply: for every operation that creates an effect, define ahead of time the operation that cancels it. Before you write the step that places the hold, you already have it clear what the step that releases it is. It isn't something you improvise when it fails; it's part of the design from the start.
For Cumbre's system, the compensation table looks like this:
| Operation (creates) | Compensation (cancels) | Leaves a trace? |
|---|---|---|
check-credit places a credit_hold | issue-refund releases the credit_hold | Little: the credit line goes back to how it was |
inventory-sync reserves stock | A release that returns the reserved stock | Little: the inventory becomes available again |
| A real charge to the customer's card | issue-refund issues a refund | Yes: two transactions remain on the statement |
| A confirmation email to the customer | No clean compensation | Yes, and irreversible: the email is already read |
Look at the last row, because it's the most honest one. Some operations have no clean compensation. A sent email can't be "un-sent": it already arrived, maybe already got read. A "your order is on the way" WhatsApp message doesn't get pulled back. For those operations, the best possible compensation is another communication — "sorry, your order was cancelled" — which doesn't undo the first one but socially corrects it. And that leads to a very important design decision we'll see in a moment: the order in which you execute the effects, so the ones that can't be undone happen last.
Worked example: issue-refund as the compensation for an orphaned hold
Let's look at Cumbre's full flow. The scenario: Luna Coffee's order ORD-3180 comes in for 4820 pesos. check-credit successfully places the hold. One step later, inventory-sync tries to reserve 20 kilos of arabica coffee and discovers there are only 8. The reservation fails. The 4820-peso hold is left orphaned: it blocks credit for an order that isn't going to ship. It needs releasing.
Step 1 — Record what got done, so it can be undone. This is where Module 4's ledger comes in. When check-credit successfully places the hold, it writes a record to the ledger that this order_id has an active hold. Without that record, when something fails later you wouldn't know what needs compensating. The compensation needs to know what got done, and the ledger is where that lives.
-- Ledger table, with the column that tracks the effect's state.
-- (Conceptual schema; the table detail belongs to Module 4.)
run_ledger
order_id TEXT
credit_hold_id TEXT -- the id the credit API returned
hold_status TEXT -- 'active' | 'released'
...
Step 2 — Detect that compensation is needed. When inventory-sync can't reserve the stock, the system has to decide: "the order can't ship, and there's an active hold; it needs releasing." This decision to compensate is a step separate from executing the compensation. And that separation is exactly Module 5's outbox pattern: you don't call the refund directly at the moment of failure; you write a compensation intent to the outbox table, and a consumer executes it.
-- A compensation intent gets written, not executed on the spot.
outbox
intent_id TEXT
intent_type TEXT -- 'release_credit_hold'
order_id TEXT -- 'ORD-3180'
payload JSONB -- { credit_hold_id, amount, reason: 'out_of_stock' }
status TEXT -- 'pending' -> 'done'
Why separate it? For the same reason as in Module 5: if the moment of failure is chaotic — right when something broke — you don't want the compensation to depend on everything else working at that instant. You write the intent reliably, and the compensation gets executed afterward, with its own retries, independently. If the system crashed between the decision and the execution, the intent is still there, waiting, and it isn't lost.
Step 3 — Execute the compensation. The issue-refund workflow reads the pending intents from the outbox and, for each release_credit_hold, calls the credit API to release the hold. The call is an HTTP Request — remember, you can't make HTTP calls from a Code node in n8n 2.0 — and it carries its idempotency key:
// ============================================================
// Node: Code — "Build release key" (inside issue-refund)
// Mode: Run Once for Each Item
//
// INPUT: an outbox intent: release_credit_hold
// OUTPUT: the item with an idempotency key for the release
// WHY: releasing the same hold twice must count as once;
// the key guarantees this even if this step gets retried
// ============================================================
const intent = $json;
// The key is derived from the concrete intent, not the moment:
// same intent -> same key -> the API releases only once.
const releaseKey = `release-hold:${intent.order_id}:${intent.credit_hold_id}`;
return {
json: {
...intent,
idempotency_key: releaseKey,
},
};
The HTTP Request that follows sends that key as a header when calling the release API, and has Retry On Fail turned on (lesson 2), which is safe precisely because the operation is idempotent.
Step 4 — Record that it got compensated. When the release succeeds, the ledger gets updated: hold_status goes from 'active' to 'released', and the outbox intent goes to 'done'. Now the system knows this order no longer has an active hold, and a future run won't try to release it again.
What to expect from the full flow. Order ORD-3180 comes in, places a hold, fails to reserve inventory, and within seconds — or however long the outbox consumer takes — the hold gets automatically released. Luna Coffee never had their credit blocked for more than an instant. The ledger tells the whole story: hold placed, then released, reason "out of stock." And since the whole flow is idempotent, if any step gets retried — or if the original webhook fired twice — there's no second hold nor a second release. That's a system that cleans up after itself.
Why a perfect rollback almost never exists
Now the uncomfortable part, and it's the one that separates someone who understood the pattern from someone who only memorized it.
In a database, a rollback leaves things exactly as they were: zero trace. In an integration system with real-world effects, that's almost never possible, for three reasons worth having clear.
First: there are effects that already went out and can't be pulled back. If before something failed you sent an "order confirmed" email, that email is already in the customer's inbox. You can send another one correcting it, but you can't make the first one never have existed. Same with an SMS, a WhatsApp message, a push notification. Communication is, almost by definition, irreversible.
Second: the compensation leaves its own trace. A charge compensated with a refund doesn't equal "no charge." The customer saw the charge on their card, maybe got worried, and now sees a credit too. Their statement has two lines, not zero. For accounting the net might be the same, but the experience wasn't neutral. And on some systems — payment gateway fees, for example — a charge-and-refund even costs real money that doesn't come back.
Third: there's a time window where the state is inconsistent. Between when the hold gets placed and when it gets released, there's an interval — seconds, sometimes minutes — where the customer does have their credit blocked by an order that's already known won't ship. If right at that instant the customer tries another order and their credit falls short because of the orphaned hold, the effect already touched them. The compensation cleans up the state, but it doesn't erase what happened during the window.
The practical conclusion isn't depressing, it's liberating: you don't design for a perfect rollback, because it doesn't exist. You design to leave the system in an acceptable state. An acceptable state is one where no money stays trapped, no inventory stays over-reserved, no customer stays charged for something they never received — even if traces, transactions, and emails remain. The design question isn't "how do I make it as if this never happened?", which has no answer. It's "what's the worst state this can end up in, and how do I bring it to an acceptable one?"
The order of effects: do the irreversible one last
A concrete and very useful design rule comes out of all this: when you have several effects to execute, put the ones that are easy to undo first and save the irreversible ones for last.
The reason is direct. If you send the confirmation email before reserving inventory, and the reservation fails, you already sent an email you now have to walk back. If you send the email after the reservation succeeded, the email only goes out once you already know the order is viable, and you never have to walk it back.
In Cumbre's system, the ideal order is: first the reversible, cheap-to-undo operations — placing the hold (it gets released), reserving inventory (it gets returned) — and only when all of those succeeded, the irreversible operations — the customer email, the final charge. That way, if something crashes, everything that needs compensating going backward is always reversible, and the irreversible part never got to happen.
Think of it like cooking for guests: you don't send the "dinner's ready, come on over" invitation until the food is ready. Order protects you: you leave the step that can't be pulled back for when there's no longer any risk of having to pull it back.
The compensation also has to be idempotent
A point that's easy to forget and that bites: the compensating action is an effect like any other, and therefore it can also be duplicated and also needs protecting.
Think about it. The outbox consumer that executes the release can get retried (lesson 2). The webhook that started it all might have fired twice. The compensation intent could get written twice if something went weird. In any of those cases, if "release the hold" isn't idempotent, you end up releasing twice — and releasing a hold twice could, depending on how the API is built, return extra credit, or give a confusing error, or leave the ledger inconsistent.
That's why, in the worked example, the release carried its own Idempotency-Key derived from the credit_hold_id, and the ledger marked hold_status = 'released'. Those two things together guarantee the release happens exactly once: the key protects on the API's side, and the state in the ledger protects on the system's side — if it's already 'released', it doesn't get attempted again.
The rule, then, is recursive and clean: everything you learned about idempotency for normal operations applies equally to compensations. Undoing isn't an exception to the module's rules; it's one more operation playing by the same rules. Refunding twice is just as bad as charging twice.
When even the compensation isn't enough
It's worth naming one more case, because it's the one that brings you back to appropriate humility. Sometimes the compensation itself can't be executed either, or it only undoes part of the damage.
Imagine check-credit placed the hold, inventory-sync failed, and when issue-refund tries to release the hold, the credit API is down — not for a second, but for hours. The retries run out. The compensation itself is left pending. Now you have an orphaned hold that you know needs releasing but can't release right now.
This isn't a flaw in your design; it's the real limit of what automation can solve on its own. And the correct response isn't inventing a compensation for the compensation, endlessly. The correct response is preserving the intent and escalating to a human: the release intent stays in the outbox, marked pending, with all its context; the consumer will retry it when the API comes back; and if it takes too long, an alert lets a person know there's a stuck hold that might need releasing by hand from the credit panel.
Notice how this chains into the two lessons that follow. The intent that doesn't get lost is lesson 5's dead-letter queue. The notice to a person when something's been stuck too long is lesson 4's alert. A failed compensation isn't the end of the world: it's exactly the case the module's next two pieces are designed to catch. The system doesn't promise everything resolves itself; it promises nothing gets silently lost.
Common mistakes
Executing the compensation on the spot, at the exact moment of the failure (conceptual). What happens: when inventory-sync fails, the refund gets called directly right there, in the same execution, in the error's catch. It works in tests. In production, one day the inventory failure coincides with a problem in the credit API, the on-the-spot compensation also fails, and now you have an orphaned hold and a lost compensation, with no record that a compensation was owed. Why it happens: calling the refund on the spot feels like the most natural, direct thing to do. How to detect it: if your compensation lives inside the same node or branch that detected the failure, with no intermediate table, it's on the spot. How to fix it: separate deciding from executing, with Module 5's outbox pattern. You write the compensation intent reliably — that almost never fails, it's a local write — and a consumer executes it later with its own retries. If the system crashes between the two, the intent is still there.
Forgetting the compensation can be duplicated (conceptual). What happens: the hold release gets designed with great care, but with no idempotency key, because "it's a compensation, it's what fixes things, what could go wrong?" A consumer retry releases twice, and depending on the API that returns extra credit or breaks the ledger. Why it happens: mentally, the compensation feels like "the good one," the one that cleans up, and vigilance drops. How to detect it: check every compensating action and ask yourself the same question as for any effect: "if this runs twice, does it cause harm?" How to fix it: give the compensation its own Idempotency-Key and mark in the ledger when it's already been compensated, so it doesn't get attempted again. The compensation plays by the same rules as the original operation.
Designing for a perfect rollback that doesn't exist (conceptual). What happens: it gets assumed that "undoing" returns the system to a state identical to "never happened," and the confirmation email gets sent early, trusting that "if something fails, we'll compensate." When something fails, it turns out the email's already been read and there's no compensation that pulls it back; the customer already found out about an order that later got cancelled. Why it happens: the word "rollback" carries the intuition from databases, where reverting really is perfect. How to detect it: for every effect in your system, classify it as "reversible" (hold, reservation) or "irreversible" (email, SMS, charge with a fee). If you have irreversible effects happening before effects that can fail, you have a latent problem. How to fix it: order the effects so the irreversible ones happen last, when there's no longer any risk of having to undo them, and accept that the state after a compensation is acceptable, not identical. Design for the acceptable state.
Exercises
Exercise 1 — Match each operation to its compensation. For each of these Cumbre operations, write its compensating action if it has one, or mark "no clean compensation" and propose the best possible fix:
(a) Placing a 4820-peso credit hold. (b) Reserving 20 kilos of arabica coffee in inventory. (c) Sending a "your order is on the way" WhatsApp message. (d) Charging 4820 pesos to the customer's card.
See solution
(a) Clean compensation: release the hold. It returns the credit line to its previous state with very little trace. It's one of the cleanest in the system.
(b) Clean compensation: return the reserved stock. The inventory becomes available again. Also very clean, as long as the reservation and its return are idempotent.
(c) No clean compensation. The message already arrived and might already have been read; it can't be pulled back. The best possible fix is another communication: a second WhatsApp saying "there was an issue with your order, we're working on it." It doesn't undo the first one; it socially corrects it. The design lesson: this message shouldn't have been sent until being certain the order was viable — it should go at the end of the effect order.
(d) Compensation: issue a refund, but with trace and cost. The refund returns the money, but leaves two transactions on the customer's statement and, depending on the gateway, can cost a fee that doesn't come back. It's acceptable, not identical. That's why the final charge, like the email, is worth saving for the end.
Why this works: the exercise forces you to classify each effect by how clean its compensation is, which is the information that later decides the order in which you execute them. Effects with a clean compensation (a, b) can go first; the ones that leave a trace or are irreversible (c, d) go last.
Exercise 2 — Order the effects. Cumbre processes an order with these four effects: (1) place the credit hold, (2) reserve inventory, (3) send the confirmation WhatsApp to the customer, (4) make the final charge to the card. Any of the four can fail. Propose an execution order that minimizes the need for irreversible compensations, and justify it.
See solution
A reasonable order: hold → inventory reservation → final charge → WhatsApp.
The reasoning, step by step:
- The hold goes first because it's reversible and cheap to undo, and because it checks the most likely thing to fail (insufficient credit) before touching inventory. If it fails, nothing else has happened yet.
- The inventory reservation goes second, also reversible. If it fails, only the hold needs releasing — a clean compensation.
- The final charge goes third, once you already know there's credit and there's stock. It's more expensive to undo (leaves a trace, costs a fee), so you only do it once the order is nearly certain. If the charge fails, you release the hold and the reservation — both clean.
- The WhatsApp goes last, once everything else has already succeeded. It's irreversible, so it should only go out when there's no risk at all of having to walk it back.
The central idea: every effect is ordered from "more reversible" to "less reversible." That way, at any point where something fails, everything that needs compensating going backward is reversible, and the irreversible part never got to happen. A different order — sending the WhatsApp first, for instance — forces you to walk back messages every time something crashes further down the line.
Why this works: the order of effects is one of the cheapest and most powerful design decisions in a resilient system. It costs nothing to get it right from the start, and it avoids a whole category of impossible compensations. It's exactly the kind of decision that gets defended in an interview by showing your graph.
Exercise 3 — The duplicated compensation. Cumbre's outbox consumer that executes release_credit_hold has Retry On Fail turned on. One day, the release API responds slowly: it releases the hold but the confirmation gets lost, and n8n retries. Describe what happens (a) if the release is NOT idempotent, and (b) if it carries its idempotency key and the state in the ledger. What two protections prevent the double undo?
See solution
(a) Without idempotency: it's lesson 2's world B applied to a compensation. The first request released the hold, but since the confirmation got lost, n8n sees a timeout and retries. The second request asks to release the same hold again. Depending on how the API is built, this can: return a confusing error ("this hold no longer exists"), or — worse — return extra credit if the API interprets each release as an independent credit. Either way, the ledger can end up inconsistent. Undoing twice is as real a bug as doing twice.
(b) With both protections: the retry sends the same Idempotency-Key derived from credit_hold_id, so the API recognizes it already processed that release and returns the existing result without releasing again. And even if the key failed somehow, the system checks the ledger before attempting: if hold_status is already 'released', it doesn't even send the request. Result: the hold gets released exactly once.
The two protections are: (1) the idempotency key in the request, which protects on the external API's side; and (2) the state in the ledger (hold_status), which protects on the system's side by preventing an attempt to compensate something already compensated. Together they cover both a node retry and a second intent that arrived for whatever reason.
Why this works: this exercise closes the module's circle. Compensation isn't a zone free from idempotency rules; it's one more effect that needs the same two protections as any operation. Refunding twice, releasing twice, returning stock twice: all of them are duplicates, and all of them get prevented the same way.
Summary and next step
In this lesson you saw that when an effect has already happened and you can't make it idempotent or avoid it, the way out is undoing it with a compensating action: for every operation that creates something, you define ahead of time the one that cancels it, like the flight refund when the hotel has no room. You understood why the pattern exists — there's no atomic transaction spanning several different systems, and a sequence of steps with their compensations is what engineering calls a saga — and you saw Cumbre's full flow: check-credit places a hold, inventory-sync fails, and issue-refund releases it, with the decision to compensate kept separate from the execution via Module 5's outbox and the state tracked in Module 4's ledger. You learned that a perfect rollback almost never exists — there are irreversible effects, the compensation leaves a trace, and there's a window of inconsistency — and that's why you design for an acceptable state, not an identical one, ordering the effects so the irreversible ones happen last. And you saw that the compensation also has to be idempotent, protected by its own key and by the state in the ledger.
Before moving on you should be able to: give the compensation for any operation in your system, or recognize it has no clean one; explain why a sent email can't be compensated and what you do instead; and order a list of effects from more reversible to less reversible with your reasoning.
What you haven't seen yet is the human decision. A retry heals itself; a clean compensation cleans up the state on its own. But there are failures no automatic mechanism can resolve — a compensation that also fails, a stuck refund, an order left in limbo — and that need a human to find out and act. Lesson 4 is about that decision: which failures deserve an alert that wakes someone up and which deserve only a log, how to define what a "real" failure is for each workflow, and why alerting on everything is the fastest way for no one to look at any alert.
Resources
- Error handling — n8n Docs — the node's error handling that triggers detecting a compensation is needed, including the error output that routes the failure down its own path.
- Handle errors gracefully — n8n Docs — official error-handling design guide, the base for deciding where the system detects a step got left halfway done.
- HTTP Request node — n8n Docs — the node
issue-refunduses to call the release and refund API, where the compensation's idempotency key lives. - Postgres node — n8n Docs — the node used to write the intent to the outbox and update the effect's state in the ledger, on the Starter Kit's Postgres.