If you have ever built a "tell me when BTC is above X" alert, you have probably hit the same problem I did: the check runs every minute, the condition is true every minute, and you get the same message every minute until you mute the chat.
What you actually want is an alert that fires once when the level is crossed, stays quiet while price sits on the other side, and re-arms when price crosses back, so the next crossing alerts again.
This post walks through the n8n workflow I use for that. It is four working nodes, no exchange API keys, and the whole fire-once/re-arm logic is about ten lines of JavaScript.
The layout: Schedule → Config → Check → Notify
The workflow is called Crypto Pack 06 — Price level alerts (Binance). It has these nodes:
| Node | Type | What it does |
|---|---|---|
Schedule |
Schedule Trigger | Cron * * * * * (every minute) |
Run manually |
Manual Trigger | For testing in the editor |
Config |
Set | Telegram/Discord settings and the list of levels |
Check |
Code | Fetches prices, evaluates rules, keeps state |
Notify |
Code | Sends each alert to Telegram and/or Discord |
Both triggers go into Config, then Check, then Notify. Keeping the settings in one Set node means the other nodes read them with $('Config').first().json, and nobody has to edit code to change a threshold.
Step 1: Schedule
A Schedule Trigger with a cron expression:
{
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "* * * * *"
}
]
}
}
Every minute is fine here: it is one HTTP request per run, to a public endpoint.
Step 2: Config
A Set node with five string fields:
| Field | Default |
|---|---|
telegramBotToken |
(empty) |
telegramChatId |
(empty) |
discordWebhookUrl |
(empty) |
telegramApiBase |
https://api.telegram.org |
levels |
BTCUSDT>100000, BTCUSDT<80000, ETHUSDT>4000, SOLUSDT<120 |
levels is a comma-separated list of rules. Each rule is a Binance symbol, > or <, and a number. You can put several rules on the same symbol, which is how you get a "range" alert (above 100k and below 80k).
Step 3: Check (the fire-once / re-arm logic)
Here is the complete Code node:
const cfg = $('Config').first().json;
const http = (url, opts = {}) => this.helpers.httpRequest({ url, json: true, timeout: 20000, ...opts });
const state = $getWorkflowStaticData('global'); // persists between *active* (scheduled) runs
const fmt = (n, d = 2) => Number(n).toLocaleString('en-US', { maximumFractionDigits: d });
const rules = String(cfg.levels).split(',').map(s => s.trim()).filter(Boolean).map(s => {
const m = s.match(/^([A-Z0-9]+)\s*([<>])\s*([\d.]+)$/i);
if (!m) throw new Error(`Bad level "${s}". Use e.g. BTCUSDT>100000`);
return { key: s, sym: m[1].toUpperCase(), op: m[2], lvl: Number(m[3]) };
});
const syms = [...new Set(rules.map(r => r.sym))];
const prices = await http(`https://data-api.binance.vision/api/v3/ticker/price?symbols=${encodeURIComponent(JSON.stringify(syms))}`);
const px = Object.fromEntries(prices.map(p => [p.symbol, Number(p.price)]));
state.fired = state.fired || {};
const out = [];
for (const r of rules) {
const p = px[r.sym]; const hit = r.op === '>' ? p > r.lvl : p < r.lvl;
if (hit && !state.fired[r.key]) { state.fired[r.key] = true; out.push({ json: { text: `🎯 <b>${r.sym}</b> ${r.op === '>' ? 'above' : 'below'} ${fmt(r.lvl, 8)} — now ${fmt(p, 8)}` } }); }
if (!hit) state.fired[r.key] = false;
}
return out.length ? out : [{ json: { info: 'No level crossed', prices: px } }];
Let's go through it.
Parsing the rules
const m = s.match(/^([A-Z0-9]+)\s*([<>])\s*([\d.]+)$/i);
if (!m) throw new Error(`Bad level "${s}". Use e.g. BTCUSDT>100000`);
return { key: s, sym: m[1].toUpperCase(), op: m[2], lvl: Number(m[3]) };
Each rule becomes { key, sym, op, lvl }. A typo in levels throws with a readable error instead of silently never firing. Note that key is the trimmed rule string itself, e.g. "BTCUSDT>100000". That key is what the state is stored under.
One request for all symbols
const syms = [...new Set(rules.map(r => r.sym))];
const prices = await http(`https://data-api.binance.vision/api/v3/ticker/price?symbols=${encodeURIComponent(JSON.stringify(syms))}`);
Symbols are de-duplicated and fetched in a single call to Binance's public market data host, data-api.binance.vision. It needs no key, and (per the workflow's sticky note) it works from regions where binance.com is blocked.
State that survives between runs
const state = $getWorkflowStaticData('global');
state.fired = state.fired || {};
$getWorkflowStaticData('global') returns an object that n8n saves with the workflow after each execution. That is where the "have I already alerted on this?" flag lives: state.fired["BTCUSDT>100000"] = true.
The core loop
for (const r of rules) {
const p = px[r.sym]; const hit = r.op === '>' ? p > r.lvl : p < r.lvl;
if (hit && !state.fired[r.key]) { state.fired[r.key] = true; out.push(/* alert */); }
if (!hit) state.fired[r.key] = false;
}
Per rule, per run, there are three cases:
-
Condition true, not yet fired: send an alert and set
fired = true. - Condition true, already fired: do nothing. This is what stops the spam.
-
Condition false: set
fired = false. The rule is re-armed and the next crossing alerts again.
A word on hysteresis: this code does not use a buffer band. It re-arms the moment price is back on the other side of the exact level. If price chops right around a level you can get an alert per crossing. For most levels checked once a minute that has been acceptable to me, but if you want a band you would add it in the !hit branch (e.g. only reset when price is some percentage away). That change is not in the workflow as shipped.
Output
When something fires, each alert is an item with a text field of this shape:
🎯 <b>${r.sym}</b> above|below <level> — now <price>
(The numbers are formatted with toLocaleString('en-US', { maximumFractionDigits: 8 }).) When nothing fires, the node returns a single info item, { info: 'No level crossed', prices: px }, so you can still see the current prices in the execution log.
Step 4: Notify
// Sends every incoming item's `text` to Telegram and/or Discord.
// Leave tokens empty to run in dry-run mode (messages are returned, not sent).
const cfg = $('Config').first().json;
const out = [];
for (const item of $input.all()) {
const text = item.json.text;
if (!text) continue;
const sent = [];
if (cfg.telegramBotToken && cfg.telegramChatId) {
const base = cfg.telegramApiBase || 'https://api.telegram.org';
await this.helpers.httpRequest({
method: 'POST', url: `${base}/bot${cfg.telegramBotToken}/sendMessage`, json: true,
body: { chat_id: cfg.telegramChatId, text, parse_mode: 'HTML', disable_web_page_preview: true },
});
sent.push('telegram');
}
if (cfg.discordWebhookUrl) {
await this.helpers.httpRequest({
method: 'POST', url: cfg.discordWebhookUrl, json: true,
body: { content: text.replace(/<\/?b>/g, '**').replace(/<[^>]+>/g, '') },
});
sent.push('discord');
}
out.push({ json: { text, sent, dryRun: sent.length === 0 } });
}
return out;
Things worth pointing out:
- It only handles items that have
text, so the "No level crossed" item is ignored. - Telegram uses
parse_mode: 'HTML', which is why the alert text uses<b>. - For Discord,
<b>tags are converted to**and any other tags are stripped. - If no token and no webhook are set, nothing is sent and each item comes back with
dryRun: true. That makes the workflow safe to import and run before you configure anything. -
telegramApiBaseis configurable, which is handy for pointing at a mock server in tests.
Testing it
In the editor. Leave the tokens empty, click Run manually, and look at the Notify output: you get the exact messages with dryRun: true. Set levels to something guaranteed to hit, such as BTCUSDT>1, to see an alert.
The static data catch. n8n only persists $getWorkflowStaticData for active (scheduled) executions, not manual test runs. So clicking Run manually twice will alert twice; that is expected. To check the fire-once behaviour for real, activate the workflow and watch the scheduled executions: the first run after a crossing alerts, and later runs stay silent for that rule until price crosses back.
Automated. I run the workflow JSON through a small Node harness with a mocked Telegram API. The test for this workflow runs it twice, passing the state from the first run into the second:
await t('06 price level crosses once then re-arms', async () => {
const a = await run(W + '06-price-level-alerts.json', { ...T, levels: 'BTCUSDT>1, ETHUSDT<1' });
assert(texts(a).length === 1, 'one hit');
const b = await run(W + '06-price-level-alerts.json', { ...T, levels: 'BTCUSDT>1, ETHUSDT<1' }, a.state);
assert(!texts(b).length, 'no repeat'); return texts(a)[0];
});
BTCUSDT>1 is always true and ETHUSDT<1 is always false, so the first run must produce exactly one alert and the second run, with the saved state, must produce none.
Wrap-up
The pattern is general: any "condition is true" check on a schedule becomes a "condition just became true" event once you store a per-rule flag in workflow static data and clear it when the condition goes false. The same trick works for funding rates, wallet balances or anything else you poll.
This is one of 9 workflows in my Crypto Automation Pack (€19 for the first 20 buyers): https://pjamour.gumroad.com/l/sfkvkp/LAUNCH19. There is also a free morning-snapshot workflow (BTC/ETH/SOL + Fear & Greed), and a free Telegram group with large wallet moves: t.me/whalemovesalert













