workflow automationn8nduplicate sendssmall business automation

Your Automation Sent the Same Message Twice. It Has No Memory — Give It One

· 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.

The customer got the same message twice

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.

Why workflows forget

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:

DesignWhat remembers the workWhat happens on a crash or restart
An in-memory loopNothing — progress lives in the running processStarts over; anything already sent gets sent again
LangGraph checkpointerA snapshot of graph state after each step, filed under a thread IDResumes from the last saved step
Temporal workflow engineAn event history — a complete, ordered log of everything that already happenedReplays the log and continues from the exact point it stopped
n8n Wait node + a Sheet ledgerThe wait is stored in the database; the ledger records what was sentThe 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 is what everyone wants, and no runtime can provide it alone across a network boundary — it cannot tell a lost request from a lost acknowledgement.

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.

Rule one: give every wait a place to live

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 pause is a first-class step, not a sleep — in n8n, that step is the Wait node: it pauses a workflow and resumes it later in four modes — after a time interval, at a specified time, on a webhook call, or on form submission (learn.ynteractive). A follow-up three days after a quote is "after a time interval, 3 days"; an approval is "on a webhook call", resumed by the manager's click.
  • A long wait costs nothing while it waits — waits over roughly 65 seconds are offloaded to the database, so a wait of days "uses basically no resources while its parked" — a stored row that resumes when due (n8n community).
  • The resume link is single-use — n8n's webhook resume URL returns a 404 on a second call, so a double-clicked approval link cannot resume it twice (Triumphoid).

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.

Rule two: write down what is done before you do it

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 key MUST be unique and MUST NOT be reused with a different payload.
  • A UUID is recommended.
  • The resource SHOULD publish an expiry policy.
  • It SHOULD reply 400 (missing key), 409 (retry while processing), or 422 (key reused with a different payload).

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.

Rule three: every wait needs an expiry

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:

  • Bind the approval to the exact action — put the quote ID and the amount in the request, and have the decision carry them. On resume, check they still match — if the quote changed, the approval is void.
  • Give it an expiry — set a deadline when the wait is created and treat later decisions as void — hours suit payments, a couple of days may suit a discount. In n8n, that is the Limit Wait Time on the Wait node. Triumphoid puts it bluntly: "Never deploy a Wait node without a TTL" — approvals without a timeout become "zombie" executions.
  • Re-check on resume — confirm the approver still holds the role and re-validate the key facts — an approval given on Monday should not execute on Thursday without a fresh look.
  • Record the decision — who approved what, when, against which quote — the ledger gets a column for it.

The build: a quote follow-up that cannot send twice

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.

Phase 1: The ledger

Create two sheets. The first is Quotes — the sheet your team uses, or a new one — one row per quote, columns in this order:

text
quote_id | customer_name | phone | quote_date | amount | discount_percent

The second is the Follow-up ledger, one row per quote, columns in this order:

text
quote_id | customer_name | phone | quote_date | amount | discount_percent | follow_up_due | follow_up_status | status_updated | replied | approved_by

quote_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.
  • Format the 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.

Phase 2: The trigger and the first wait

Build the workflow in n8n with these nodes, in this order:

  1. A Google Sheets Trigger node watching your Quotes sheet, Trigger On set to "Row added".
  2. The ingest check — a Google Sheets node (in the 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.
  3. An IF node: if the lookup's 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.
  4. Append Row in the 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.
  5. The Wait node — rename it "Follow-up Wait": Resume "After Time Interval," Wait Amount 3, Wait Unit Days.

The follow_up_due expression from step 4:

text
{{ 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.

Phase 3: The memory check

After the Follow-up Wait node, add the Guard Step, in n8n form:

  1. A Google Sheets node (in the 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).
  2. An IF node with two conditions joined by OR: replied is equal to yes, follow_up_status is equal to sent. Wire True to nothing and False onward to a second IF node.
  3. A second IF node covering the in-flight case: two conditions joined by AND — 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.

Phase 4: The send and the write-back

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:

text
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:

  1. Before the WhatsApp node, add a Google Sheets node (in the 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.
  2. The WhatsApp node: a WhatsApp Business Cloud node set to Send Template. Pick the 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 }}.
  3. Immediately after the WhatsApp node, add a second Google Sheets node (in the 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.

Phase 5: The approval wait for big discounts

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:

  1. The discount IF node: if 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.
  2. A Slack node (Resource: Message, Operation: Send) posting to the manager's own Slack DM — set Send Message To to User and pick the manager, not a shared channel, because anyone holding the link below can approve. The message must carry the exact action it approves, and two links: the resume URL with a decision attached to each. Note the & — 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.
  3. The approval wait — a Wait node renamed "Approval Wait", set to Resume "On Webhook Call," with Limit Wait Time set to 48 hours. It sits after the Slack node: the message in step 2 already carries $execution.resumeUrl, which is referenceable before the Wait runs.
text
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 }}&quote_id={{ $json.quote_id }}
Reject: {{ $execution.resumeUrl }}&decision=reject&discount={{ $json.discount_percent }}&quote_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.

If the manager approves

The first checks whether $json.query.decision is equal to approve. If it is, run these steps:

  1. Re-check the ledger: a Google Sheets node (in the 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.
  2. A second IF node stops the run — two conditions joined by OR: 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.
  3. Only if the re-check passes, a Google Sheets node (in the 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.
  4. The run continues to phase 4, starting at "Mark sending" — the approval path must mark 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 manager rejects

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:

text
Discount rejected — quote {{ $json.query.quote_id }}, discount {{ $json.query.discount }}%. No follow-up sent.

If nobody answers

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":

text
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.

Phase 6: Test it like a customer

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.

  1. The double-click — add the same quote row twice to the 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.
  2. The reply during the wait — send a quote, then mark 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.
  3. The expired approval — send a quote with a big discount, ignore the approval request, and wait out the 5-minute limit — ten minutes total from the quote, five for the first wait plus five for the limit. Expect the escalation notice and no customer message. If the workflow is still in Waiting after the limit, the Limit Wait Time is not set.

What this does not fix

Three honest limits, so the build is not oversold.

  • The check-then-write race: if two runs reach the memory check in the same second, both can pass before either writes 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.
  • Waiting executions depend on your n8n setup: on some self-hosted configurations, pending waits may not resume after a crash or restart — check yours before relying on a three-day wait.
  • The ledger is only as good as its key. If two quotes share a 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.

Ready to leave the mundane work to AI?

Book a free consultation. We'll show you which of your workflows AI can take over first.