Your Automation Sent the Same Message Twice. It Has No Memory — Give It One
· AgenticLabs India
· AgenticLabs India
A customer got your quote follow-up twice in one morning — or never got it at all. Both failures have the same cause: the workflow has nowhere to wait and nothing that remembers. Three rules fix it.
A customer asks for a quote on Monday. Your team sends it. On Thursday the follow-up goes out — "Just checking in, did you get a chance to look at the quote?" — polite, well timed, exactly what a good business does. Twenty minutes later, the same customer gets the same message again.
Nothing in your business is broken. The quote was right, the customer is real, the follow-up was a good idea. What is broken is quieter: the workflow that sent the message has no memory — nobody gave it anywhere to write down that it already sent that follow-up.
So when the trigger fired a second time — a second form submission, a re-delivered webhook, a sheet row edited and saved again — it did exactly what it was built to do.
The opposite failure has the same cause: the follow-up that never arrives. A quote sent on Monday, a reminder due Thursday, and nothing — because the automation could not wait three days. Most small automations are built as instant reactions, and a three-day gap is not one: it is a wait, and a wait needs a place to live. Without one, the work fires twice or never fires at all.
This post is about giving your workflows a memory — durable execution — with no new platform: three rules, and a follow-along build that puts them into a quote follow-up in n8n.
A workflow forgets for a simple reason: it keeps its progress in memory, and memory disappears when the process dies. That is fine for a job that takes two seconds — not for one that takes three days. Four designs handle this differently:
| Design | What remembers the work | What happens on a crash or restart |
|---|---|---|
| An in-memory loop | Nothing — progress lives in the running process | Starts over; anything already sent gets sent again |
| LangGraph checkpointer | A snapshot of graph state after each step, filed under a thread ID | Resumes from the last saved step |
| Temporal workflow engine | An event history — a complete, ordered log of everything that already happened | Replays the log and continues from the exact point it stopped |
| n8n Wait node + a Sheet ledger | The wait is stored in the database; the ledger records what was sent | The wait resumes when due; the ledger stops the second send |
The enterprise versions name the problem precisely. Temporal documents that its workflows "can run—and keep running—for years, even if the underlying infrastructure fails," and that if the application crashes, Temporal "will automatically recreate its pre-failure state so it can continue right where it left off." The mechanism is the event history: every activity, timer, and signal is recorded as an event, and recovery replays that log. LangGraph does the same job at a smaller scale: a checkpointer saves a snapshot of graph state, organised into threads keyed by a thread_id, and runs with the same thread ID continue the same thread. The warning in its own docs is the one that matters here: MemorySaver and InMemorySaver keep checkpoints in RAM, and "all checkpoints are lost" when the process restarts — so production use needs a persistent checkpointer (PostgresSaver for a database, SqliteSaver for a local file).
The second half of the lesson is retries — the part most small automations get wrong: every durable system retries, and a retry repeats whatever the step does. Safeguard states the rule plainly: assume every tool call that changes something will be attempted more than once, and make the second attempt harmless.
At most once means one attempt, never retried: no duplicates, but work can be silently lost.At least once means retry until success is recorded: no silent loss, but if an attempt succeeded and the success was not recorded — a lost response, a killed worker, an early timeout — the next attempt repeats the action.Exactly-once effect comes from at-least-once execution plus a receiver that recognises the second request and does nothing. That recognition is the memory, and rule two is how you build it.
A wait is not a delay inside a running program. A wait is stored state. The moment your workflow needs to pause — three days until a follow-up, until a manager approves a discount, until a customer replies — it must be able to stop existing as a running process and still come back. Three things make that work in the tools a small business uses:
The Wait node is not the only way n8n pauses for a person. Messaging and email nodes have Send-and-Wait operations that pause the workflow until someone responds, and since n8n 2.6 (January 2026) the review can attach directly to an AI Agent's tools: the reviewer sees the exact tool and parameters before approving (rills.ai, triggerworkflow). The request goes out through a channel you choose: Slack, Telegram, Gmail, WhatsApp, or n8n's own Chat.
A wait gives the workflow somewhere to pause. A ledger gives it something to remember — the small-business version of Temporal's event history and LangGraph's checkpoint: a plain table recording, for every job, what has already happened. One row per quote, one column per fact that matters: was the follow-up sent, did the customer reply, was the discount approved. Before the workflow sends anything, it looks the job up in the ledger; if the ledger says the work is done, it stops. That lookup is the whole trick.
In Zapier, the pattern has a name: the Guard Step. Duplicates usually come from the trigger firing twice for one real event — a polling trigger re-sees an item, a webhook gets redelivered, a form gets submitted twice because someone double-clicked (dev.to). The fix is a durable shared log — a Google Sheet or an Airtable base — holding unique IDs, a lookup right after the trigger, and a filter that only continues if the ID does not exist: "make your Zap smart enough to know it's already done the work." The honest limit: a check followed by a write is not atomic, so two runs in the same second can both pass.
The formal version of the same idea is the idempotency key, worth knowing about even if you never write code — it explains why the ledger works. The IETF's HTTPAPI working group is standardising an Idempotency-Key header in an Internet-Draft — draft-08, dated 22 October 2026, still a draft and not a standard — that "can be used to make non-idempotent HTTP methods such as POST or PATCH fault-tolerant" (IETF). The draft's rules are the ledger's rules, written formally:
The rule that matters most is where the key comes from: derive it from the workflow and the step, so it is the same on every retry — never a fresh value per attempt (Safeguard). In the follow-along below, the key is the quote ID: one stable, unique value per job, written into the ledger before the send, checked before every send after it.
The same mistake in a different costume: when a run resumes from an interrupt, the runtime restarts the whole node from its beginning — not from the line that called interrupt — so "side effects called before interrupt must be idempotent" (LangGraph interrupts). Put the email send on line one and the approval on line three, and the resume sends the email twice. The fix is structural: put the side effect after the pause, or make it safe to repeat — exactly what the ledger does below.
A wait can be long. An approval should not be. That sentence, from Safeguard, is the whole of rule three. A three-day follow-up wait is fine: the world it describes is still true on Thursday. An approval is different — the longer it sits, the more its world goes stale: the quote gets revised, the approver's authority changes, the risk changes. The safe pattern has four parts:
This is the build: an afternoon, n8n and a Google Sheet plus the WhatsApp Business Cloud account and Slack workspace you already use, giving one real workflow the three rules above — a place to wait, a memory of what is done, and an expiry on every wait. The job is a quote follow-up: a customer gets a quote, and if they have not replied in three days, they get one polite follow-up on WhatsApp — exactly once. A discount above your threshold waits for the manager's approval first. Two design choices: the ledger is a Google Sheet, not a database, because the reader can open it, read it, and fix it; and the follow-up goes out on WhatsApp — the pattern is identical for email, so swap the WhatsApp node for a Gmail node.
Create two sheets. The first is Quotes — the sheet your team uses, or a new one — one row per quote, columns in this order:
quote_id | customer_name | phone | quote_date | amount | discount_percentThe second is the Follow-up ledger, one row per quote, columns in this order:
quote_id | customer_name | phone | quote_date | amount | discount_percent | follow_up_due | follow_up_status | status_updated | replied | approved_byquote_id and follow_up_status do the work. The quote ID is the key: one stable, unique value per job — your quote number, or the row ID from your quotes sheet. Never the customer name — two customers share one — and never a timestamp — two quotes share a minute. Keep it to letters, digits, and hyphens — no spaces, &, or ? — or the approval links in phase 5 parse wrong. follow_up_status holds one of three values: waiting, sending, or sent. status_updated records when the status last changed, so the workflow can tell a fresh sending from a stale one. replied holds yes when the customer has replied, blank otherwise — whoever answers the customer sets it. The rest is context for a human reading the sheet.
Three format rules:
phone holds the full international number, digits only, with the country code and no +, spaces, or dashes — for India, 91 followed by the 10-digit mobile, e.g. 919876543210; Meta rejects anything else.discount_percent is a plain number — 15, not 15% — with no % formatting on the column.status_updated column as Plain text (Format → Number → Plain text) before the first run, so the ISO timestamp round-trips as text — otherwise Sheets may reformat it and the stale-marker check in phase 3 cannot parse it.Build the workflow in n8n with these nodes, in this order:
Quotes sheet, Trigger On set to "Row added".Follow-up ledger) set to Get Row(s), filtering quote_id to {{ $json.quote_id }} — the item here is still the trigger's output. On that node, turn on Always Output Data: without it, a lookup that finds nothing passes no items onward and the workflow stalls.quote_id is empty, no ledger row exists — wire True to Append Row. If it is not empty, a row exists — wire False to nothing and the run ends there.Follow-up ledger — only if no row exists does the run continue here. One trap: after a no-match lookup, Always Output Data passes on a single empty item, so the quote fields are gone from $json — a mapping that reads {{ $json.quote_id }} appends a blank row. Map the six quote fields from the trigger node's output instead — {{ $('Google Sheets Trigger').first().json.quote_id }}, and likewise for the other five — plus follow_up_status set to waiting, status_updated stamped with {{ $now.toISO() }}, and follow_up_due set by the expression below.The follow_up_due expression from step 4:
{{ DateTime.fromISO($('Google Sheets Trigger').first().json.quote_date).plus({ days: 3 }).toFormat('yyyy-MM-dd') }}That expression needs quote_date as a date string, so in the trigger set DateTime Render to Formatted String and keep the Quotes sheet's quote_date column in yyyy-MM-dd form. That is phase 2 — rule one.
The ingest check is what makes a double-submitted quote harmless: the second trigger finds the ledger row the first trigger wrote and stops before appending. (The same check-then-write race from rule two applies here too; see "What this does not fix".)
Expected result: add a test row to the Quotes sheet and see the run sitting in the Executions list with status Waiting, and one ledger row with follow_up_status set to waiting and follow_up_due three days out. Add the same quote row a second time and confirm no second ledger row appears. The run is a stored row until the due date.
After the Follow-up Wait node, add the Guard Step, in n8n form:
Follow-up ledger) set to Get Row(s) — rename it "Fetch ledger row", turn on Always Output Data — filtering quote_id to {{ $json.quote_id }} (the item here is the row the Wait resumed with, so the field exists).replied is equal to yes, follow_up_status is equal to sent. Wire True to nothing and False onward to a second IF node.follow_up_status is equal to sending, and status_updated is after 24 hours ago. status_updated arrives from the sheet as text, so wrap it first: compare {{ DateTime.fromISO($json.status_updated) }} against {{ $now.minus({ hours: 24 }) }} with the operator "after" — set this condition's type to Date & Time, since the after operator only exists under that type. Wire True to nothing — a send is in flight — and False on to the discount check in phase 5. A row that has said sending for more than a day is a run that died between marking and sending, and the follow-up is still owed.Expected result: set the Follow-up Wait node to 5 minutes for the test (set it back to 3 days afterwards). Add a test row; while the run waits, set the ledger row's follow_up_status to sent. When the wait ends, confirm the run stops at the first IF node and nothing is sent, then set the row back to waiting. The double-run proof comes in phase 6.
Before you build this phase, one platform rule decides how the message goes out: WhatsApp only delivers free-form messages inside the 24 hours after the customer's last message; outside that window, only a Meta-approved template will go out (Meta, Courier). Three days after a quote, the window has closed — so the follow-up must be a template. Create it first, in Meta's WhatsApp Manager: name it quote_followup, category Utility, body text with two placeholders:
Hi {{1}}, just checking in on the quote we sent on {{2}}. Happy to answer any questions, or adjust anything in it. Reply here and we'll pick it up.Meta reviews every template before you can use it — submit it a day early. Then build the phase:
Follow-up ledger) set to Update Row — rename it "Mark sending" — Column to Match On quote_id, matching value {{ $json.quote_id }} — setting follow_up_status to sending and stamping status_updated with {{ $now.toISO() }}. Turn on Always Output Data, so the WhatsApp node always receives an item.quote_followup template, set the recipient (To) to {{ $json.phone }} — the digits-only international number from phase 1 — and map its parameters: {{1}} to {{ $json.customer_name }}, {{2}} to {{ $json.quote_date }}.Follow-up ledger) set to Update Row — rename it "Mark sent". Its matching value cannot be {{ $json.quote_id }}: after the WhatsApp node, $json is Meta's API response ({"messages": [{"id": "wamid..."}]}), which has no quote_id — the update would match nothing and follow_up_status would stay sending. Read the quote ID from the first Update Row's output instead: matching value {{ $('Mark sending').first().json.quote_id }}. That sets follow_up_status to sent and stamps status_updated with {{ $now.toISO() }}. Turn on Always Output Data here too.The order matters: mark sending before the send, sent after it — the ledger version of "write down what is done before you do it".
Expected result: with the Follow-up Wait still at 5 minutes for testing, add a fresh test row with a new quote_id. Five minutes later the customer gets one template message and the ledger row reads sent. Add the same row again and confirm nothing goes out. Set the Follow-up Wait back to 3 days before going live.
Discounts above your threshold take a different path — this is where rule three lives. After the memory check, add these three nodes, in execution order:
discount_percent is above your threshold (10 is a sensible example — enter the number, not 10%), branch to an approval wait. Wire Phase 3's second IF so its False output feeds this node instead of going straight to phase 4, and wire this node's False output to phase 4 — below the threshold, the run goes straight to the send. Set the condition type to Number and turn on "Convert types where required": Sheets returns the value as the string "15", and the IF node validates types strictly by default — without the toggle it throws a "Wrong type" error and the run fails.& — the resume URL already carries a ?signature= query, so the decision joins with &, not a second ?. Both links also carry the quote ID: after the manager clicks, the resumed item holds only the link's query data.$execution.resumeUrl, which is referenceable before the Wait runs.Discount approval needed — quote {{ $json.quote_id }} for {{ $json.customer_name }}, amount {{ $json.amount }}, discount {{ $json.discount_percent }}%. This approval expires in 48 hours.
Approve: {{ $execution.resumeUrl }}&decision=approve&discount={{ $json.discount_percent }}"e_id={{ $json.quote_id }}
Reject: {{ $execution.resumeUrl }}&decision=reject&discount={{ $json.discount_percent }}"e_id={{ $json.quote_id }}After the Approval Wait node, route on the decision with two IF nodes in sequence — an IF node has two outputs, so three outcomes need two of them.
The first checks whether $json.query.decision is equal to approve. If it is, run these steps:
Follow-up ledger) set to Get Row(s) — turn on Always Output Data — re-fetches the row, filtering quote_id to {{ $json.query.quote_id }} — the resumed item is only the link's query data.replied is equal to yes; {{ $json.discount_percent }} is not equal to {{ $('Approval Wait').first().json.query.discount }} (not {{ $json.query.discount }} — the Get Row(s) just replaced the item with the ledger row, so $json.query no longer exists). Set both conditions' type to String. Wire True to nothing — the approval is void — and False onward to the approved_by write.Follow-up ledger) set to Update Row — Column to Match On quote_id, matching value {{ $json.quote_id }} — writes the manager's name into approved_by; turn on Always Output Data, so phase 4 always receives an item. Type the name: the click carries no identity, and the approval is valid because the link went to that manager's own DM.sending before the send, just like the direct path, or a second run in the same second sees waiting and sends a duplicate.If the first IF node's answer is no, its False output goes to a second IF node checking for $json.query.decision equal to reject. If it is, a Slack node (Resource: Message, Operation: Send, Send Message To set to User, yourself) posts to your own DM and the run stops. The message reads the fields from the link's query data:
Discount rejected — quote {{ $json.query.quote_id }}, discount {{ $json.query.discount }}%. No follow-up sent.If the second IF node's answer is also no, there is no decision: the wait timed out. On expiry the Approval Wait node resumes down the same output with no query data, so the empty decision routes here — and the resumed item is the Slack node's API response, which carries no quote fields. A Slack node (Resource: Message, Operation: Send, Send Message To set to User, yourself) posts to your own DM, reading the quote fields from "Fetch ledger row":
Approval expired — quote {{ $('Fetch ledger row').first().json.quote_id }}, discount {{ $('Fetch ledger row').first().json.discount_percent }}%. No follow-up sent.Expected result: for testing, set the Follow-up Wait node and the Limit Wait Time to 5 minutes each (restore 3 days and 48 hours afterwards). Send a test quote above the threshold, click nothing, and confirm the expiry notice arrives ten minutes later — five for the first wait, then five for the approval limit — and the customer gets nothing. Send another, click Reject, and confirm the rejection notice. Send a third, click Approve, and confirm the follow-up goes out with approved_by filled in. At live timings the expiry notice arrives 3 days plus 48 hours after the quote — the approval wait sits after the three-day wait.
Three tests, each reproducing a failure from the top of this post. Run them with the Follow-up Wait node and the Limit Wait Time shortened to 5 minutes each, then restore 3 days and 48 hours before going live.
Quotes sheet, seconds apart. Expect one ledger row and, five minutes later, one follow-up. If two ledger rows appear, the ingest check is not matching on quote_id; if two follow-ups go out, the memory check is reading the wrong column or key.replied to yes in the ledger before the wait ends. Expect silence — if the follow-up still goes out, the IF node is not reading replied.Three honest limits, so the build is not oversold.
sending — and the same applies in phase 2. For a ten-person business that is a freak event, not a weekly one — but it is real, and the only complete fix is a key enforced by the receiving system itself, as the IETF draft standardises for APIs.quote_id, the second one will look "already sent" and never get its follow-up.None of these limits is a reason not to build it — they are the reason to build it with the tests in phase 6 and to keep the ledger somewhere a human can open. A workflow with a memory you can read is one you can fix; one with no memory fails in ways you only discover from an angry customer.
The same three rules travel well beyond quote follow-ups: an invoice reminder, a payment check, a re-order — every one is a wait, a memory, and an expiry. Start with the quote follow-up, run the three tests, then ask the rest of your automations: if the trigger fired twice, would the customer know? If yes, you have found the next workflow that needs a memory. Building it — and wiring it into the rest of your tools — is what our Business Workflow Automation page describes.
Book a free consultation. We'll show you which of your workflows AI can take over first.