sifting/io
Developer Tutorials
14 min readSiftingIO Team

n8n price alert workflow: message Slack only when a symbol crosses a level

Build an n8n price alert for Slack with market-hours checks, input validation, and state that detects level crossings between sampled prices.

n8n price alert workflow: message Slack only when a symbol crosses a level

An n8n price alert is useful when it tells you something changed. Posting the latest price every five minutes soon fills a Slack channel with repeated numbers. This workflow remembers which side of a level the last accepted price was on and sends a message when a newer accepted sample is on the other side.

The example watches AAPL against 200. It uses a market-open check, strict response validation, and a small amount of saved state. Both scripts are included in the workflow JSON; you supply your SiftingIO and Slack credentials.

This is a scheduled notification, not an execution system. A price that crosses a level and comes back between polls will not produce an alert.

How the workflow works#

  1. Run every five minutes during a weekday UTC window that covers the US equities regular session.
  2. Check whether us_equities is open. Stop before requesting a price if it is closed.
  3. Fetch the latest AAPL value and validate its symbol, price, timestamp, and age.
  4. Compare it with the last accepted side of the level. The first non-equal value establishes a baseline without alerting.
  5. Send an alert item through an IF node to Slack only when the side changes.

The market gate and price validation solve different problems: an open market can still have an old or malformed price.

Connect the two API requests#

Create an n8n Header Auth credential. Set its Name field to X-API-Key and its Value field to your SiftingIO API key. Select it in both HTTP Request nodes. Do not paste the key into the shared workflow JSON.

The market-status endpoint is:

GET https://api.sifting.io/v1/fnd/markets/us_equities/status

The gate requires data.market to match the configured market and data.is_open to be a boolean. A missing field or the string "false" fails the execution instead of silently treating a broken response as a closed market.

The last-trade endpoint is:

GET https://api.sifting.io/v1/last/trade/stocks/AAPL

Here is a synthetic response, not an observed market price:

{ "s": "AAPL", "p": "200.01", "P": "100", "t": 1789567200000 }

Price p and size P are strings. Timestamp t is a number in Unix epoch milliseconds for the published value. The detector uses s, p, and t, not size. Its message describes a sampled value, not a trade you could necessarily execute.

The JSON enables Include Response Headers and Status, Never Error, and JSON response format. HTTP responses reach the scripts as body, headers, and statusCode, including non-2xx responses. Network errors, timeouts, and invalid JSON can still fail the HTTP node. See the HTTP Request options.

Import and configure the workflow#

Save the JSON below as a file and choose Import from File in n8n. The import documentation also covers copying workflows into the editor.

After import:

  • Select the Header Auth credential in both HTTP Request nodes.
  • Select your Slack authentication method and credential, then choose a test channel. Invite the Slack app to that channel if required.
  • Keep both Code nodes in Run Once for All Items mode.
  • Confirm the IF node checks whether alert is true and that its true output connects to Slack.
  • Check that the workflow timezone is UTC.

The JSON specifies individual node type versions, not an n8n release. The embedded scripts have been executed against offline fixtures and the JSON has been parsed and checked. An actual n8n import, scheduled run, and Slack delivery have not been tested for this article; verify those in your installed version before relying on alerts.

{
  "name": "AAPL level alert",
  "settings": {
    "timezone": "UTC",
    "executionOrder": "v1"
  },
  "nodes": [
    {
      "name": "Every 5 minutes in session",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "*/5 13-21 * * 1-5"
            }
          ]
        }
      },
      "id": "alert-node-1"
    },
    {
      "name": "Market status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        220,
        0
      ],
      "parameters": {
        "url": "https://api.sifting.io/v1/fnd/markets/us_equities/status",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          },
          "timeout": 10000
        },
        "method": "GET"
      },
      "id": "alert-node-2"
    },
    {
      "name": "Skip if closed",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        0
      ],
      "parameters": {
        "jsCode": "const MARKET = 'us_equities';\n\nconst r = $input.first().json;\nconst fail = (why) => { throw new Error('market status check failed: ' + why); };\nconst isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);\n\nif (!isObj(r) || !Number.isInteger(r.statusCode)) fail('no HTTP response object');\nif (r.statusCode !== 200) fail('HTTP ' + r.statusCode + ' ' + JSON.stringify(r.body));\nif (!isObj(r.body) || !isObj(r.body.data)) fail('body.data is not an object');\n\nconst d = r.body.data;\nif (typeof d.market !== 'string' || d.market.toLowerCase() !== MARKET) {\n  fail('expected market ' + MARKET + ', got ' + JSON.stringify(d.market));\n}\nif (typeof d.is_open !== 'boolean') {\n  fail('is_open is not a boolean: ' + JSON.stringify(d.is_open));\n}\n\nif (d.is_open === false) return [];\nreturn [{ json: { market: d.market, is_open: true, state: d.state } }];",
        "mode": "runOnceForAllItems"
      },
      "id": "alert-node-3"
    },
    {
      "name": "Last trade",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        0
      ],
      "parameters": {
        "url": "https://api.sifting.io/v1/last/trade/stocks/AAPL",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          },
          "timeout": 10000
        },
        "method": "GET"
      },
      "id": "alert-node-4"
    },
    {
      "name": "Detect cross",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        0
      ],
      "parameters": {
        "jsCode": "const SYMBOL = 'AAPL'; // must match the Last trade URL\nconst LEVEL = 200;\nconst VENUE = 'stocks';\nconst MARKET = 'us_equities';\nconst MAX_AGE_MS = 60 * 1000;\nconst SKEW_TOLERANCE_MS = 5 * 1000;\n\nconst r = $input.first().json;\nconst now = Date.now();\nconst skip = (reason, extra = {}) => [{ json: { alert: false, reason, ...extra } }];\nconst isObj = v => v !== null && typeof v === 'object' && !Array.isArray(v);\n\nif (typeof SYMBOL !== 'string' || !SYMBOL ||\n    typeof VENUE !== 'string' || !VENUE ||\n    typeof MARKET !== 'string' || !MARKET ||\n    !Number.isFinite(LEVEL) || LEVEL <= 0 ||\n    !Number.isSafeInteger(MAX_AGE_MS) || MAX_AGE_MS < 0 ||\n    !Number.isSafeInteger(SKEW_TOLERANCE_MS) || SKEW_TOLERANCE_MS < 0) {\n  throw new Error('Invalid alert configuration');\n}\nif (!isObj(r) || !Number.isInteger(r.statusCode)) return skip('no_http_response');\nconst headers = isObj(r.headers) ? r.headers : {};\nconst header = name => Object.entries(headers).find(([key]) => key.toLowerCase() === name)?.[1];\n\nif (r.statusCode === 503 && isObj(r.body) && r.body.error === 'stale_snapshot') {\n  return skip('stale_snapshot', { last_t: r.body.last_t, server_now: r.body.server_now });\n}\nif (r.statusCode === 429) {\n  return skip('quota_or_rate_limited', {\n    error: isObj(r.body) ? r.body.error : undefined,\n    retry_after: header('retry-after'),\n  });\n}\nif (r.statusCode !== 200) {\n  return skip('http_' + r.statusCode, { error: isObj(r.body) ? r.body.error : undefined });\n}\n\nconst q = r.body;\nif (!isObj(q)) return skip('bad_body');\nif (q.s !== SYMBOL) return skip('symbol_mismatch', { expected: SYMBOL, got: q.s });\nif (typeof q.p !== 'string' || !/^\\d+(\\.\\d+)?$/.test(q.p)) return skip('bad_price');\nconst price = Number(q.p);\nif (!Number.isFinite(price) || price <= 0) return skip('bad_price');\n\nconst t = q.t;\nif (!Number.isSafeInteger(t) || t <= 0) return skip('bad_timestamp');\nconst ageMs = now - t;\nif (ageMs < -SKEW_TOLERANCE_MS) return skip('clock_skew', { age_ms: ageMs });\nif (ageMs > MAX_AGE_MS) return skip('stale', { age_ms: ageMs });\n\n// Node-scoped state keeps this detector separate from other Code nodes.\nconst store = $getWorkflowStaticData('node');\nconst sameConfig = store.level === LEVEL && store.symbol === SYMBOL &&\n  store.venue === VENUE && store.market === MARKET;\nif (sameConfig && Number.isSafeInteger(store.last_t) && t <= store.last_t) {\n  return skip('not_newer', { t, last_t: store.last_t });\n}\n\nconst side = price > LEVEL ? 'above' : price < LEVEL ? 'below' : 'at';\nconst prevSide = sameConfig && (store.side === 'above' || store.side === 'below')\n  ? store.side : undefined;\n\n// Only accepted values update state. Configuration changes establish a\n// fresh baseline here, without clearing unrelated fields.\nObject.assign(store, {\n  level: LEVEL, symbol: SYMBOL, venue: VENUE, market: MARKET,\n  last_price: q.p, last_t: t,\n  side: side === 'at' ? prevSide : side,\n});\n\nif (side === 'at') return skip('at_level', { price, prev_side: prevSide });\nif (prevSide === undefined) return skip('baseline', { side, price });\nif (prevSide === side) return skip('no_cross', { side, price });\n\nreturn [{ json: {\n  alert: true, symbol: SYMBOL, level: LEVEL, side, prev_side: prevSide,\n  price, t, age_ms: ageMs,\n  ratelimit_remaining: header('x-ratelimit-remaining'),\n} }];",
        "mode": "runOnceForAllItems"
      },
      "id": "alert-node-5"
    },
    {
      "name": "Crossed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1100,
        0
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "alert-true",
              "leftValue": "={{ $json.alert }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "alert-node-6"
    },
    {
      "name": "Slack",
      "type": "n8n-nodes-base.slack",
      "typeVersion": 2.2,
      "position": [
        1320,
        -100
      ],
      "parameters": {
        "resource": "message",
        "operation": "post",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "name",
          "value": "#market-alerts"
        },
        "text": "={{ $json.symbol }} moved from {{ $json.prev_side }} to {{ $json.side }} {{ $json.level }}: sampled value {{ $json.price }} (value timestamp {{ new Date($json.t).toISOString() }})",
        "otherOptions": {
          "includeLinkToWorkflow": false
        },
        "messageType": "text"
      },
      "id": "alert-node-7"
    }
  ],
  "connections": {
    "Every 5 minutes in session": {
      "main": [
        [
          {
            "node": "Market status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Market status": {
      "main": [
        [
          {
            "node": "Skip if closed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Skip if closed": {
      "main": [
        [
          {
            "node": "Last trade",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Last trade": {
      "main": [
        [
          {
            "node": "Detect cross",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Detect cross": {
      "main": [
        [
          {
            "node": "Crossed?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Crossed?": {
      "main": [
        [
          {
            "node": "Slack",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  }
}

Decide what counts as a crossing#

The detector keeps equality separate from the two sides. At exactly 200, it records the sample but preserves the previous side. A sequence of 199.50, 200.00, then 200.01 alerts once, when the last sample arrives. If the first sample is exactly 200, there is no side to compare yet.

For the same configuration, a duplicate or older timestamp is rejected. Rejected responses leave the stored baseline unchanged. Changing SYMBOL, LEVEL, VENUE, or MARKET establishes a new baseline on the next valid sample; editing a level is not itself a crossing.

The state is scoped to the detector node, not shared with other Code nodes. Successfully saved state carries the baseline across closed sessions and gaps. The next accepted sample after an overnight break is compared with the last accepted side, so an alert means the side changed between observations. It does not tell you when the crossing occurred.

Freshness and error handling#

The example rejects a value older than 60 seconds or more than five seconds ahead of the n8n host clock. Those are example tolerances, not universal market-data rules. Keep the host clock synchronized and choose tolerances appropriate to your instrument. Exactly 60 seconds old and exactly five seconds ahead pass the age checks, although the timestamp must still be newer than the previous accepted one.

The API documentation describes 503 stale_snapshot. The script handles that response separately but still checks every 200 response itself; it does not assume the server will reject every stale value.

A 429 from Last trade becomes quota_or_rate_limited, preserving the returned error and Retry-After when present. The workflow records that header but does not automatically wait or retry. A 429 from Market status instead fails the execution. Check the error before retrying: an exhausted allowance and a short-term rate limit need different responses.

The market gate throws on an unsuccessful or malformed status response. A closed market instead returns an empty array, so nothing reaches Last trade or Slack. There is no detector reason on that closed-market branch; inspect the Market status output.

Check the logic before enabling notifications#

These representative cases were exercised with synthetic HTTP responses, a controlled clock, and in-memory state. Each sequence uses increasing valid timestamps unless it deliberately tests ordering. Independent error cases start from a known baseline.

Input or sequenceExpected result
Fresh state, price 199.50baseline, no alert
Then 199.80no_cross
Then 200.00at_level, stored side remains below
Then 200.01Alert from below to above
Then 199.99Alert from above to below
Wrong symbol, array body, boolean or array priceRejected; state unchanged
Missing, string, or fractional timestampbad_timestamp
Value 61 seconds oldstale
Value six seconds ahead of the clockclock_skew
Same or earlier timestamp, still within the age tolerancenot_newer
HTTP 503 with stale_snapshotstale_snapshot
HTTP 429quota_or_rate_limited
Changed level plus an invalid responseRejected; previous state preserved
Changed level plus a valid non-equal sampleNew baseline, no alert
Missing or non-boolean is_openMarket gate throws
is_open: falseMarket gate stops the branch

These checks validate script behavior, not n8n persistence or Slack delivery.

In your test workflow, use controlled sample values to test a crossing without waiting for the market. Keep the level fixed at 200 and pass 199.50 followed by 200.01 with increasing fresh timestamps through the detector while retaining the same test state. The second sample should alert. Changing the level between runs resets the baseline and is not a valid crossing test.

Then publish or activate the workflow using the controls in your n8n version. Check a scheduled first non-equal sample reports baseline, later same-side samples report no_cross, and closed-market runs stop before requesting a price. Separately verify Slack credentials and channel access with a controlled test alert. Do not treat manual test state as proof of persistence between scheduled runs.

Estimate requests from the actual branches#

The cron */5 13-21 * * 1-5 runs at five-minute intervals from 13:00 through 21:55 UTC on weekdays: 108 runs. Each run requests market status. On a normal 390-minute regular session, 78 also request a price.

Cadence in the same UTC windowStatus requests per weekdayPrice requests per full sessionTotal per dayTotal for 22 full sessions
Every five minutes108781864,092
Every minute54039093020,460

These estimates assume successful status checks, no retries, 22 full sessions, and is_open true only during the regular session. Holidays and shortened sessions reduce price requests, but weekday status checks still run. Other workflows, tests, and retries add requests.

Check current allowances on the pricing page and actual usage in your dashboard. HTTP request counts alone do not establish how each endpoint is attributed to a billing allowance.

The explicit UTC timezone matters. n8n otherwise falls back to the instance timezone; see the Schedule Trigger documentation. The chosen window covers the usual summer and winter US regular sessions without changing the cron twice a year.

Know the delivery limits#

n8n documents workflow static data as experimental. Changes are saved on successful triggered executions, not manual tests, and high-frequency use can be unreliable. Keep one active copy and prevent overlapping executions. For more durable storage, consider n8n data tables or a database.

A saved baseline is not a delivery receipt. Slack can accept a message while its response is lost, so retrying may duplicate it. A failed execution may also miss an alert if the price returns to the previous side before the next successful run. Do not assume a failed execution always restores the prior in-memory state; verify failure behavior in your installed version.

This workflow offers neither at-least-once nor exactly-once delivery. Use an error workflow to surface failures. If notifications must survive outages, add durable pending-message records and a retry policy rather than relying only on the last price side.

Adapt it to another market#

Update the price URL, market-status URL, the gate's MARKET, and the detector's SYMBOL, VENUE, MARKET, and LEVEL together. Choose a schedule suited to that instrument's session. The market-hours guide covers calendars and shortened sessions.

For all-day crypto polling, */5 * * * * gives 8,640 scheduled runs in a 30-day month or 8,928 in a 31-day month. With the status request and gate removed, each run makes one price request. Keeping both requests doubles those counts before retries.

A community node can replace the HTTP request only if you also adapt its output to the response envelope these scripts expect. Do not assume it exposes status codes and headers in the same form.

If five-minute sampling is too coarse, a WebSocket version can compare successive received updates. It still needs reconnect handling, gap detection, and its own state strategy.

Create a free API key or explore the API documentation.

Keep reading

Related posts