<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[cryptoflows]]></title><description><![CDATA[cryptoflows]]></description><link>https://cryptoflows.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 04:57:02 GMT</lastBuildDate><atom:link href="https://cryptoflows.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building crypto price level alerts in n8n that fire once and re-arm]]></title><description><![CDATA[Alert once when a level is crossed, stay quiet, re-arm when price crosses back. Workflow static data, no API keys.]]></description><link>https://cryptoflows.hashnode.dev/building-crypto-price-level-alerts-in-n8n-that-fire-once-and-re-arm</link><guid isPermaLink="true">https://cryptoflows.hashnode.dev/building-crypto-price-level-alerts-in-n8n-that-fire-once-and-re-arm</guid><category><![CDATA[n8n]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[crypto]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[cryptoflows]]></dc:creator><pubDate>Tue, 27 Oct 2026 13:23:43 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>What you actually want is an alert that fires <strong>once</strong> when the level is crossed, stays quiet while price sits on the other side, and <strong>re-arms</strong> when price crosses back, so the next crossing alerts again.</p>
<p>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.</p>
<h2>The layout: Schedule → Config → Check → Notify</h2>
<p>The workflow is called <code>Crypto Pack 06 — Price level alerts (Binance)</code>. It has these nodes:</p>
<table>
<thead>
<tr>
<th>Node</th>
<th>Type</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>Schedule</code></td>
<td>Schedule Trigger</td>
<td>Cron <code>* * * * *</code> (every minute)</td>
</tr>
<tr>
<td><code>Run manually</code></td>
<td>Manual Trigger</td>
<td>For testing in the editor</td>
</tr>
<tr>
<td><code>Config</code></td>
<td>Set</td>
<td>Telegram/Discord settings and the list of levels</td>
</tr>
<tr>
<td><code>Check</code></td>
<td>Code</td>
<td>Fetches prices, evaluates rules, keeps state</td>
</tr>
<tr>
<td><code>Notify</code></td>
<td>Code</td>
<td>Sends each alert to Telegram and/or Discord</td>
</tr>
</tbody></table>
<p>Both triggers go into <code>Config</code>, then <code>Check</code>, then <code>Notify</code>. Keeping the settings in one <code>Set</code> node means the other nodes read them with <code>$('Config').first().json</code>, and nobody has to edit code to change a threshold.</p>
<h2>Step 1: Schedule</h2>
<p>A Schedule Trigger with a cron expression:</p>
<pre><code class="language-json">{
  "rule": {
    "interval": [
      {
        "field": "cronExpression",
        "expression": "* * * * *"
      }
    ]
  }
}
</code></pre>
<p>Every minute is fine here: it is one HTTP request per run, to a public endpoint.</p>
<h2>Step 2: Config</h2>
<p>A Set node with five string fields:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Default</th>
</tr>
</thead>
<tbody><tr>
<td><code>telegramBotToken</code></td>
<td><em>(empty)</em></td>
</tr>
<tr>
<td><code>telegramChatId</code></td>
<td><em>(empty)</em></td>
</tr>
<tr>
<td><code>discordWebhookUrl</code></td>
<td><em>(empty)</em></td>
</tr>
<tr>
<td><code>telegramApiBase</code></td>
<td><code>https://api.telegram.org</code></td>
</tr>
<tr>
<td><code>levels</code></td>
<td><code>BTCUSDT&gt;100000, BTCUSDT&lt;80000, ETHUSDT&gt;4000, SOLUSDT&lt;120</code></td>
</tr>
</tbody></table>
<p><code>levels</code> is a comma-separated list of rules. Each rule is a Binance symbol, <code>&gt;</code> or <code>&lt;</code>, and a number. You can put several rules on the same symbol, which is how you get a "range" alert (above 100k <em>and</em> below 80k).</p>
<h2>Step 3: Check (the fire-once / re-arm logic)</h2>
<p>Here is the complete Code node:</p>
<pre><code class="language-javascript">const cfg = $('Config').first().json;
const http = (url, opts = {}) =&gt; this.helpers.httpRequest({ url, json: true, timeout: 20000, ...opts });
const state = $getWorkflowStaticData('global'); // persists between *active* (scheduled) runs
const fmt = (n, d = 2) =&gt; Number(n).toLocaleString('en-US', { maximumFractionDigits: d });

const rules = String(cfg.levels).split(',').map(s =&gt; s.trim()).filter(Boolean).map(s =&gt; {
  const m = s.match(/^([A-Z0-9]+)\s*([&lt;&gt;])\s*([\d.]+)$/i);
  if (!m) throw new Error(`Bad level "${s}". Use e.g. BTCUSDT&gt;100000`);
  return { key: s, sym: m[1].toUpperCase(), op: m[2], lvl: Number(m[3]) };
});
const syms = [...new Set(rules.map(r =&gt; 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 =&gt; [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 === '&gt;' ? p &gt; r.lvl : p &lt; r.lvl;
  if (hit &amp;&amp; !state.fired[r.key]) { state.fired[r.key] = true; out.push({ json: { text: `🎯 &lt;b&gt;${r.sym}&lt;/b&gt; ${r.op === '&gt;' ? '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 } }];
</code></pre>
<p>Let's go through it.</p>
<h3>Parsing the rules</h3>
<pre><code class="language-javascript">const m = s.match(/^([A-Z0-9]+)\s*([&lt;&gt;])\s*([\d.]+)$/i);
if (!m) throw new Error(`Bad level "${s}". Use e.g. BTCUSDT&gt;100000`);
return { key: s, sym: m[1].toUpperCase(), op: m[2], lvl: Number(m[3]) };
</code></pre>
<p>Each rule becomes <code>{ key, sym, op, lvl }</code>. A typo in <code>levels</code> throws with a readable error instead of silently never firing. Note that <code>key</code> is the trimmed rule string itself, e.g. <code>"BTCUSDT&gt;100000"</code>. That key is what the state is stored under.</p>
<h3>One request for all symbols</h3>
<pre><code class="language-javascript">const syms = [...new Set(rules.map(r =&gt; r.sym))];
const prices = await http(`https://data-api.binance.vision/api/v3/ticker/price?symbols=${encodeURIComponent(JSON.stringify(syms))}`);
</code></pre>
<p>Symbols are de-duplicated and fetched in a single call to Binance's public market data host, <code>data-api.binance.vision</code>. It needs no key, and (per the workflow's sticky note) it works from regions where <a href="http://binance.com">binance.com</a> is blocked.</p>
<h3>State that survives between runs</h3>
<pre><code class="language-javascript">const state = $getWorkflowStaticData('global');
state.fired = state.fired || {};
</code></pre>
<p><code>$getWorkflowStaticData('global')</code> returns an object that n8n saves with the workflow after each execution. That is where the "have I already alerted on this?" flag lives: <code>state.fired["BTCUSDT&gt;100000"] = true</code>.</p>
<h3>The core loop</h3>
<pre><code class="language-javascript">for (const r of rules) {
  const p = px[r.sym]; const hit = r.op === '&gt;' ? p &gt; r.lvl : p &lt; r.lvl;
  if (hit &amp;&amp; !state.fired[r.key]) { state.fired[r.key] = true; out.push(/* alert */); }
  if (!hit) state.fired[r.key] = false;
}
</code></pre>
<p>Per rule, per run, there are three cases:</p>
<ol>
<li><strong>Condition true, not yet fired</strong>: send an alert and set <code>fired = true</code>.</li>
<li><strong>Condition true, already fired</strong>: do nothing. This is what stops the spam.</li>
<li><strong>Condition false</strong>: set <code>fired = false</code>. The rule is re-armed and the next crossing alerts again.</li>
</ol>
<p>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 <code>!hit</code> branch (e.g. only reset when price is some percentage away). That change is not in the workflow as shipped.</p>
<h3>Output</h3>
<p>When something fires, each alert is an item with a <code>text</code> field of this shape:</p>
<pre><code class="language-plaintext">🎯 &lt;b&gt;${r.sym}&lt;/b&gt; above|below &lt;level&gt; — now &lt;price&gt;
</code></pre>
<p>(The numbers are formatted with <code>toLocaleString('en-US', { maximumFractionDigits: 8 })</code>.) When nothing fires, the node returns a single info item, <code>{ info: 'No level crossed', prices: px }</code>, so you can still see the current prices in the execution log.</p>
<h2>Step 4: Notify</h2>
<pre><code class="language-javascript">// 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 &amp;&amp; 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(/&lt;\/?b&gt;/g, '**').replace(/&lt;[^&gt;]+&gt;/g, '') },
    });
    sent.push('discord');
  }
  out.push({ json: { text, sent, dryRun: sent.length === 0 } });
}
return out;
</code></pre>
<p>Things worth pointing out:</p>
<ul>
<li>It only handles items that have <code>text</code>, so the "No level crossed" item is ignored.</li>
<li>Telegram uses <code>parse_mode: 'HTML'</code>, which is why the alert text uses <code>&lt;b&gt;</code>.</li>
<li>For Discord, <code>&lt;b&gt;</code> tags are converted to <code>**</code> and any other tags are stripped.</li>
<li>If no token and no webhook are set, nothing is sent and each item comes back with <code>dryRun: true</code>. That makes the workflow safe to import and run before you configure anything.</li>
<li><code>telegramApiBase</code> is configurable, which is handy for pointing at a mock server in tests.</li>
</ul>
<h2>Testing it</h2>
<p><strong>In the editor.</strong> Leave the tokens empty, click <em>Run manually</em>, and look at the <code>Notify</code> output: you get the exact messages with <code>dryRun: true</code>. Set <code>levels</code> to something guaranteed to hit, such as <code>BTCUSDT&gt;1</code>, to see an alert.</p>
<p><strong>The static data catch.</strong> n8n only persists <code>$getWorkflowStaticData</code> for <strong>active</strong> (scheduled) executions, not manual test runs. So clicking <em>Run manually</em> 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.</p>
<p><strong>Automated.</strong> 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:</p>
<pre><code class="language-javascript">await t('06 price level crosses once then re-arms', async () =&gt; {
  const a = await run(W + '06-price-level-alerts.json', { ...T, levels: 'BTCUSDT&gt;1, ETHUSDT&lt;1' });
  assert(texts(a).length === 1, 'one hit');
  const b = await run(W + '06-price-level-alerts.json', { ...T, levels: 'BTCUSDT&gt;1, ETHUSDT&lt;1' }, a.state);
  assert(!texts(b).length, 'no repeat'); return texts(a)[0];
});
</code></pre>
<p><code>BTCUSDT&gt;1</code> is always true and <code>ETHUSDT&lt;1</code> is always false, so the first run must produce exactly one alert and the second run, with the saved state, must produce none.</p>
<h2>Wrap-up</h2>
<p>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.</p>
<hr />
<p><em>Originally published on</em> <a href="https://dev.to/cryptoflows/building-crypto-price-level-alerts-in-n8n-that-fire-once-and-re-arm-479m"><em>DEV</em></a><em>.</em></p>
<p>The workflow is free and MIT-licensed: <a href="https://github.com/cryptoflows/n8n-crypto-price-alerts">cryptoflows/n8n-crypto-price-alerts</a>.</p>
<p>Free single workflow: Crypto Morning Snapshot (BTC/ETH/SOL + Fear &amp; Greed): <a href="https://pjamour.gumroad.com/l/free-crypto-snapshot">https://pjamour.gumroad.com/l/free-crypto-snapshot</a></p>
<p>Want the full pack? 11 workflows, code LAUNCH19 for €19: <a href="https://pjamour.gumroad.com/l/sfkvkp/LAUNCH19">https://pjamour.gumroad.com/l/sfkvkp/LAUNCH19</a></p>
<p>Disclosure: I made both.</p>
]]></content:encoded></item></channel></rss>