Why a schedule trigger alone double-sends
The trigger fires on time. That is all it promises. It does not promise the workflow will finish once. Three normal events break the assumption that one fire means one send:
- A retry. n8n retries a failed execution. If the Telegram node actually delivered but the database write after it timed out, the retry sends the same digest again.
- A manual test. You click "Execute workflow" to check the new customer list. The test runs the real send nodes. The scheduled run 20 minutes later sends the same digest.
- A restart or a long queue. A self-hosted instance that restarts, or a run that queues behind a slow API call, can deliver at 08:00 and again after recovery. The time looks right. The content is identical. Two messages land in your inbox 90 seconds apart.
None of these are bugs in n8n. They are the reason every scheduled sender in production treats "run this once per day" as an application-level guarantee, not a trigger property.
The run-key pattern
Give the day a key, and refuse to send a key that already sent. The key is the workflow name plus the calendar date. For a daily digest that key is always the same shape:
// Code node, right after the Schedule Trigger
const today = new Date()
.toLocaleDateString('en-CA', { timeZone: 'Africa/Cairo' }); // YYYY-MM-DD
return [{ json: { run_key: 'daily-digest:' + today, today } }];
Pin the timezone here. A digest keyed to UTC sends "today's" numbers at the wrong boundary for anyone not on UTC — the digest goes out at 01:00 local with yesterday's data. Set the timezone once in n8n settings (Settings → Timezone) for every workflow, then reuse the same zone in the Code node so the key and the display date agree.
Now check the store before you build anything. Use the smallest store you already have — a Data Table, a Google Sheet row, or a Supabase table with a unique constraint on run_key. The unique constraint is the point: the check-then-insert race is exactly what a second concurrent run hits, and the database is the only thing that can refuse both.
// Code node, after the store lookup
const already = await $node['Lookup Run'].json();
if (already && already.sent_at) {
return [{ json: { skip: true, reason: 'already sent ' + already.run_key } }];
}
return [{ json: {
run_key: $json.run_key, today: $json.today, skip: false
} }];
Branch on skip. The skip path ends immediately — no query, no build, no send. That matters: a skipped run that still queries the CRM and drafts the email is a run that still looks busy in the execution log and can still fail.
Write the lock before you send, not after
Insert the run_key row with sent_at = null before the send nodes, then update it with the real timestamp after the send succeeds. If the run dies mid-send, the row exists and the next run skips — better to miss one digest than to send it three times. A unique constraint on run_key turns a race into a clean error you can branch on instead of a duplicate message.
Manual test mode that cannot send
The manual test is the most common duplicate. Split the workflow at the lock:
- Trigger A — Schedule Trigger. Runs the full path: lock check, build digest, send, stamp.
- Trigger B — Manual Trigger. Runs the same build path but skips the lock check and ends at a "preview" node that writes the digest to a log or a draft channel, never to the real recipient.
Both triggers feed one Code node that computes the digest body. Only the scheduled branch connects to the send nodes. When you verify a change, you use Trigger B, see the rendered digest, and know it never reached a subscriber.
The five-node skeleton
The whole pattern is five nodes plus the digest build itself:
[Schedule Trigger] (08:00, your timezone)
|
[Code: compute run_key] daily-digest:2026-11-08
|
[Data Table / Supabase: lookup run_key]
|
[IF skip = true] -> [Stop] (already sent, end quietly)
|
[Data Table: insert run_key, sent_at = null] (the lock)
|
[Build digest] (CRM query, counts, list)
|
[Telegram / Email: send]
|
[Data Table: update sent_at = now] (close the lock)
|
[Log: one line to your error workflow if anything threw]
Two details people skip. First, the insert must be a create-if-absent, not an upsert that silently overwrites — an upsert lets two concurrent runs both "win". Second, the final update must run even if the send node throws, so wrap the send in an error branch that writes sent_at and an error column together. A row that never closes is a digest that never retries, which is the correct failure: silent absence beats duplicate delivery for a daily summary.
When the digest must be hourly, not daily
The same lock works for any period. Change the key granularity: digest:2026-11-08T08 for hourly, digest:2026-11 for monthly. Keep the store prune job the same shape too — delete rows older than 90 days in a weekly cleanup workflow so the table does not grow forever. What you should not do is stretch the key to a week for a daily digest: a week-long key means a failed Monday blocks Tuesday through Sunday.
Where this ships today
The run-key, lock-before-send, and manual-preview pattern above is bundled as a ready-to-import workflow on the store. Point the store node at your own Data Table or Sheet, set the timezone, and the next scheduled run sends exactly once — even if you tested it at 07:55.