name: tenderly-alerts description: Use when setting up real-time blockchain monitoring on Tenderly — creating wallet/contract alerts (balance thresholds, transaction status, function calls, ERC20 transfers, event emissions), wiring Slack/webhook notifications via the Alerts REST API, troubleshooting 400 "Expressions are not in the right format" errors, or programmatically managing alerts outside the dashboard
Tenderly Alerts
Overview
Tenderly's Alerts API lets you provision real-time on-chain monitoring (balance, tx status, function calls, events, ERC20 transfers) and route notifications to Slack/email/webhooks. The public docs describe the dashboard flow but leave the API's expression schema mostly undocumented — expression type names in the docs (successful_tx, failed_tx, etc.) return 400 "Expressions are not in the right format". This skill captures the empirically-verified schema and the gotchas.
Core Pattern
An alert is an array of expressions (AND'd together) plus a list of delivery_channels. Each expression is { type, expression: {...} }. Type names are NOT free-form — only specific strings work. Wrong type or unknown field → silent drop or 400.
Quick Reference
Auth
# Generate at https://dashboard.tenderly.co/account/authorization
# Header: X-Access-Key (NOT Authorization: Bearer)
curl -H "X-Access-Key: $TOKEN" https://api.tenderly.co/api/v1/...
The me alias for account slug does not work — use the real slug from the dashboard URL (dashboard.tenderly.co/<slug>/<project>). 401 with the right header almost always means wrong slug or empty token (env vars don't persist across separate shell invocations — write the token to a file with chmod 600).
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /account/{slug}/projects |
List projects |
| GET | /account/{slug}/delivery-channels |
List notification destinations |
| GET | /account/{slug}/project/{proj}/alerts |
List alerts |
| POST | /account/{slug}/project/{proj}/alert |
Create alert (singular!) |
| GET | /account/{slug}/project/{proj}/alert/{id} |
Get alert |
| PATCH | /account/{slug}/project/{proj}/alert/{id} |
Update alert (requires FULL payload, not partial — name field is mandatory) |
| DELETE | /account/{slug}/project/{proj}/alert/{id} |
Delete alert |
PUT returns 404 — only PATCH works. Despite the name, PATCH is not a partial update: it rejects with "Name cannot be empty" if you omit fields. Standard pattern: GET → mutate the returned alert object → PATCH with full body (drop server-managed fields like id, project_id, created_at, updated_at, color).
Base URL: https://api.tenderly.co/api/v1.
Verified expression types
type |
expression shape |
Notes |
|---|---|---|
network |
{network_id: "1"} |
Chain ID as string. Required to scope alerts to a chain. |
eth_balance |
{address, threshold, operator} |
threshold is wei as string. operator ∈ <, <=, >, >=, ==, !=. Triggers on tx that mutates the balance, not on existing state. |
tx_status |
{transaction_success: true | false} |
Does NOT accept address — combine with whitelisted_caller_addresses. |
whitelisted_caller_addresses |
{addresses: ["0x..."]} |
Filters by tx sender (caller). Addresses lowercased on storage. |
tx_value |
{transaction_value: "0", operator: ">"} |
Filters by msg.value (wei string). Field name is transaction_value, NOT value — value is silently dropped. Combine with whitelisted_caller_addresses to filter by sender. |
Naming pattern: the inner field for "stateful" filters tends to use a transaction_* prefix (transaction_success, transaction_value). If a type returns 200 but your filter field comes back null, try renaming the field with that prefix.
Type names that DON'T work (return 400)
successful_tx, failed_tx, successful_transaction, failed_transaction, from_address, caller_address, filtered_caller_address, transaction_address, from, to, tx, address, transaction_value (use tx_value), erc20_transfer, erc20_token_transfer, erc20, transfer, token_transfer, log_emitted, event_emitted, log, event.
For unverified alert types (ERC20 transfer, function call, event emitted, state change, view function, allowlisted/blocklisted callers), the dashboard UI is the fastest discovery path: configure it once in the UI, then GET the alert via API and copy the expressions array as your template.
Full alert payload
{
"name": "Wallet outgoing tx succeeded",
"description": "Optional human-readable context",
"enabled": true,
"severity": "info | warning | critical",
"expressions": [
{ "type": "network", "expression": { "network_id": "1" } },
{ "type": "tx_status", "expression": { "transaction_success": true } },
{
"type": "whitelisted_caller_addresses",
"expression": { "addresses": ["0xABCD..."] }
}
],
"delivery_channels": [{ "id": "<channel_uuid>", "enabled": true }]
}
Common recipes
Balance threshold (wallet running low):
{
"expressions": [
{ "type": "network", "expression": { "network_id": "1" } },
{
"type": "eth_balance",
"expression": {
"address": "0x...",
"threshold": "100000000000000000",
"operator": "<"
}
}
],
"delivery_channels": [{ "id": "...", "enabled": true }],
"name": "...",
"enabled": true
}
Any successful tx from a wallet (activity monitor): network + tx_status (true) + whitelisted_caller_addresses.
Failed tx from a wallet (compromise/bug signal): same as above with transaction_success: false.
For the remaining 8 Tenderly alert types (Function Call, Event Emitted, Event Parameter, ERC20 Token Transfer, Allowlisted/Blocklisted Callers, Transaction Value, State Change, View Function) the dashboard UI is the fastest way to discover the exact expression shape: configure the alert in the UI, then GET it via API and copy the returned expressions array as the template.
Delivery channels
Cannot be created via API — only listed/referenced. Set up the destination (Slack OAuth, webhook URL, email, Discord, Telegram, PagerDuty) in the dashboard at /account/<slug>/project/<proj>/alerts/destinations, then:
curl -H "X-Access-Key: $TOKEN" \
https://api.tenderly.co/api/v1/account/<slug>/delivery-channels
Extract id from the response and reuse across alerts. Channels are account-scoped (visible to every project) unless you pick a project scope in the UI.
Gotchas
- Unknown fields are silently stripped. If you send
{type: "tx_status", expression: {address: "0x..", status: "success"}}the response will showexpression: {transaction_success: null}— no error, but your filter is gone. AlwaysGETthe alert after creation to verify the stored shape matches intent. - Token in
<bash-input>/ one-shotexportdoesn't persist across separate Bash tool invocations. Write to~/.tenderly_tokenwithchmod 600andcatit in each curl. - Balance alerts trigger on tx mutating the balance, not on state. A wallet sitting below threshold won't fire — only a tx involving it will re-evaluate.
meis not a valid account slug for these endpoints (despite older docs claiming so). Use the real slug.- Addresses are lowercased on storage but matched case-insensitively.
Negation limitation (no native "NOT in list" expression)
Tenderly's expression set has Allowlisted Callers and Blocklisted Callers, but those filter callers INTO a target contract, not callees FROM a wallet. To express "tx from W to any address NOT in [A, B, …]" (e.g., key-leak detection for a relayer that should only touch specific contracts), there is no native combination. Workaround: create a Web3 Action triggered by any tx where from = W, encode the allowlist in TypeScript, and POST to Slack from the action when the check fails.
Common Mistakes
| Mistake | What happens | Fix |
|---|---|---|
Using type: "successful_tx" (or similar guess) |
400 "Expressions are not in the right format" | Use tx_status + whitelisted_caller_addresses combo |
Putting address inside tx_status.expression |
Silently dropped, alert fires for all txs | Use separate whitelisted_caller_addresses expression |
Omitting network expression |
Alert may match across chains or behave unpredictably | Always include {type:"network",expression:{network_id:"<id>"}} |
Trying to POST /delivery-channels |
404 | Create channel in dashboard, list via API to get id |
Authorization: Bearer <token> |
500 (internal error) | Use X-Access-Key: <token> |
Setting account_slug = "me" |
401 | Use the real account slug from dashboard URL |
Sending threshold: 0.1 for eth_balance |
Likely 400 or unexpected behavior | Send wei as a string: "100000000000000000" |
Verification workflow
After any create/update, always:
GETthe alert by id- Diff
expressionsreturned vs. what you sent - Any missing field = silently stripped → schema is wrong