Tenderly
Tenderly is a blockchain DevOps platform providing transaction simulation, debugging, monitoring, and testing infrastructure. It supports Ethereum, Polygon, Arbitrum, Optimism, Base, BSC, Avalanche, Fantom, and 60+ other EVM networks through a unified REST API.
What You Probably Got Wrong
LLMs frequently generate outdated Tenderly API calls, confuse v1 and v2 endpoints, and misunderstand how Virtual TestNets differ from simple forks. These corrections are non-negotiable.
- The API is v2, not v1. All endpoints use
https://api.tenderly.co/api/v2/. If you see/api/v1/in your code, you are using the deprecated API. The v1 simulation endpoint had a different request/response schema. Stop and switch to v2. - "Forks" are now "Virtual TestNets". Tenderly rebranded forks to Virtual TestNets. The API paths still use
/vnets/or/testnet/in some contexts, but the product name is Virtual TestNet. Do not call them "forks" in user-facing code — the API field names may still sayforkinternally. - Authentication uses
X-Access-Key, notAuthorization: Bearer. The primary auth mechanism is a headerX-Access-Key: <your-access-key>. Bearer tokens exist for OAuth flows but are not the standard integration path. If you setAuthorization: Bearer <access-key>, the request will 401. - Project paths require both account slug AND project slug. Every project-scoped endpoint is
/api/v2/project/{accountSlug}/{projectSlug}/.... If you omit either slug, you get a 404. The account slug is your username or org name, NOT your account ID. - Simulation
fromfield is required. The v2 simulation endpoint requires afromaddress. Omitting it does not default to zero address — it returns a 400 error. Always specify who is sending the transaction. - State overrides use a map keyed by address, not an array. The
state_objectsfield in simulation requests is{ [address]: { storage: { [slot]: value } } }, not an array of override objects. - Virtual TestNet RPC URLs expire. When you create a Virtual TestNet, the returned RPC URL is ephemeral. If the VNet is deleted or expires (based on your plan), the URL stops working. Do not hardcode VNet RPC URLs.
- Web3 Actions are NOT Lambda functions with arbitrary runtimes. They run in a constrained Node.js environment with a specific set of available packages. You cannot install arbitrary npm packages. The runtime provides
ethers,axios, and the Tenderly SDK. Check the docs for the current package list. - Gas Profiler data comes from simulation, not from live transactions. You must simulate a transaction first, then access the gas breakdown from the simulation result. There is no separate "gas profiler" endpoint.
saveandsave_if_failsare separate flags. Settingsave: truesaves every simulation to your dashboard. Settingsave_if_fails: trueonly saves failed simulations. They are independent booleans, not mutually exclusive.- Simulation
network_idis a string, not a number. Pass"1"for Ethereum mainnet, not1. The API will reject numeric network IDs. block_numberin simulations is optional. If omitted, it uses the latest block. If provided, it must be a number (not a string), and it must be a block that has been indexed by Tenderly.
Quick Start
Authentication
Every Tenderly API request requires your access key. Generate one from the Tenderly Dashboard under Settings > Authorization > API Access Tokens.
const TENDERLY_ACCESS_KEY = process.env.TENDERLY_ACCESS_KEY;
const TENDERLY_ACCOUNT_SLUG = process.env.TENDERLY_ACCOUNT_SLUG;
const TENDERLY_PROJECT_SLUG = process.env.TENDERLY_PROJECT_SLUG;
if (!TENDERLY_ACCESS_KEY) throw new Error("TENDERLY_ACCESS_KEY is required");
if (!TENDERLY_ACCOUNT_SLUG) throw new Error("TENDERLY_ACCOUNT_SLUG is required");
if (!TENDERLY_PROJECT_SLUG) throw new Error("TENDERLY_PROJECT_SLUG is required");
const BASE_URL = `https://api.tenderly.co/api/v2/project/${TENDERLY_ACCOUNT_SLUG}/${TENDERLY_PROJECT_SLUG}`;
const headers = {
"X-Access-Key": TENDERLY_ACCESS_KEY,
"Content-Type": "application/json",
};
Installation
npm install axios
No dedicated Tenderly SDK package is required for REST API usage. All interaction is through HTTP requests. The @tenderly/actions package is only needed for Web3 Actions development.
Transaction Simulation API
Simulate any transaction without sending it onchain. Returns full execution trace, state changes, gas usage, event logs, and revert reasons.
Simulate a Simple ETH Transfer
interface SimulationRequest {
network_id: string;
from: string;
to: string;
input: string;
value: string;
gas: number;
gas_price: string;
save: boolean;
save_if_fails: boolean;
simulation_type: "quick" | "full" | "abi";
state_objects?: Record<string, {
balance?: string;
storage?: Record<string, string>;
}>;
block_number?: number;
}
async function simulateTransaction(
params: SimulationRequest
): Promise<{ simulation: { id: string; status: boolean; gas_used: number; block_number: number }; transaction: { transaction_info: { call_trace: { calls: Array<{ from: string; to: string; input: string; output: string; gas_used: number; type: string }> }; state_diff: Array<{ address: string; original: Record<string, string>; dirty: Record<string, string> }>; logs: Array<{ address: string; topics: string[]; data: string }> } } }> {
const response = await fetch(`${BASE_URL}/simulate`, {
method: "POST",
headers,
body: JSON.stringify(params),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Simulation failed (${response.status}): ${error}`);
}
return response.json() as Promise<SimulationResponse>;
}
Simulate an ERC-20 Transfer
import { encodeFunctionData, parseUnits } from "viem";
const erc20TransferAbi = [
{
name: "transfer",
type: "function",
stateMutability: "nonpayable",
inputs: [
{ name: "to", type: "address" },
{ name: "amount", type: "uint256" },
],
outputs: [{ name: "", type: "bool" }],
},
] as const;
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const SENDER = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
const RECIPIENT = "0x1234567890abcdef1234567890abcdef12345678";
const calldata = encodeFunctionData({
abi: erc20TransferAbi,
functionName: "transfer",
args: [RECIPIENT, parseUnits("1000", 6)],
});
const result = await simulateTransaction({
network_id: "1",
from: SENDER,
to: USDC,
input: calldata,
value: "0",
gas: 100_000,
gas_price: "0",
save: true,
save_if_fails: true,
simulation_type: "full",
});
console.log(`Success: ${result.simulation.status}`);
console.log(`Gas used: ${result.simulation.gas_used}`);
Simulate with State Overrides
Override account balances or storage slots before simulation. Useful for testing as any address without needing its private key.
const resultWithOverrides = await simulateTransaction({
network_id: "1",
from: SENDER,
to: USDC,
input: calldata,
value: "0",
gas: 200_000,
gas_price: "0",
save: false,
save_if_fails: true,
simulation_type: "full",
// Override sender's ETH balance to 100 ETH
state_objects: {
[SENDER]: {
balance: "0x56BC75E2D63100000", // 100 ETH in hex
},
},
});
Simulation Types
| Type | Description | Use Case |
|---|---|---|
quick |
Basic pass/fail, gas used, no trace | CI/CD validation, quick checks |
full |
Full call trace, state diffs, logs | Debugging, security analysis |
abi |
Decoded inputs/outputs using verified ABI | Human-readable output |
Batch Simulation (Simulate Bundle)
Simulate multiple transactions sequentially, where each transaction sees the state changes from previous ones.
interface BundleSimulationRequest {
simulations: SimulationRequest[];
}
async function simulateBundle(
simulations: SimulationRequest[]
): Promise<SimulationResponse[]> {
const response = await fetch(`${BASE_URL}/simulate-bundle`, {
method: "POST",
headers,
body: JSON.stringify({ simulations }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Bundle simulation failed (${response.status}): ${error}`);
}
const data = await response.json();
return data.simulation_results as SimulationResponse[];
}
Virtual TestNets (Forks)
Virtual TestNets create a copy of any network's state at a specific block. You get a private RPC endpoint that behaves like a real network but is fully isolated. Transactions on a Virtual TestNet do not affect mainnet.
Create a Virtual TestNet
async function createVirtualTestNet(params: {
slug: string;
display_name: string;
fork_config: { network_id: number; block_number?: number };
virtual_network_config: { chain_config: { chain_id: number } };
sync_state_config: { enabled: boolean };
explorer_page_config: { enabled: boolean; verification_visibility: "bytecode" | "src" | "all" };
}): Promise<{ id: string; rpcs: Array<{ name: string; url: string }>; fork_config: { network_id: number; block_number: number } }> {
const response = await fetch(
`https://api.tenderly.co/api/v2/project/${TENDERLY_ACCOUNT_SLUG}/${TENDERLY_PROJECT_SLUG}/vnets`,
{ method: "POST", headers, body: JSON.stringify(params) }
);
if (!response.ok) {
const error = await response.text();
throw new Error(`VNet creation failed (${response.status}): ${error}`);
}
return response.json();
}
const vnet = await createVirtualTestNet({
slug: "mainnet-test",
display_name: "Mainnet Testing Fork",
fork_config: {
network_id: 1,
},
virtual_network_config: {
chain_config: {
chain_id: 73571, // custom chain ID to avoid collision
},
},
sync_state_config: {
enabled: false,
},
explorer_page_config: {
enabled: true,
verification_visibility: "src",
},
});
// Use the RPC URL with viem or ethers
const rpcUrl = vnet.rpcs[0].url;
console.log(`VNet RPC: ${rpcUrl}`);
Fund an Account on a Virtual TestNet
async function fundAccount(
rpcUrl: string,
address: string,
amountHex: string
): Promise<void> {
const response = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "tenderly_setBalance",
params: [[address], amountHex],
id: 1,
}),
});
if (!response.ok) {
throw new Error(`Fund account failed (${response.status})`);
}
}
// Fund address with 1000 ETH
await fundAccount(
rpcUrl,
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
"0x3635C9ADC5DEA00000" // 1000 ETH in hex wei
);
Custom RPC Methods on Virtual TestNets
| Method | Description |
|---|---|
tenderly_setBalance |
Set ETH balance for one or more addresses |
tenderly_addBalance |
Add ETH to existing balance |
tenderly_setErc20Balance |
Set ERC-20 token balance for an address |
tenderly_setStorageAt |
Set arbitrary storage slot value |
evm_snapshot |
Create a state snapshot (returns snapshot ID) |
evm_revert |
Revert to a previous snapshot |
evm_increaseTime |
Advance block timestamp |
evm_increaseBlocks |
Mine a specified number of blocks |
Set ERC-20 Balance on Virtual TestNet
async function setErc20Balance(
rpcUrl: string,
tokenAddress: string,
walletAddress: string,
amountHex: string
): Promise<void> {
const response = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "tenderly_setErc20Balance",
params: [tokenAddress, walletAddress, amountHex],
id: 1,
}),
});
if (!response.ok) {
throw new Error(`Set ERC-20 balance failed (${response.status})`);
}
}
Delete a Virtual TestNet
async function deleteVirtualTestNet(vnetId: string): Promise<void> {
const response = await fetch(
`https://api.tenderly.co/api/v2/project/${TENDERLY_ACCOUNT_SLUG}/${TENDERLY_PROJECT_SLUG}/vnets/${vnetId}`,
{
method: "DELETE",
headers,
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`VNet deletion failed (${response.status}): ${error}`);
}
}
Alerts and Webhooks
Monitor onchain events in real time. Alerts trigger when specified conditions are met and can notify via webhook, email, Slack, Telegram, PagerDuty, or Discord.
Alert Types
| Type | Trigger |
|---|---|
successful_tx |
Transaction executes successfully |
failed_tx |
Transaction reverts |
function_call |
Specific function is called on a contract |
event_emitted |
Specific event is emitted |
state_change |
Storage slot value changes |
balance_change |
ETH or token balance changes beyond threshold |
block_mined |
New block is mined (useful for health checks) |
whitelisted_caller |
Transaction from a specific address |
blacklisted_caller |
Transaction NOT from a whitelisted address |
Create an Alert via API
async function createAlert(params: {
name: string;
network: string;
type: string;
enabled: boolean;
alert_targets: Array<{
type: "webhook" | "email" | "slack" | "telegram" | "pagerduty" | "discord";
webhook?: { url: string; secret?: string };
email?: { address: string };
}>;
alert_parameters: {
contracts?: string[];
events?: Array<{ name: string; signature: string }>;
functions?: Array<{ name: string; signature: string }>;
threshold?: { amount: string; direction: "above" | "below" | "both" };
};
}): Promise<{ id: string }> {
const response = await fetch(`${BASE_URL}/alerts`, {
method: "POST",
headers,
body: JSON.stringify(params),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Alert creation failed (${response.status}): ${error}`);
}
return response.json() as Promise<{ id: string }>;
}
Webhook Signature Verification
If you set a secret on the webhook target, Tenderly signs the payload with HMAC-SHA256 in the x-tenderly-signature header. Verify with timingSafeEqual from node:crypto. See the setup-alerts example for a complete webhook receiver implementation.
Web3 Actions (Serverless Functions)
Web3 Actions are serverless functions that execute in response to onchain events, periodic schedules, or webhook triggers. They run in Tenderly's Node.js runtime with access to ethers, axios, and the Tenderly SDK.
Project Setup
npm install -g @tenderly/actions-cli
mkdir my-actions && cd my-actions
tenderly actions init
This creates a tenderly.yaml configuration file and an actions directory.
Configuration (tenderly.yaml)
account_id: ""
actions:
your-account/your-project:
runtime: v2
sources: actions
specs:
onTransferDetected:
description: "Triggers on ERC-20 Transfer events"
function: actions/transfer:onTransfer
trigger:
type: transaction
transaction:
status:
- mined
filters:
- network: 1
eventEmitted:
contract:
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
name: Transfer
periodicCheck:
description: "Runs every 5 minutes"
function: actions/health:periodicCheck
trigger:
type: periodic
periodic:
cron: "*/5 * * * *"
webhookHandler:
description: "Handles external webhook calls"
function: actions/webhook:handleWebhook
trigger:
type: webhook
Action Function Signature
import {
ActionFn,
Context,
Event,
TransactionEvent,
PeriodicEvent,
WebhookEvent,
} from "@tenderly/actions";
// Transaction-triggered action
export const onTransfer: ActionFn = async (
context: Context,
event: Event
) => {
const txEvent = event as TransactionEvent;
const txHash = txEvent.hash;
const logs = txEvent.logs;
// Access project secrets (stored in Tenderly dashboard)
const apiKey = await context.secrets.get("EXTERNAL_API_KEY");
// Access project storage (key-value store persisted between runs)
const lastProcessed = await context.storage.getStr("lastProcessedBlock");
await context.storage.putStr("lastProcessedBlock", txEvent.blockNumber.toString());
console.log(`Processing tx: ${txHash}`);
};
// Periodic action
export const periodicCheck: ActionFn = async (
context: Context,
event: Event
) => {
const periodic = event as PeriodicEvent;
console.log(`Periodic run at: ${periodic.time}`);
};
// Webhook-triggered action
export const handleWebhook: ActionFn = async (
context: Context,
event: Event
) => {
const webhook = event as WebhookEvent;
const body = webhook.payload;
console.log(`Webhook received: ${JSON.stringify(body)}`);
};
Deploy Actions
tenderly actions deploy
Action Runtime Constraints
| Constraint | Limit |
|---|---|
| Execution timeout | 60 seconds |
| Memory | 256 MB |
| Payload size (webhook) | 1 MB |
| Storage (per project) | 10 MB |
| Available packages | ethers, axios, @tenderly/actions (built-in) |
Contract Verification
Verify contracts on Tenderly for decoded transaction traces and human-readable function calls.
Verify via API
POST to /project/{account}/{project}/contracts/verify with a body containing:
{
contracts: [{
contractToVerify: "0xAddress:1", // "address:network_id"
solcConfig: {
compiler_version: "v0.8.24+commit.e11b9ed9",
optimizations_used: true,
optimizations_count: 200,
},
sources: {
"Contract.sol": { name: "Contract.sol", code: "// solidity source..." },
},
}]
}
Verify Using Hardhat Plugin
npm install @tenderly/hardhat-tenderly
// hardhat.config.ts
import "@tenderly/hardhat-tenderly";
const config = {
tenderly: {
project: process.env.TENDERLY_PROJECT_SLUG,
username: process.env.TENDERLY_ACCOUNT_SLUG,
},
};
npx hardhat tenderly:verify --network mainnet ContractName=0x...
Gas Profiler
Gas profiling data is embedded in simulation_type: "full" results. There is no separate gas profiler endpoint. Walk the transaction.transaction_info.call_trace.calls array to get per-call gas breakdowns including from, to, gas_used, and type for each internal call. See the simulate-transaction example for a complete extractGasProfile implementation.
Transaction Debugger
The Transaction Debugger provides step-by-step EVM execution traces for any transaction. Access it via the dashboard or programmatically.
Get Debug Trace for an Existing Transaction
async function getTransactionTrace(
networkId: string,
txHash: string
): Promise<{ gas: number; failed: boolean; return_value: string; struct_logs: Array<{ pc: number; op: string; gas: number; gas_cost: number; depth: number; stack: string[]; memory: string[]; storage: Record<string, string> }> }> {
const response = await fetch(
`https://api.tenderly.co/api/v2/project/${TENDERLY_ACCOUNT_SLUG}/${TENDERLY_PROJECT_SLUG}/network/${networkId}/transaction/${txHash}/trace`,
{ method: "GET", headers }
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Trace retrieval failed (${response.status}): ${error}`);
}
return response.json();
}
Error Handling Pattern
All Tenderly API errors return { error: { message, slug, id } }. Always parse the error body on non-2xx responses to get actionable error slugs. See resources/error-codes.md for the complete error reference and templates/tenderly-client.ts for a production-ready tenderlyFetch<T>() wrapper with proper error extraction.
Rate Limits and Quotas
| Resource | Free | Pro | Enterprise |
|---|---|---|---|
| Simulations/month | 1,500 | 50,000 | Custom |
| Virtual TestNets (concurrent) | 1 | 10 | Custom |
| Alerts | 5 | 100 | Custom |
| Web3 Actions | 3 | 25 | Custom |
| API rate limit | 10 req/s | 50 req/s | Custom |
Rate limit responses return HTTP 429 with a Retry-After header.
CI/CD Integration
Simulate Before Deploy (GitHub Actions)
name: Simulate Deployment
on:
pull_request:
paths:
- "contracts/**"
jobs:
simulate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npx hardhat compile
- name: Simulate deployment
env:
TENDERLY_ACCESS_KEY: ${{ secrets.TENDERLY_ACCESS_KEY }}
TENDERLY_ACCOUNT_SLUG: ${{ secrets.TENDERLY_ACCOUNT_SLUG }}
TENDERLY_PROJECT_SLUG: ${{ secrets.TENDERLY_PROJECT_SLUG }}
run: npx ts-node scripts/simulate-deploy.ts
Further Reading
- Tenderly API v2 docs: https://docs.tenderly.co/reference/api
- Virtual TestNets: https://docs.tenderly.co/virtual-testnets
- Web3 Actions: https://docs.tenderly.co/web3-actions
- Gas Profiler: https://docs.tenderly.co/debugger/gas-profiler
- Hardhat integration: https://docs.tenderly.co/monitoring/smart-contracts/hardhat