A Safe{Wallet} multisig is only as safe as its owner list and its threshold. If a signer is swapped out or the threshold drops from 3 to 1, you want to hear about it in minutes, not when someone reads the next governance call notes. The message this workflow sends looks like this:
Safe 0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe changedThreshold: 3 to 2Owners: 5 to 5Now: 0xe4df0cdc..., 0xa1cf7f84..., 0xd68f1a88..., 0xfcf00b0f..., 0x8fd960f1...TL;DR
The evmquery trigger node reads getOwners() and getThreshold() in one
expression and only fires when the combined value changes. Connect it to an n8n
Telegram node and you have an alert with no schedule node, no state store and
no IF node.
Prerequisites
- An n8n instance (Cloud or self-hosted) with the
n8n-nodes-evmquerycommunity node installed. Wallet balance change alerts in n8n covers the install. - An evmquery API key from the dashboard, saved in n8n as an “evmquery API” credential.
- A Telegram bot token from BotFather, saved as a “Telegram account” credential, and the chat id you want the alert in.
- The address of a Safe on Ethereum, Base, BNB Chain or Polygon. The examples use
0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe, a 3-of-5 Safe on Ethereum.
One expression that holds the whole configuration
The trigger node compares the value of one expression between polls, so the first job is to fold everything you care about into a single value. For a Safe that is the owner list and the threshold:
{"owners": dyn(safe.getOwners()), "threshold": dyn(formatUnits(safe.getThreshold(), 0))}Run against the example Safe, this returned:
{ "owners": [ { "value": "0xe4df0cdc9ef7e388ea906226010bbd1b9a6ffed9" }, { "value": "0xa1cf7f847ecd82459ce05a218eaa38a9d92e7b6b" }, { "value": "0xd68f1a882f3f9ffddabd4d30c4f8dfca1f9e51ba" }, { "value": "0xfcf00b0fedbc8f2f35a3b8d4b858d5805f2bb05d" }, { "value": "0x8fd960f1b9d68bad2b97bd232fb75cc1f186b064" } ], "threshold": 3}Two reads, one Multicall3 round, 3 units. Wrap each value in dyn() so one map can carry the owner list and the threshold together; the Safe read post does the same for its five-field query. formatUnits(..., 0) turns the threshold into a plain number for the message. The trigger node serializes the whole map before comparing, and you keep real fields to use in the Telegram message.
Why the order of owners does not cause false alerts
You would normally sort a list before comparing it. You do not need to here. Safe stores owners in a linked list, so getOwners() returns them in list order, and the order only changes when the owner set itself changes (adding an owner puts it at the head, swapping replaces one in place). Two polls of an unchanged Safe return identical arrays. Repeated runs of the expression return identical output.
Why the nonce is not in there
nonce() is tempting because it is the cheapest way to see that something happened. But it increments on every executed Safe transaction, including ordinary token transfers. Put it in the expression and the trigger fires on every single transaction, which is a different alert (a transaction monitor) and a noisy one. The example Safe’s nonce is 8, so it would have fired for each of those eight transactions. Leave it out and the alert means exactly one thing: the people or the rules for approving transactions changed.
The evmquery Trigger node
Add an evmquery Trigger node and fill in these fields. The parameter names are the ones the node uses internally, which matters if you edit exported JSON.
Chain (chainId): EthereumContracts (contracts): safe = 0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afeExpression: {"owners": dyn(safe.getOwners()), "threshold": dyn(formatUnits(safe.getThreshold(), 0))}Options > Emit On: Value Change (emitOn: change)Poll Times: Every 5 minutesThere is no Context variable because the Safe address is fixed in the contract row. If you want one workflow for several Safes, give each its own trigger node; each node keeps its own last value, keyed to its chain, expression and contracts, so two Safes do not overwrite each other.
The output on a real fire has four fields: value, previousValue, blockNumber and type. Five minutes costs 36 units an hour for one Safe, comfortably inside the free tier’s 700 units per hour mentioned in the balance alert post.
The Telegram node
Connect an n8n Telegram node to the trigger. Set Resource to Message, Operation to Send Message, paste your chat id, and use this as the text:
Safe 0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe changedThreshold: {{ $json.previousValue?.threshold ?? 'unknown' }} to {{ $json.value.threshold }}Owners: {{ $json.previousValue?.owners.length ?? 'unknown' }} to {{ $json.value.owners.length }}Now: {{ $json.value.owners.map(o => o.value).join(', ') }}The ?. and ?? 'unknown' parts exist because of the Fetch Test Event behavior described below. The .map(o => o.value) exists because getOwners() returns each address wrapped as an object with a value field, as you can see in the output above. I turned off Append n8n Attribution under Additional Fields so the message is only the alert, and I kept the text free of < and > since Telegram parses HTML by default in this node.
Import the finished workflow
This is the whole workflow. In n8n choose Import from File or paste it onto the canvas, then pick your own credentials on both nodes and replace YOUR_TELEGRAM_CHAT_ID.
{ "name": "Safe owner and threshold change alert", "nodes": [ { "parameters": { "pollTimes": { "item": [ { "mode": "everyX", "value": 5, "unit": "minutes" } ] }, "chainId": "evm_ethereum", "expression": "{\"owners\": dyn(safe.getOwners()), \"threshold\": dyn(formatUnits(safe.getThreshold(), 0))}", "contracts": { "entries": [ { "name": "safe", "address": "0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe" } ] }, "options": { "emitOn": "change" } }, "name": "evmquery Trigger", "type": "n8n-nodes-evmquery.evmQueryTrigger", "typeVersion": 1, "position": [ 0, 0 ], "credentials": { "evmQueryApi": { "id": "REPLACE_ME", "name": "evmquery API" } } }, { "parameters": { "resource": "message", "operation": "sendMessage", "chatId": "YOUR_TELEGRAM_CHAT_ID", "text": "=Safe 0x0da0c3e52c977ed3cbc641ff02dd271c3ed55afe changed\nThreshold: {{ $json.previousValue?.threshold ?? 'unknown' }} to {{ $json.value.threshold }}\nOwners: {{ $json.previousValue?.owners.length ?? 'unknown' }} to {{ $json.value.owners.length }}\nNow: {{ $json.value.owners.map(o => o.value).join(', ') }}", "additionalFields": { "appendAttribution": false } }, "name": "Telegram", "type": "n8n-nodes-base.telegram", "typeVersion": 1.2, "position": [ 320, 0 ], "credentials": { "telegramApi": { "id": "REPLACE_ME", "name": "Telegram account" } } } ], "connections": { "evmquery Trigger": { "main": [ [ { "node": "Telegram", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" }}The credential ids are placeholders. n8n asks you to select real credentials after import, and no secret is in the file.
Testing and edge cases
Fetch Test Event breaks the Telegram expression. In the editor, the trigger’s test fetch always returns the current value with previousValue: null and does not touch the stored state. A message that reads $json.previousValue.threshold directly fails on exactly that null, so the Telegram node errors the first time you test the workflow. The optional chaining above avoids it, and the same guard covers the first-ever fire after a restart.
The first poll never alerts. After you activate the workflow, the first successful poll silently stores the current value. Nothing happens until the second poll sees a difference. If you want to see it work, point a copy at a Safe you control and change its threshold, or temporarily use Emit On: Every Poll.
A change and its reversal inside one interval is invisible. The node compares polls, not blocks. If an owner is added and removed again between two five-minute polls, you get nothing. For a multisig that is rarely a problem, but it is a reason not to widen the interval to hours.
Modules are not watched. A Safe can also enable modules that move funds without owner signatures. This alert does not cover them. The Safe read post shows the getModulesPaginated read, and you could add it to the same expression as a third field.
If you would rather run this from a cron job than from n8n, the diffing is a few lines of code; the n8n trigger just saves you the state file. More n8n patterns are on the automation page.
Or run it as a monitor
If you would rather not run n8n for this, an evmquery monitor can watch the Safe on a schedule and alert by email or an HTTPS webhook. A change trigger tracks one value, and the threshold is the natural fit:
safe.getThreshold()Use a change trigger. It stores the first value as its baseline and fires when the threshold differs. To watch a specific signer, add a condition monitor such as !safe.isOwner(owner). evmquery retries failed deliveries and pauses the monitor with an alert after repeated errors. The Free plan covers 3 monitors with 15-minute checks. An n8n webhook trigger can forward the alert to Telegram.
Next steps
- Gnosis Safe API: read owners, threshold and nonce: the reads behind this expression, plus modules and the proxy trap
- Wallet balance change alerts in n8n: the same trigger node on a token balance
- Read smart contracts in n8n: the Execute Query action when you want to build the diff yourself
- Gnosis Safe Inspector: check a Safe’s current configuration in the browser
- Monitors: a hosted change trigger for the Safe threshold, with email or webhook delivery


