Direct Execution API
The Direct Execution API allows you to execute blockchain transactions directly without creating workflows. All endpoints require API key authentication and are subject to rate limiting and spending caps.
Authentication
All direct execution endpoints require an organization API key (kh_) passed in the Authorization header as a bearer token:
Authorization: Bearer kh_your_api_keySee Authentication for the full auth model and API Keys for details on creating and managing API keys.
Scope is enforced on these endpoints. A key created with mcp:read only can read execution status and run dry-run simulations, but is refused with 403 insufficient_scope when it tries to broadcast. Broadcasting needs mcp:write or mcp:admin. A key created without any scope has no scope restriction and passes every gate; the same rules apply to MCP OAuth tokens.
Rate Limits
Direct execution requests are limited to 60 requests per minute per API key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset so you can pace requests; a 429 adds Retry-After with the seconds to wait. See API errors for the full header reference.
Spending Caps
Two independent daily caps bound the native token value an organization moves (gas is not counted against them):
| Cap | Unit | Applies to |
|---|---|---|
dailyValueCapWei | wei | every EVM chain |
dailySolanaValueCapLamports | lamports | Solana |
Every organization is capped, including one that has never configured anything. An organization that has set no cap of its own, or that clears one, gets the platform default for that chain family rather than unlimited spending. There is no uncapped state: raising the ceiling means setting a higher number, not leaving the field empty. The defaults are 0.02 ETH per day for EVM chains and 0.5 SOL per day for Solana; self-hosted deployments can change them with the EXECUTE_DEFAULT_DAILY_VALUE_CAP_WEI and EXECUTE_DEFAULT_DAILY_SOLANA_VALUE_CAP_LAMPORTS environment variables.
Both caps count value moved by workflow runs as well as by this API, so the two cannot be used to double-spend the same daily budget. Exceeding one returns 403 with Daily spending cap exceeded (or Daily Solana spending cap exceeded).
Call GET /api/analytics/spend-cap before planning a large transfer. Read effectiveDailyCapWei and effectiveDailySolanaCapLamports — those are the figures enforcement uses. A null dailyCapWei means the organization configured nothing, not that spending is unbounded.
Stablecoin transfers
An ERC-20 transfer carries no native value, so the daily caps above cannot see it. A single transaction that moves a recognised stablecoin (any token listed for that chain and flagged as a stablecoin) is limited to 100 USD, applying the 1:1 peg to the token’s own decimals. The limit is per transaction rather than per day, and covers every write path: /api/execute/transfer, /api/execute/contract-call, protocol actions, /api/execute/node, and the equivalent workflow steps. Over the limit nothing is signed or broadcast; the request completes as a failed execution (202 with status: "failed") whose error reads Stablecoin transfer of ... exceeds the 100.0 USD per-transaction limit. Self-hosted deployments can change the figure with EXECUTE_DEFAULT_STABLECOIN_CAP_MICRO_USD (micro-USD, so 100000000 is 100 USD).
On the three endpoints that accept simulate a dry run reports the same refusal: simulating an over-limit transfer returns a failed simulation carrying the limit, rather than a clean estimate for a transfer that would fail at broadcast. Protocol actions and /api/execute/node have no dry run at all, so the limit is only reported there at broadcast.
approve is bounded by the same figure, with one exception. Approving more than the limit is allowed when the spender is a contract belonging to a protocol integration, which is what makes the usual approve-then-swap pattern work. Approving more than the limit to any other address is refused, because an unbounded allowance to an address outside that set is a standing right to move the balance that no later check can see. An approval at or under the limit is always allowed.
Two things this does not do: it does not price non-stablecoin ERC-20s, which are not bounded at all, and it does not cover Solana. SPL token transfers are outside the ceiling, and the daily Solana cap counts native SOL only.
Safe First-Write Sequence
This sequence is for /api/execute/transfer, /api/execute/contract-call and
/api/execute/check-and-execute - the three endpoints that accept a simulate
flag. It does not apply to protocol actions or to /api/execute/node,
neither of which has a dry run. Both refuse the flag: "simulate": true in the
request body, or inside config on /api/execute/node, is rejected with 400
and code unsupported_param before anything is reserved, signed or broadcast.
"simulate": false is accepted, because it asks for the real execution those
endpoints perform.
A first write on those two endpoints has no dry run to put in step 2’s place, so the pre-flight has to happen off this API:
- Read
GET /api/chainsand choose a chain whereisEnabledandisTestnetare bothtrue. - Simulate the call yourself, against your own RPC: an
eth_callfrom the account that will bemsg.senderat the target contract - the organization’s EOA, or the Safe when the organization routes writes through one. - Send once, with an
Idempotency-Key. The key must identify the work rather than the attempt: see Choosing a stable key. - Save the returned
executionIdand pollGET /api/execute/{executionId}/status. Both endpoints return one.unconfirmedis not a failure: do not rotate the key and do not re-send, or a transaction that may still land is sent twice. - Read the effect back off chain. A confirmed receipt says a transaction was included; it does not say the contract’s state is what you intended.
/api/execute/node is named above as a write path the stablecoin limit covers,
but is not otherwise documented on this page.
Use the same request body from simulation through broadcast so the transaction you inspected is the transaction you send:
- Read
GET /api/chainsand choose a chain whereisEnabledandisTestnetare bothtrue. - Send the intended request with
"simulate": true. Continue only when the response hassuccess: trueandwouldRevert: false. - Remove
simulate, add anIdempotency-Keyheader, and send the request once. The key must identify the work rather than the attempt, so that a retry sends the same one: see Choosing a stable key. - Save the returned
executionId, then pollGET /api/execute/{executionId}/status. Honor thepollIntervalHintJSON response body field (orX-Poll-Interval-Hintresponse header) between polls. - Treat the status response’s
receiptsas the authoritative onchain proof: each entry is a receipt re-fetched from the chain, soverifiedandreceiptStatussay what actually happened.transactionHashandtransactionLinkidentify the transaction but are self-reported by the write path.
This sequence catches bad addresses, ABI mistakes, insufficient balances, and reverts before broadcast, while idempotency makes an interrupted client safe to retry. Start with a testnet and testnet funds; simulation does not sign or send a transaction.
Idempotency
Send an Idempotency-Key header to safely retry a request without risking a double-execution. The key is any client-chosen string (for example an agent-side transaction id, ideally a UUID). Every guarantee below depends on the retry sending the same key, so a caller that reconstructs a request rather than replaying a buffered one must derive its key deterministically: see Choosing a stable key.
- Replay: a retry with the same key and the same request body returns the original response (same
executionId, same status) without executing again, plus anidempotentReplaymarker described below. Replay lasts 24 hours from the original request. Past that the stored response is gone and the same key executes again, silently, so a job that repeats on a cadence of a day or longer needs a time bucket in its key: see Choosing a stable key. - Conflict: reusing a key with a different request body returns
409with codeidempotency_conflict,retryable: false, and theoriginalExecutionIdthe key first produced. Use a new key for genuinely different work, not for a retry of the same work whose body was reconstructed: see Choosing a stable key. - In progress: a duplicate that arrives while the first request is still running returns
409with codeidempotency_in_progressandretryable: true; retry shortly with the same key. - Scope: keys are scoped per organization and per endpoint, so the same key is shared across an org’s API keys but does not collide between
/transfer,/contract-call,/check-and-execute, and a workflow webhook. - Window: stored responses are replayable for 24 hours. After that the key is free to reuse.
Recognising a replay
A replayed response is otherwise indistinguishable from a fresh one, which matters most when the stored outcome was a failure: the body carries the original error and nothing else, so a retry loop reads “still reverting” when in fact no transaction was sent. To make the difference visible, a replayed JSON-object body carries an extra top-level field:
{
"success": false,
"error": "Contract call failed: Error(LK: not yet due)",
"idempotentReplay": true
}idempotentReplayis present only on a replay, and is alwaystrue. A fresh response never carries it, so treat its absence as “this outcome just happened”.- It is added to every replayed object body, successes as well as failures. A replayed
202carries it alongside the originalexecutionId. - It is added at read time only. The stored response is never modified, so replaying twice returns the same body both times.
- Bodies that are not JSON objects (arrays, strings,
null) are returned untouched, so a client that already parses those shapes is unaffected. - The marker rides in the body rather than a response header because the common consumer is an agent reading a tool result, where headers are not surfaced.
Conflict and in-progress responses are not replays and never carry the field.
When to reuse a key, and when to rotate it
The two 409 codes mean opposite things, so the status does not separate them.
Both carry retryable, which answers one narrow question: is it safe to send this
request again under the same key? The field is on these two codes only, and every
other status keeps the semantics documented in Errors whether or
not it is present.
{
"error": "A request with this Idempotency-Key is already being processed. Retry the same key shortly; do not rotate it.",
"code": "idempotency_in_progress",
"retryable": true
}Reuse the same key whenever you do not hold a definite outcome for the previous
attempt. A timeout, a dropped connection or a 5xx tells you nothing about whether
the request was received, so a retry must be able to match the original. Reusing the
key is what makes that retry safe: it returns the in-progress guard while the first
request is still running, and the real outcome as a replay once it finishes.
Reuse the same key after a failure the chain was conclusive about. A transaction that reached the chain and reverted at inclusion releases the key: it landed, the chain gave a verdict, and nothing is still in flight. The same key simply executes again, so you no longer have to rotate to recover from that attempt.
The send boundary preserves evidence instead of making the route guess from a
missing RPC reply. Directly signed EVM transactions retain a deterministic hash
once submission may have begun. Every broadcasting core also reports its boundary:
broadcastAttempted: false means it proved the attempt stopped before submission;
true means submission may have started. Missing evidence fails closed and keeps
the key rather than treating a missing hash as proof that nothing was broadcast.
Two consequences worth stating plainly, because they decide what your retry should do:
- A write-core pre-broadcast rejection (for example
staticCallor an in-core balance check) is released. Validation 4xx responses also release, whilesimulate: truereserves no idempotency record at all. - The mixed-chain
/api/execute/transferroute keeps a hashless Solana failure held. Solana send failures report attempted evidence where known, and any missing evidence fails closed; absence of a signature is never treated as proof that nothing reached the network. - A Safe transaction whose outer
execTransactionmined while the inner call reverted is held. The Safe’s nonce and the owner signatures for it were consumed, so “nothing landed” is not true even though the intended work did not happen.
An outcome nobody could read keeps its key. If the receipt was unreadable
the transaction may still land, so that record is held and replays for 24 hours.
The reply carries "status": "unconfirmed" and "idempotentReplay": true. Poll
GET /api/execute/{executionId}/status rather than rotating, because a new key
has no record to match and would broadcast a second transaction for work the
first attempt may still be completing.
Rotate to a new key when the work itself is different, not to escape a failure.
A conflict does not by itself mean rotate. retryable: false says only that
this body is not the body the key was bound to, and there are two reasons for
that. If the work is genuinely different, rotate — that is what a new key is
for. If it is the same intent whose body was re-serialized, the body drifted and
the intent did not: hashRequest normalizes key order but not values, so "0.1"
against "0.10", network in place of chainId, or a reworded memo all produce
a conflict for work that is already under way. Rotating there escapes the
in-progress guard on a request that may already have broadcast. Canonicalize the
body and keep the key.
Rotating a key while a request may still be in flight is the case to avoid. It escapes the in-progress guard, and for a fund-moving call the second request can broadcast a transaction for an action the first one is already completing.
Requests without an Idempotency-Key behave normally. Read-only and dry-run (simulate: true) requests are not affected.
curl -X POST https://app.keeperhub.com/api/execute/transfer \
-H "Authorization: Bearer kh_..." \
-H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
-H "Content-Type: application/json" \
-d '{ "chainId": "8453", "recipientAddress": "0x...", "amount": "0.1" }'Workflow webhooks (POST /api/workflows/{workflowId}/webhook) accept the same header, scoped per workflow.
Choosing a stable key
A UUID generated per attempt does not survive a retry: the second attempt generates a different UUID, so the request is treated as new and executes again. A UUID works only when it is persisted before the first attempt and recovered afterwards.
A caller that cannot persist a key must derive one that is reproducible from the work itself. Derive it from a canonical form of the caller’s own stable identifier for the piece of work, joined with the fields that determine the onchain effect:
taskId|chainId|recipientAddress|amount|tokenAddressThe separator is a single ASCII vertical bar, U+007C, with no surrounding whitespace.
taskId is whatever the caller already uses to name the work: an invoice number, a
payroll period, a job id. It must be stable across a retry of the same work and
different for different work.
Work that repeats on a schedule needs the period in the taskId, not just the job name.
A daily job keyed on nightly-sweep alone derives the same key on every run, and because
the replay window is 24 hours it lands near the boundary each time: sometimes inside the
window, where the run is swallowed as a replay, sometimes outside it, where the run
executes. Including the period, as in nightly-sweep-2026-08-06, makes each run distinct
work with a full 24 hours of retry protection of its own.
Canonicalize each part before joining:
-
taskId: trim surrounding whitespace, and percent-encode any%as%25and any|as%7C. Without this ataskIdof8453|0xabcon chain1joins to the same string as a different intent on chain8453. Do not case-fold it; task identifiers are opaque to this endpoint. -
Resolve the chain to one spelling. These endpoints accept
chainIdand also the deprecatednetworkalias, so{"network": "base"}and{"chainId": 8453}are the same transfer. Resolve the alias to a numeric chain id first, then use its decimal integer form with no leading zeros, so8453,"8453"and"base"all agree. -
Lowercase addresses, so a checksummed and an unchecksummed address agree.
-
Canonicalize
amountas a decimal string, not a binary float, under all of the following rules, so that two conforming implementations cannot disagree:- trim surrounding whitespace, and reject a leading
+or- - use no exponent notation
- require at least one digit before the decimal point, so
.5becomes0.5 - strip leading zeros, except the single
0before a decimal point, so01.5becomes1.5and007becomes7 - strip trailing zeros after the decimal point, then strip a trailing decimal point,
so
0.0010becomes0.001and1.000becomes1 - if the rules above leave an empty string, use
0, so0,0.0and0.000all agree regardless of the order the rules are applied in
Specifying the string form rather than a numeric type is deliberate: a caller parsing
"0.1"as a 64-bit float gets0.100000000000000006, and binary floats also collapse distinct 18-decimal amounts onto the same value. - trim surrounding whitespace, and reject a leading
-
Represent omitted optional fields as an empty string, so the separator positions stay fixed.
Hash the joined string’s UTF-8 bytes with SHA-256 and send the digest as lowercase hex
in the Idempotency-Key header.
A stable key does not by itself produce a replay
Deriving a stable key is necessary but not sufficient, and it is worth being precise about what it buys, because the difference decides how a caller should handle the response.
The stored record is keyed on (organization, scope, key), but the request body is
hashed too, and only a value-equal body replays. The body is hashed after it is parsed,
so formatting is normalized — whitespace, key order, and the spelling of JSON numbers
all stop mattering, and {"chainId": 8453} and {"chainId": 8.453e3} are the same body.
What is not normalized is the value itself, so anything carried as a string keeps its
exact spelling. {"network": "base"} and {"chainId": 8453} are different bodies, as are
the strings "0.001" and "0.0010", and so is a reason, memo or note field that
the caller reworded between attempts.
So a retry that reuses a stable key with a reconstructed, value-different body returns
409 idempotency_conflict, not a replay. That is the outcome to design for, and it
is the safe one: the fail-closed 409 is precisely what stops the reconstructed retry
from executing a second time. A caller that expects a replay will read it as a bug in
its key derivation and reach for a fresh key, which is the one response that does cause
a double-execution.
Handle it as an answer rather than an error. When the 409 body carries a non-null
originalExecutionId, poll GET /api/execute/{executionId}/status with it to learn the
outcome of the work you were retrying.
originalExecutionId is nullable, and it is null in the two cases you are most likely to
hit here: the first attempt reached the broadcast path and failed, and the first attempt
is still in flight. Neither is a reason to rotate the key. Instead, canonicalize the body
with the rules above so it matches the original and re-send under the same key. A record
that has settled — whether it succeeded or failed — replays its stored response, so that
re-send returns the original outcome rather than executing again; a record still in
flight returns 409 idempotency_in_progress, which is the retryable code, so back off
and re-send.
To get an actual replay instead, the retry must reproduce every value in the body, though not its formatting. Canonicalize the body with the same rules used for the key, and omit free-text fields whose wording is not reproducible, rather than regenerating them.
A stable key makes a repeated submission of the same work safe. It does not help with three other cases:
- the caller submits genuinely different work, which needs a different key rather than deduplication
- the state that justified the request has changed by the time the transaction lands, which needs a check before submission
- the same work is legitimately repeated but the key cannot tell it apart from a retry
The last case is why taskId belongs in the key by default. Omit it only when
repeating the transfer would genuinely be a mistake. Hashing the effect fields alone
makes every identical transfer the same request, so an agent that legitimately pays the
same recipient the same amount twice inside the 24 hour window gets the second call
answered from the first one’s cached response: the original executionId,
status: completed, and no second transfer. That outcome is flagged only by
idempotentReplay: true in the body, which is easy to miss if the caller does not check
that field, so the second payment can go missing while the response reads as success.
Sponsored Executions
Writes may be gas-sponsored and broadcast through a relayer or smart-account
(EIP-7702) path instead of your org’s EOA wallet. A sponsored execution does
not change your EOA’s nonce or native balance, and it will not appear in a
block explorer’s txlist for that address — checks against the EOA will
conclude nothing happened even though the transaction succeeded. Check the
sponsored field on the status response and treat transactionHash /
transactionLink as the authoritative proof, not EOA-level state.
Who acted
On a sponsored execution the transaction’s from is the sponsor’s fee payer,
not your wallet, and its to is the sponsor’s entry contract, not the target.
Your wallet acts inside that transaction. result.executedCall.from names it:
the sender of the traced call frame that actually hit the target contract.
A sponsored approve on Base Sepolia, as returned by
GET /api/execute/{executionId}/status and trimmed to the relevant fields:
{
"executionId": "4k4qm0kkjrkfc095jubo9",
"status": "completed",
"sponsored": true,
"result": {
"sponsored": true,
"executedCall": {
"contractAddress": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
"from": "0x742d35cc6634c0532925a3b844bc454e4438f44e",
"functionName": "approve",
"functionSignature": "approve(address,uint256)",
"args": { "spender": "0xd36E12a5b2926A5cbE6B4DE42a0D60Fd35d3cb04", "amount": "1" },
"sponsored": true,
"topLevelTo": "0x5af5194b4b0909eb978e3cf1e25333852277f07d",
"reverted": false
}
}
}Here the transaction was sent by the fee payer to topLevelTo, the sponsor’s
entry contract, which called the organization’s wallet, which called approve
on the token: from is that wallet, 0x742d..., not the fee payer and not
topLevelTo. The capture is a real Base Sepolia execution with the
organization’s wallet replaced by the placeholder used elsewhere on this page.
args keys are the parameter names of the ABI the call was decoded with: the
ABI supplied here named the second parameter amount; the platform’s own
ERC-20 ABI names it value.
from is the sender of the first frame, in execution order, that called the
target with the decoded function; the trace is walked depth-first, so that is
the call that ran first, not the shallowest one. When your organization calls
the target directly, which is every case above, that frame is the direct call,
and the field is the acting address under every routing mode: your
organization’s wallet on a direct send, that same wallet when a sponsor paid the
gas, and the Safe on a Safe-routed organization, where the wallet signs the
outer transaction but msg.sender at the target is the Safe. If a contract the
transaction calls reaches the target before your direct call does, the earlier
frame is the one reported and from is that contract.
One precision: from is the sender of the trace frame that hit the target,
which is not always the Solidity-level msg.sender. When the target is called
directly, which is every case above, the two are the same. If the address you
passed as the target is an implementation reached by DELEGATECALL, from is
the contract that issued the DELEGATECALL (the proxy), because that is what
the trace frame records. The msg.sender seen inside the delegated code is a
different value: it is inherited unchanged from the proxy’s own caller, and is
not what this field reports.
executedCall is best-effort. It is omitted entirely when the transaction
cannot be traced (an RPC without debug_traceTransaction, or no call frame
matching the target), and from alone is omitted when the trace records no
sender for the matched frame, so read both defensively rather than assuming
they are there.
Transfer Funds
POST /api/execute/transferTransfer native tokens (ETH, MATIC, etc.) or ERC-20 tokens directly.
Request Body
{
"chainId": 11155111,
"recipientAddress": "0x742d35Cc6634C0532925a3b844Bc454e4438f44e",
"amount": "0.1",
"tokenAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238",
"gasLimitMultiplier": "1.2"
}Recipient validation
recipientAddress is validated with a strict EIP-55 checksum before the
request is accepted. Pass either:
- the exact checksummed form (mixed-case), or
- an all-lowercase address (e.g.
0x742d35cc6634c0532925a3b844bc454e4438f44e).
A mixed-case address whose checksum does not match is rejected with
Invalid recipient address: <address> — even if the lowercase hex is correct.
Widely-copied example addresses often carry a mangled checksum or the wrong
number of hex digits, so prefer copying from the address book or from a tool
that computes EIP-55 rather than retyping. Add frequently-used recipients to the
address book first; address book entries are
stored lowercase and displayed in checksummed form.
Parameters:
chainId(required): Numeric chain ID as a number or numeric string (for example,11155111for Ethereum Sepolia or8453for Base). The legacynetworkfield still accepts known chain names but is deprecated.recipientAddress(required): Destination wallet addressamount(required): Amount in human-readable units (e.g., “0.1” for 0.1 ETH or tokens)tokenAddress(optional): ERC-20 token contract address. Omit for native token transfers.tokenConfig(optional): JSON string with token metadata for non-standard tokens:{"decimals":18,"symbol":"USDC"}gasLimitMultiplier(optional): Gas limit multiplier (e.g., “1.5” for 50% buffer)
Response
Successful broadcast requests return HTTP 202 Accepted:
{
"executionId": "n3364uzl2s6aram5v558c",
"status": "completed",
"transactionHash": "0x...",
"transactionLink": "https://etherscan.io/tx/0x..."
}The execution runs synchronously. Status will be completed, failed or
unconfirmed when the request returns. transactionHash and transactionLink
are present only when the transfer step reported success, so a failed or
unconfirmed response carries neither - including when a transaction was
broadcast and its receipt could not be confirmed. Retrieve the hash for those
from GET /api/execute/{executionId}/status, which reads the stored execution
rather than the step result.
Call Smart Contract
POST /api/execute/contract-callCall any smart contract function. Automatically detects read vs write operations.
Request Body
{
"contractAddress": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
"chainId": 1,
"functionName": "balanceOf",
"functionArgs": "[\"0x742d35Cc6634C0532925a3b844Bc454e4438f44e\"]",
"abi": "[{...}]",
"value": "0.1",
"gasLimitMultiplier": "1.2"
}Parameters:
contractAddress(required): Smart contract addresschainId(required): Numeric chain ID as a number or numeric string. The legacynetworkfield still accepts known chain names but is deprecated.functionName(required): Name of the function to call. The workflow web3 action node config calls this same valueabiFunction; this route acceptsabiFunctionas an alias so payloads copied between the two layers bind without a rename. If both keys are present their values must agree once surrounding whitespace is trimmed; a mismatch - including an empty or non-stringfunctionNamenext to a differentabiFunction- is rejected with a 400 naming both values.functionArgs(optional): JSON array string of function arguments (e.g.,"[\"0x...\", \"1000\"]")abi(optional): Contract ABI as JSON string. Auto-fetched from block explorer if omitted.errorAbis(optional): JSON array of ABI documents whoseerrorentries join revert decoding, after the ABI above. Decoding only:abistill encodes the call, so the extra documents cannot change the calldata. Use it when the revert is raised somewhere other than the call target, such as a hook the target calls or an implementation behind a proxy. At most 4 documents, 16 KB each; a document declaring no error the decoder can build is rejected with a 400 rather than accepted and ignored.value(optional): Native value to send with the call, as a decimal string in ether units (e.g.0.1) (for payable functions)gasLimitMultiplier(optional): Gas limit multiplier
Raw calldata
Callers that already hold encoded calldata (execution frameworks, transaction
builders, replayed transactions) may send data instead of functionName and
functionArgs:
{
"contractAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"chainId": 8453,
"data": "0x095ea7b3000000000000000000000000c1256ae5ff1cf2719d4937adb3bbccab2e00a2ca00000000000000000000000000000000000000000000000000000000004c4b40",
"abi": "[{...}]",
"simulate": true
}The route decodes data against the ABI, either the abi in the body or the
explorer-verified ABI it fetches when abi is omitted, into the canonical
function key (approve(address,uint256)) and a typed functionArgs array, and
then continues exactly as a typed request: read functions return their result,
simulate dry-runs, writes reserve against the spending caps and the
stablecoin limit, and the execution record carries the decoded function and
arguments rather than opaque bytes.
Nothing is inferred. A selector the ABI does not contain is rejected with
400 on field data; supply the contract’s ABI or the typed fields instead.
No signature database is consulted, so a guessed signature can never reach the
signing path. data must be 0x-prefixed hex of whole bytes carrying at least
the 4-byte selector; plain value transfers use Transfer Funds.
The decode must be lossless. The transaction that is broadcast is rebuilt from
the decoded function and arguments, not from the bytes you sent, so calldata is
re-encoded and compared against data, and anything that does not survive the
round trip - trailing bytes past the arguments (an ERC-2771 appended sender,
for example), non-minimal offsets, non-canonical padding - is rejected with
400 on field data rather than dropped silently.
Send data or the typed fields, not both. A body carrying data alongside
functionName (or abiFunction) describes the same call twice and is rejected
with 400 on field data, for the same reason a differing functionName and
abiFunction pair is. check-and-execute does not accept data.
Direct execution vs. workflow node field names
The same values carry different field names depending on which surface you’re building against. This route accepts either spelling; workflow node config accepts only the single spelling in the right-hand column, which is not the canonical one in either row.
| Meaning | POST /api/execute/contract-call | Workflow web3 action node config |
|---|---|---|
| Chain to execute on | chainId (canonical), network (deprecated alias) | network |
| Function to call | functionName (canonical), abiFunction (alias) | abiFunction |
The abiFunction alias is specific to this route. check-and-execute still
requires functionName (and action.functionName), so a body that carries
only abiFunction is rejected there with a 400.
Response
Read Function (view/pure):
{
"result": "1500000000000000000"
}Read functions return immediately with the result value.
Write Function:
{
"executionId": "n3364uzl2s6aram5v558c",
"status": "completed",
"transactionHash": "0x...",
"transactionLink": "https://etherscan.io/tx/0x..."
}Write functions execute synchronously. status is completed, failed or
unconfirmed by the time the request returns.
transactionHash is present whenever a transaction reached the chain, which
includes failed and unconfirmed. A call that reverts still produced a
transaction, and the hash is how you find out what the chain said about it. It
is absent only when the call never broadcast - a guard, a validation error, or
a failure before submission.
transactionLink accompanies the hash whenever the write produced an explorer
URL, including a reverted or unreadable broadcast.
Protocol Actions
POST /api/execute/{protocol}/{action-slug} <!-- api-docs-ignore -->Execute a registered protocol action (for example POST /api/execute/aave-v3/supply).
Use search_protocol_actions via MCP or the protocol registry to discover
available actions and their parameters.
Request Body
Pass action parameters as a JSON object. chainId is required for every action
(the legacy network field is accepted as a deprecated alias). Required fields
for each action are defined in the protocol registry.
Protocol actions have no dry run. A simulate field is rejected with 400 and
code unsupported_param before anything is reserved or signed, except
"simulate": false. To check a write before sending it, see
Safe First-Write Sequence.
Response
Read actions return the plugin result directly with HTTP 200.
Write actions return HTTP 202 Accepted with this endpoint’s envelope
(executionId, status, and the optional fields below). status is one of
completed, failed, or unconfirmed. Unlike Call Smart
Contract writes, protocol writes may include rejection
and errorClass on a failed write, and they include transactionLink whenever
the write step produced one (including on revert). Call Smart Contract writes
omit rejection/errorClass; they still include transactionLink whenever
the write step produced one (including on revert).
{
"executionId": "n3364uzl2s6aram5v558c",
"status": "failed",
"transactionHash": "0x...",
"transactionLink": "https://etherscan.io/tx/0x...",
"error": "execution reverted",
"errorClass": "external",
"rejection": {
"kind": "string-revert",
"reason": "execution reverted"
}
}executionId and status are always present. transactionHash and
transactionLink are included whenever the write broadcast a transaction,
including on failed and unconfirmed, so a reverted or still-pending call
stays look-up-able in the explorer.
error is present only when status is failed. rejection and errorClass
are optional and appear only on failed writes when the step could classify the
revert.
unconfirmed is non-terminal and poll-only: the transaction was broadcast but
the chain has not confirmed it yet. Do not treat the body as a failure. Do not
rotate Idempotency-Key or re-submit; the transaction may still land and a
second send moves funds twice. Poll GET /api/execute/{executionId}/status
until completed or failed for receipts and the persisted result.
Check and Execute
POST /api/execute/check-and-executeRead a contract value, evaluate a condition, and conditionally execute a write operation.
Request Body
{
"contractAddress": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
"chainId": 1,
"functionName": "balanceOf",
"functionArgs": "[\"0x742d35Cc6634C0532925a3b844Bc454e4438f44e\"]",
"abi": "[{...}]",
"errorAbis": ["[{...}]"],
"condition": {
"operator": "gt",
"value": "1000000000000000000"
},
"action": {
"contractAddress": "0x...",
"functionName": "transfer",
"functionArgs": "[\"0x...\", \"500000000000000000\"]",
"abi": "[{...}]",
"gasLimitMultiplier": "1.2"
}
}errorAbis (optional) is the same field the contract-call route accepts, and it
sits at the top level rather than inside action so one list covers both calls
this route makes: the check read and the action write. Decoding only, for both.
Condition Operators:
eq: Equal toneq: Not equal togt: Greater thanlt: Less thangte: Greater than or equal tolte: Less than or equal to
The check function must resolve to exactly one supported scalar output.
Solidity integers support all six operators. address and bytes1 through
bytes32 support eq and neq only. condition.value must be a
BigInt-compatible decimal or hexadecimal string. KeeperHub rejects empty,
multi-output, compound, or otherwise unsupported ABI return shapes with HTTP
400 before the check RPC call. It also rejects an operator that the output
type does not support, or a runtime result that cannot be compared, before the
action executes. The action leg never forwards native value; action.value is
not part of the supported request shape.
Response
Condition Not Met:
{
"executed": false,
"conditionResult": {
"met": false,
"observedValue": "500000000000000000",
"targetValue": "1000000000000000000",
"operator": "gt"
}
}Condition Met and Action Executed:
{
"executed": true,
"executionId": "n3364uzl2s6aram5v558c",
"status": "completed",
"conditionResult": {
"met": true,
"observedValue": "1500000000000000000",
"targetValue": "1000000000000000000",
"operator": "gt"
}
}The request field is condition and the response field is conditionResult, on
both the broadcast and the simulate: true paths. A parser written once against
this endpoint works for both.
Gas Top-Up
POST /api/execute/gas-top-upConvert USDC in the organization’s Turnkey wallet into native ETH on that same wallet. Gas sponsorship pays network fees only; it does not give the wallet spendable ETH. Use this route when the wallet holds USDC but needs native balance, for example for ETH value transfers, withdrawals, or writes that fall back to self-paid gas.
This is not the pay-as-you-go billing top-up. Sending USDC to the organization wallet for billing pays for executions and KeeperHub covers gas; this route converts the wallet’s own USDC into ETH that stays in the wallet.
The route sends three gas-sponsored transactions from the organization’s Turnkey EOA:
approveexactlyamountUsdcto Uniswap V3 SwapRouter02 (never unlimited).exactInputSingleUSDC to WETH, with the wallet as recipient. A Uniswap QuoterV2quoteExactInputSinglecall is taken before anything is sent, and a zero or failed quote refuses the request. Once the approve confirms, the quote is taken again, and the minimum output is that fresh quote less 0.5%, set server-side. Each quote is also checked against the chain’s Chainlink ETH/USD price, which the pool cannot move: a quote more than 2% worse than that price, or no fresh Chainlink price at all, refuses the request (testnets skip this check). The swap goes through the router’smulticallwith a deadline 3 minutes after that fresh quote, so a swap left in the mempool past that reverts instead of filling at a stale price. If the second quote fails or fails the Chainlink check, the swap is not sent and no USDC is spent. Whenever the swap does not happen after the approve confirmed (not sent, declined, or reverted), the route sendsapproveof0to the router so no allowance is left behind.withdrawon WETH for the amount the swap delivered, read from the swap’s own receipt, which pays native ETH to the wallet. WETH the wallet already held is left alone.
Requires mcp:write. There is no dry-run mode: simulate is refused.
Request Body
{
"chainId": 8453,
"amountUsdc": "5"
}| Field | Type | Description |
|---|---|---|
chainId | number or numeric string | 1 (Ethereum), 8453 (Base), 42161 (Arbitrum), or 11155111 (Sepolia) |
amountUsdc | string | USDC to convert, positive, at most 6 decimal places |
Any other field is rejected with 400. There is no recipient parameter (the
ETH always lands in the wallet that paid the USDC) and no slippage parameter.
Limits and refusals
Checked before an execution is reserved or anything is sent:
| Status | When |
|---|---|
400 | Unsupported chain, invalid amount, unknown field, or simulate present |
403 | Organization circuit breaker engaged |
403 | amountUsdc above the per-call stablecoin cap (100 USD by default) |
403 | amountUsdc would take the organization past its daily gas top-up limit (200 USD per UTC day by default). The error gives the amount used today, the amount requested, and the limit |
422 | Gas sponsorship unavailable: not enabled on the chain, credits exhausted, or no Turnkey wallet. The route never falls back to self-paid gas |
422 | WALLET_NOT_CONFIGURED, or the chain’s canonical USDC is not a supported stablecoin |
An insufficient USDC balance, a failed quote, or a first quote that fails the
Chainlink price check (or finds no fresh Chainlink price) is reported after the
execution is reserved, as 202 with status: "failed" and no transaction sent.
The daily limit counts a finished top-up, whatever its status, when its swap
landed (its USDC is spent), and a completed or unconfirmed top-up whose swap was
broadcast and may still land. A top-up that stopped before its swap, or whose
swap reverted, does not count. Top-ups still in flight count too, for the rest
of the UTC day however long they take, so a slow or
interrupted run never frees its share early. It is checked twice: once before
the wallet and sponsorship are resolved, so a request over a spent budget is
refused without that work, and again atomically with the reservation, so
concurrent requests cannot both fit under the last of the day’s allowance. A
retry with the same Idempotency-Key replays the stored result even once the
limit is reached. Self-hosted deployments can change it with
EXECUTE_DEFAULT_DAILY_GAS_TOP_UP_CAP_MICRO_USD (micro-USD, so 200000000 is
200 USD).
Response
{
"executionId": "k72596auc9fwidsvm8jxu",
"status": "completed",
"chainId": 8453,
"wallet": "0x742d35cc6634c0532925a3b844bc454e4438f44e",
"transactionHash": "0x...",
"transactionLink": "https://basescan.org/tx/0x...",
"steps": [
{ "name": "approve", "status": "confirmed", "transactionHash": "0x..." },
{ "name": "swap", "status": "confirmed", "transactionHash": "0x..." },
{ "name": "unwrap", "status": "confirmed", "transactionHash": "0x..." }
],
"usdcSpent": "5",
"wethReceived": "0.0019",
"ethReceived": "0.0019",
"quotedWethOut": "1909500000000000",
"amountOutMinimum": "1899952500000000",
"sponsored": true
}transactionHash is the unwrap transaction on success. quotedWethOut and
amountOutMinimum are in wei.
If the swap’s receipt still cannot be read after a few seconds of retries, the
route unwraps amountOutMinimum,
which the swap is guaranteed to have delivered, and the response carries a
warning: any WETH above that minimum stays in the wallet. Unwrap it with the
wrapped/unwrap protocol action.
Partial completion
The three transactions are separate, so a run can stop after one or two. The
response says which landed: each entry in steps is confirmed, failed, or
skipped. error explains what state the wallet is left in:
- Approve failed: no USDC was spent.
- Swap failed: no USDC was spent, and the route sets the approval back to
zero.
approvalRevokedistrueonce that confirmed, withrevokeTransactionHashandrevokeTransactionLink. It isfalsewhen the reset did not complete, in which case an approval for exactlyamountUsdcremains; revoke it with anapproveof0to the router. A swap that is still unconfirmed is never revoked, since it needs the approval to land. - Swap broadcast but unconfirmed: the USDC may or may not have been spent.
usdcSpentis omitted andswapPendingistrue. - Unwrap failed: the USDC is spent and the WETH is left unwrapped in the
wallet.
usdcSpentandwethReceivedare set,ethReceivedis not.
Once the swap has confirmed, a retry with the same Idempotency-Key replays the
stored result rather than swapping again. Do not call again with a new key to
finish a partial run: the USDC is already spent. Unwrap the remaining WETH with
the wrapped/unwrap protocol action instead.
Dry-Run Simulation
All three execute endpoints (/api/execute/transfer, /api/execute/contract-call, /api/execute/check-and-execute) accept a simulate flag on the body. When set to boolean true, the endpoint validates inputs, resolves the org’s from-address, encodes the call, and runs provider.estimateGas + provider.call against the chain — without signing or broadcasting a transaction.
No row is inserted into the execution audit table, no funds are reserved against the spending cap, and no transaction hash is produced. Use it to pre-flight a transaction (catch reverts, allowance mismatches, balance shortfalls, ABI mistakes) before spending gas.
A deterministic failed simulation answers with HTTP 400. Do not classify every such body as an EVM
revert: read a string code first, then failureKind, then wouldRevert. A code is an
attributed preflight failure such as insufficient_balance or insufficient_allowance; failureKind: "revert"
with wouldRevert: true is a confirmed call revert; an uncoded
failureKind: "validation" is not. Route-level parameter errors may carry none of these
fields. This ordering keeps a generic “non-2xx means the request is malformed” wrapper
from discarding actionable chain-state diagnostics without mislabelling input errors as
reverts. A simulator infrastructure failure uses failureKind: "unavailable",
wouldRevert: false, and HTTP 503 instead.
Request
Add "simulate": true to any of the standard request bodies:
{
"contractAddress": "0x...",
"chainId": 1,
"functionName": "transfer",
"functionArgs": "[\"0x...\", \"1000000\"]",
"abi": "[{...}]",
"simulate": true
}simulate must be a strict boolean — true or false. Strings ("true"), numbers (1), and other non-boolean values are rejected with HTTP 400 to prevent silent fall-through to a real broadcast. The top-level body field is the only way to request a dry run. There is no query-string form: a simulate query parameter, in any letter case, is rejected with 400 and code unsupported_param on every /api/execute/* endpoint. The one exception is ?simulate=false, which asks for nothing the endpoint would not already do.
Because a dry run never signs or broadcasts, a credential scoped mcp:read may run one. Removing simulate to broadcast requires mcp:write.
A sequence of calls
A single dry run resolves against latest state, so the second call of an
approve-then-deposit pair reverts on allowance every time: the approve has not
landed. Send calls instead of the single top-level call to dry-run an ordered
sequence, each call against the state the one before it produced:
{
"chainId": 84532,
"simulate": true,
"calls": [
{
"contractAddress": "0x036cbd53842c5426634e7929541ec2318f3dcf7e",
"functionName": "approve",
"functionArgs": "[\"0xd36e12a5b2926a5cbe6b4de42a0d60fd35d3cb04\", \"1000\"]"
},
{
"contractAddress": "0xd36e12a5b2926a5cbe6b4de42a0d60fd35d3cb04",
"functionName": "deposit",
"functionArgs": "[\"1000\", \"0x...orgWallet\"]"
}
]
}Each call takes its own abi, falling back to a top-level abi and then to the
explorer-verified ABI, so a sequence spanning two contracts needs no extra
round trip from you. value is accepted per call. At most 10 calls.
calls is a dry-run shape only. This endpoint broadcasts one transaction
per request, so calls without simulate: true is rejected with 400. The
response says the same thing in atomic: false: the entries describe N separate
transactions sent from the wallet in that order, and on the real chain nothing
stops another transaction landing between them.
The response carries one result per call, in order, each the same shape a single-call dry run returns:
{
"success": false,
"status": "simulated",
"from": "0x...orgWallet",
"atomic": false,
"mechanism": "eth_simulateV1",
"wouldRevert": true,
"results": [
{ "success": true, "status": "simulated", "gasEstimate": "55425", "wouldRevert": false },
{
"success": false,
"status": "simulated",
"failureKind": "revert",
"wouldRevert": true,
"revertReason": "ERC4626: deposit more than max"
}
]
}success is true only when every call answered cleanly. The status code follows
the worst call: 503 if the node could not answer one, 400 if one would
revert or did not validate, 200 otherwise.
mechanism names how the answer was produced. eth_simulateV1 carries state
across calls in one request and is used wherever the chain’s node offers it.
Nodes without it fall back to state-overrides, which replays each call’s
debug_traceCall state diff as an eth_call override for the next one — the
same answer, one round trip per call instead of one for the sequence. If a node
offers neither, the calls after the first report failureKind: "unavailable"
rather than quietly answering against latest state.
The per-transaction stablecoin ceiling is applied to each call, exactly as it is on the single-call path: these are separate transactions at broadcast, so a call over the ceiling would fail at send and must not dry-run clean.
Response — successful simulate
{
"success": true,
"status": "simulated",
"from": "0x...orgWallet",
"to": "0x...target",
"value": "1000000000000000000",
"gasEstimate": "65000",
"simulatedReturnValue": true,
"wouldRevert": false
}from: the org’s wallet address used as the sender (see “Known limitation” below)to: the low-level call target. For an ERC-20 transfer this is the token contract, not the transfer recipientvalue: native value in wei sent with the callgasEstimate: estimated gas units required by the call, as a decimal stringsimulatedReturnValue: the decoded return value of the call (e.g.truefor ERC-20transfer, the read value for view functions,nullfor native transfers to an EOA recipient). A non-standard token that returns no data ontransfer(the USDT pattern) also simulates successfully withsimulatedReturnValue: null— the simulator treats an empty return the same as a native transfer to an EOA, not as a failure.wouldRevert: alwaysfalseon this path
Response — would-revert
When the chain would have rejected the transaction, the endpoint returns HTTP 400 with the decoded reason:
{
"success": false,
"status": "simulated",
"from": "0x...orgWallet",
"to": "0x...target",
"value": "0",
"failureKind": "revert",
"wouldRevert": true,
"revertReason": "Error(ERC20: transfer amount exceeds balance)",
"error": "Error(ERC20: transfer amount exceeds balance)"
}failureKind:"revert"confirms that the call produced a revert rather than an input or preflight failurewouldRevert:trueon this failure path; use it together withfailureKind, not as a revert discriminator by itself
Revert decoding tries (in order): custom errors in the ABI the request supplied, custom errors in any of its errorAbis documents, common OpenZeppelin / standard errors, then the standard Error(string) revert (which is surfaced as Error(<message>)). If none match, the failure is either attributed to a funding shortfall (see below) or the raw RPC error message is surfaced. A revert raised in a contract other than the call target - a hook, a proxy implementation, a router - is only decodable through errorAbis, because the ABI that encodes the call is the target’s own.
Response — underfunded sender
A node asked to estimate gas for a transfer the sender cannot pay for rejects it without revert data, and the resulting CALL_EXCEPTION names neither the balance nor the address. When the simulator can confirm that is what happened, the failure carries a machine-readable code and the numbers a caller needs to fix it:
{
"success": false,
"status": "simulated",
"from": "0x...orgWallet",
"to": "0x...recipient",
"value": "1000000000000000000",
"failureKind": "validation",
"wouldRevert": true,
"revertReason": "Insufficient ETH balance. Have: 0.25, Need: 1.0. Fund 0x...orgWallet with at least 0.75 ETH on this chain and retry.",
"error": "Insufficient ETH balance. Have: 0.25, Need: 1.0. Fund 0x...orgWallet with at least 0.75 ETH on this chain and retry.",
"code": "insufficient_balance",
"balanceWei": "250000000000000000",
"requiredWei": "1000000000000000000",
"shortfallWei": "750000000000000000",
"nativeSymbol": "ETH",
"originalError": "missing revert data (action=\"estimateGas\", ...)"
}failureKind:"validation"here means no EVM revert was decoded. It does not mean the request data is malformed; inspectcodebefore interpreting this discriminatorcode: machine-readable cause — branch on this rather than string-matchingrevertReason. Absent when the simulator has no more specific machine-readable cause. The set is closed:insufficient_balance: the funding wallet cannot cover the native value the call sendsinsufficient_allowance: current ERC-20 allowance is less than needed. When the token reverts withERC20InsufficientAllowance, the response also carriesallowance,neededAllowanceandspenderinsufficient_token_balance: sender ERC-20 token balance is less than the transfer amountcontract_paused: target contract is paused (EnforcedPause,Pausable: paused)contract_not_paused: operation requires target contract to be paused, but it is currently unpaused (ExpectedPause)caller_not_authorized: the simulated sender is not the owner or lacks the required role (OwnableUnauthorizedAccount,AccessControlUnauthorizedAccount). This is an on-chain revert, distinct from the API auth codeunauthorizedreentrancy_blocked: reentrancy guard triggeredsafe_signature_invalid,safe_insufficient_gas,safe_not_authorized: Safe execution failuresrole_condition_violation: Zodiac Roles modifier condition failedpanic: the contract hit a SolidityPanic(uint256).panicCodecarries the exact panic number as a hex string (for example"0x11"for arithmetic overflow), andrevertReasoncarries the readable name
remediation: set alongside a decoded-revertcode. A plain-English diagnosis of what is wrong, with the numbers and addresses behind it. It may point at request inputs you control (amount, arguments, gas limit, signer mode); it never tells you to make an on-chain callbalanceWei/requiredWei/shortfallWei: the sender’s native balance, the native value the call would move, and the difference, all in weinativeSymbol: the chain’s native currency symbol (ETH,BNB,POL); falls back tonativeif the chain is not seededoriginalError: the node’s own message, kept verbatim. Attribution only ever adds — nothing the chain said is discardedundecodedRevertData: present only when the node did return revert data that no ABI on the decode path matched. The first four bytes are the custom-error selector, which you can look up in a selector database. When this field is set, funding the wallet may not be enough on its own — the contract is also rejecting the call- When
undecodedRevertDatais set, the selector alone is not the whole answer: a selector database names it but cannot give its arguments, and the arguments are where a reason code or a job id lives. Supply the ABI of the contract that raised the revert in the request’serrorAbisfield (see Call Smart Contract) and the revert decodes with its arguments instead of staying hex
The comparison is against the transfer value only; gas is not included (the gas estimate is what failed, so there is no number to add). A wallet funded with exactly the transfer amount therefore still fails, carrying the node’s own insufficient funds for gas * price + value message and no code.
Safe-routed organizations: the balance is read from from, which is the org’s EOA. If your organization routes writes through a Safe, the transfer is funded from the Safe instead, so these fields describe the wrong address — see Known limitation below.
Token-transfer specifics
For ERC-20 transfers, decimals is optional — when omitted, the simulator looks up the token’s decimals() on-chain. tokenConfig is resolved through the same helper the broadcast path uses, so customToken, supportedTokenId, and legacy tokenConfig shapes all work identically.
check-and-execute specifics
simulate: true still evaluates the condition (which is read-only) and only swaps the action’s write for a simulated call. The flag goes at the top level of the body, not inside action: action.simulate is rejected with 400 (code unsupported_param, field action.simulate) rather than ignored, except "simulate": false. The response wraps the simulate body in the existing { executed, conditionResult } envelope:
{
"success": true,
"status": "simulated",
"from": "0x...",
"to": "0x...",
"gasEstimate": "65000",
"simulatedReturnValue": true,
"wouldRevert": false,
"executed": true,
"conditionResult": { "met": true, "...": "..." }
}executed reflects whether the action would have successfully run, so a reverted simulate returns executed: false.
When no action is simulated
A dry run reaches the action only when the condition is met and the action is a
write. Two outcomes stop earlier, and both answer 200 with success: true and
status: "simulated", so the run is never mistaken for a failure:
{
"success": true,
"status": "simulated",
"executed": false,
"conditionResult": { "met": false, "...": "..." }
}A read-only action answers the same way with executed: true and the result
of the read.
Neither carries wouldRevert. That field is a statement about a specific call
that was encoded and estimated, and on these paths no such call was made, so
there is nothing to report. Read wouldRevert only when it is present, and use
success to decide whether the dry run itself completed.
Broadcast responses are unaffected: without simulate: true these two outcomes
return { executed, conditionResult } exactly as before.
Known limitation
The from address used during simulation is the org’s wallet (getOrganizationWalletAddress). Organizations that route writes through a Safe will see a simulation that reflects the EOA sending the call, not the Safe. Most config-bug categories (bad ABI, bad args, allowance mismatches) still surface; Safe-routed msg.sender semantics do not.
This also applies to the underfunded-sender response above. The balance is read from from, but a Safe-routed org funds the transfer from the Safe, so code, balanceWei, shortfallWei and the “Fund <address>” sentence describe the EOA rather than the address the broadcast actually spends from. The same holds for the decoded-revert codes insufficient_token_balance, insufficient_allowance and caller_not_authorized, and for their remediation: the token balance, the allowance owner and the caller they describe are the EOA, not the Safe. If your organization routes writes through a Safe, do not act on those fields without resolving the signer mode first.
Get Execution Status
GET /api/execute/{executionId}/statusCheck the status of a direct execution.
Response
{
"executionId": "n3364uzl2s6aram5v558c",
"status": "completed",
"pollIntervalHint": 0,
"type": "transfer",
"network": "11155111",
"transactionHash": "0x...",
"transactionLink": "https://etherscan.io/tx/0x...",
"sponsored": false,
"retryCount": 0,
"receipts": [
{
"hash": "0x...",
"chainId": 11155111,
"verified": true,
"receiptStatus": "success",
"blockNumber": 11413447,
"gasUsed": "68115",
"verifiedAt": "2024-01-01T00:00:15Z"
}
],
"gasUsedWei": "21000000000000",
"gasPriceWei": "1163827869",
"estimatedCostUsd": null,
"result": {...},
"error": null,
"createdAt": "2024-01-01T00:00:00Z",
"completedAt": "2024-01-01T00:00:15Z"
}Other fields:
-
pollIntervalHint: recommended seconds to wait before the next poll, or0when the execution has reached a terminal state (completedorfailed). Identical to theX-Poll-Interval-Hintheader, surfaced in the JSON body so MCP tools and clients without header access can decide terminality safely. -
network: the chain identifier the request supplied, stored verbatim as a string. The form is decided by the value, not by the field: bothchainIdand the deprecatednetworkalias accept a numeric chain ID or a known chain name, so"11155111"and"sepolia"are each reachable through either. Do not key a chain lookup on this without handling both forms. A body carrying neither field is rejected with a 400 before an execution row exists, so this is nevernullon the endpoints documented here. When a body sends both, the routes disagree about which wins:contract-calltakesnetwork, whiletransferandcheck-and-executetakechainId. Send one. -
retryCount: internal re-executions of a node execution, which is/api/execute/nodeand is not covered by this page. It is always0for the transfer, contract-call and check-and-execute endpoints documented here, whatever happened internally - those paths never set it, so a0is not evidence that nothing was retried. Where the field is set, each count is a fresh execution of the step rather than a replacement of an earlier transaction: nothing is resubmitted at a pinned nonce and no gas price is bumped. A failure that carries a transaction hash is therefore never retried, whatever its message says: the hash means a transaction is already live, and a retry would sign a second one rather than replace it. Of the failures that carry no hash, only connection-level errors (resets and timeouts) are retried, and an error reporting that a transaction is already live - a used nonce, an already-known hash, an underpriced replacement - is not. The one case left open is an attempt that exceeds its own per-attempt timeout: it is abandoned rather than cancelled, so nothing comes back to carry a hash, and a per-attempt timeout shorter than the chain’s confirmation latency can leave two transactions confirmed.That endpoint’s
retryobject takesmaxRetries(0 to 10) andtimeoutMs(1000 to 600000).maxRetriesdefaults to 3, so an omitted value is four attempts, not one, and the worst-case budget -timeoutMstimes attempts - may not exceed 600000ms, the idempotency processing-lock TTL. A request that exceeds it is rejected with a 400 naming the arithmetic that was applied. OmittingmaxRetriestherefore admits atimeoutMsup to 150000; past that, sendmaxRetries: 0to keep a single long attempt. LoweringtimeoutMsis the wrong way to fit the budget for a write: per the paragraph above, a per-attempt timeout under the chain’s confirmation latency is what produces two confirmed transactions. -
gasPriceWei: the effective gas price, as a decimal string. On EVM chains this is in wei. On Solana it is the micro-lamports-per-compute-unit price of the priority component, as described in Gas Management. Do not multiply it bygasUsedWei: that field is already a cost (gasUsed * effectiveGasPrice), so the product squares the price. The figure in gas units is the per-receiptgasUsedabove, and multiplying that is a cost on EVM chains only. -
estimatedCostUsd: reserved, and alwaysnulltoday. Nothing populates it; it awaits a price-oracle integration. Do not branch on it being non-null.
Receipts:
receipts carries one entry per transaction hash this execution claimed, each
independently re-fetched from the chain before the execution was allowed to
settle. It is the evidence behind status, not a restatement of it:
verified: whether this hash positively confirmed on-chain. An execution settles ascompletedonly when every entry istrue.receiptStatus:success,reverted,safe_inner_failure(the outer transaction succeeded but a wrapped inner call failed),not_found, ortimeout. The last two mean verification could not reach a definitive answer within its budget; they fail the execution closed rather than optimistically settling it, so afailedexecution carryingtimeoutmay describe a transaction that later lands.blockNumber/gasUsed: read from the fetched receipt, not self-reported by the write path.
The array is empty for executions that claimed no transaction hash, such as read calls and simulations.
Status Values:
pending: Queued for executionrunning: Currently executingunconfirmed: Broadcast, but the receipt could not be read conclusively yet. Non-terminal. Keep polling. Do not re-send the request with a freshIdempotency-Key, which would risk a second transaction: see Idempotency and Zero to a Verified Onchain Transaction.completed: Successfully completedfailed: Execution failed
Treat this list as a lower bound rather than a closed set. A client that routes an
unrecognised status into a failing default branch will report a failure for an
execution that is still settling, and one that responds by retrying with a new
idempotency key can put a second transaction onchain. Decide terminality from the
pollIntervalHint response body field (or the X-Poll-Interval-Hint response
header) rather than from the status string: the server computes it from its own
terminal set, so it stays correct for statuses added after your client shipped.
0 means terminal.
sponsored is true when the write was gas-sponsored and broadcast through
a relayer or smart-account path rather than your org’s EOA wallet — see
Sponsored Executions.
When polling this endpoint, honour the pollIntervalHint response body field
(or the X-Poll-Interval-Hint response header) instead of polling on a fixed
timer: it gives the recommended number of seconds to wait before the next poll.
A value of 0 means the execution has reached a terminal state (completed or
failed) and you can stop polling.
Error Responses
Direct execution endpoints return detailed error information:
{
"error": "Missing required field",
"field": "network",
"details": "network is required and must be a non-empty string"
}Common Error Codes:
401: Invalid or missing API key403: The daily spending cap is exceeded, or the credential lacks the scope the request needs (insufficient_scope). Scope is enforced for both OAuth tokens and organization API keys. A key created without a scope has no scope restriction and passes every gate. See Spending Caps — an organization that never configured a cap is still subject to the platform default.422: Wallet not configured, codeWALLET_NOT_CONFIGURED(see Wallet Management)429: Rate limit exceeded400: Invalid request parameters. Asimulateflag sent where the endpoint does not read it returns codeunsupported_param, withfieldnaming where it was found (simulate,config.simulateoraction.simulate); see Dry-Run Simulation
An insufficient_scope response names the scope the endpoint needs and the one
this connection is allowed:
{
"error": "insufficient_scope",
"message": "This endpoint requires the `mcp:write` scope. This credential is allowed `mcp:read`. Retrying will not widen it. An API key's scope is fixed when the key is created and cannot be raised. A new key has to be issued with the scope this endpoint requires.",
"retryable": false,
"required_scope": "mcp:write",
"granted_scope": "mcp:read"
}The closing sentence names the remedy for the credential that was used, because the two families differ. An API key’s scope is written into the key when it is created and cannot be changed afterwards, so a new key is the only route. An OAuth connection is instead told that an owner or admin controls its ceiling under Settings > Developer > Agents.
granted_scope is what the credential may do right now. For an OAuth token that
is not always the scope it was issued with: an organization can cap what its
agents may do, and the cap is applied on every call, so a token issued with
mcp:admin reports mcp:read here while a read-only cap is in force.
Reauthorizing with a wider scope does not lift a cap; only an owner or admin
can, in the Agents settings. An API key reports the scope stored on the key.
Broadcasting requires mcp:write. A dry run (simulate: true) neither signs nor broadcasts, so mcp:read is sufficient.
Workflow Run preflight simulation
Before an interactive Run, the workflow editor calls POST /api/workflows/{workflowId}/simulate to perform a read-only preflight of reachable EVM write nodes.
The simulation is advisory and never blocks execution. Reverts, funding shortfalls, invalid simulation inputs, unsupported signers, RPC failures, timeouts, and unavailable simulation services are shown in the issues overlay with Run Anyway available. A funding shortfall reports the account to fund and the amount it is short by, and names no configured field, because no configured field is wrong.
Only write nodes reachable from a trigger are simulated. Disconnected write nodes are ignored.
Consecutive Write Contract nodes on one chain along one path are simulated as a sequence, each against the state the node before it produced, so an approve followed by a deposit is judged as it will actually run rather than warning on allowance because the approve has not landed. The nodes are taken in the order the edges connect them, not the order they were saved. The state is carried by eth_simulateV1 where the chain’s node offers it and by trace-derived eth_call overrides where it does not. If the sequence cannot be answered at all, because the chain’s node offers neither mechanism or the RPC is unavailable, each node of the run is simulated on its own against current chain state instead, so no node loses the result it would have had before sequencing existed. A run holds at most ten nodes, the same limit as calls on the direct-execution sequence endpoint; an eleventh consecutive write starts a new run.
A node that cannot join such a run is simulated on its own against current chain state: a write with more than one incoming connection, the second branch of a fan-out (the branch the connections reach first continues the run, unless it carries no write at all), a write on a different chain from the one before it, a write whose inputs use runtime templates, a write that sends native value (only the single-call path can report a funding shortfall with the amount), the first write past the ten-node limit, and native and token transfers. A warning on such a node may depend on an earlier workflow step whose state change has not been applied, and says so. A warning on a node simulated within a sequence drops that hedge only when no other reachable write ran before the sequence started and every earlier call in the sequence landed. Every reachable node that changes chain state counts as such a write, including the ones this preflight does not simulate (Approve Token, Batch Write Contract, protocol writes and Tempo writes), so a run that begins after any of them, or after a transfer, keeps the hedge, because the sequence applied the run alone; and so does a node whose earlier call in the run reverted, because a reverted call applies no state.
Warnings are returned in the order the nodes would run, following the connections from the trigger, rather than in the order the nodes are stored. Each warning still carries the node id and a parameterPath into the stored nodes array.
The endpoint is protected by rate limiting, a maximum of 50 workflow nodes, a 15-second simulation deadline, and a ten-call bound on each simulated sequence. A preflight that overruns the deadline answers 503 with error: "SIMULATION_TIMEOUT" and no warnings at all, rather than a partial set; on the state-override mechanism a sequence costs three requests per call where a single node costs two, so a long run on a chain without eth_simulateV1 is the case most likely to reach it.
The preflight does not sign or broadcast transactions, create execution records, reserve spending limits, or perform billing operations.