Zero to a Verified Onchain Transaction
Most quickstarts stop when the API accepts your request. This guide continues to the part that actually matters: proving a transaction landed, and knowing what to do when the answer is ambiguous.
It assumes a testnet throughout. Nothing here asks you to paste a private key.
1. What “verified” means here
A request that returned 202 Accepted proves only that KeeperHub queued work. Treat a
transfer as landed when all of the following hold:
- the execution reports a
transactionHash GET /api/execute/{executionId}/statusreturnsstatus: "completed"- the matching
receipts[]entry hasverified: trueandreceiptStatus: "success" - the effect you intended is visible onchain (a
Transferlog, a balance change)
KeeperHub already re-fetches each receipt from the chain before an execution settles, so
receipts[] is evidence rather than a restatement of status. Checking the same hash
against your own RPC is still worth doing once while you are building the integration:
it tells you that your understanding of the transaction matches the chain’s, and it is
the check you will want in place the first time a result looks strange.
2. Prerequisites
- An organization API key (
kh_). See API Keys. - A configured wallet integration. Without one, execution returns
422withWALLET_NOT_CONFIGURED. - A testnet chain that is enabled for your org. Read
GET /api/chainsand pick one whereisEnabledandisTestnetare bothtrue(Ethereum Sepolia is11155111). - Testnet funds in whichever account actually pays. Section 4 is about identifying it.
- An RPC endpoint for that chain, for the independent check in section 8.
Keep the key in an environment variable. Never inline it in a command you will paste into an issue or a commit.
3. Connect
The agent-native surface is the MCP server:
claude mcp add --transport http --scope user keeperhub https://app.keeperhub.com/mcp \
--header "Authorization: Bearer kh_your_key_here"See MCP Server for OAuth and per-workflow servers.
A non-destructive way to confirm the connection works is list_integrations, which
reads and changes nothing. If it returns 401, the key or header is wrong; a 403 on a
tool you expected to have means the token’s scope is too narrow.
4. Know which account is which
This is the step most first integrations skip, and the one that makes later errors hard to read. Four roles are easy to conflate, and they are frequently different addresses:
| Role | What it does |
|---|---|
| Turnkey EOA | signs the transaction. On an unsponsored route it also broadcasts the outer transaction and pays the gas. On a sponsored route a relayer submits and pays instead, so receipt.from is not your wallet |
| Safe | msg.sender at the target contract, when signer routing is on |
| Zodiac Roles modifier | validates the call against an allowlist and per-token allowances, when enabled |
| Token holder | the account whose balance actually decreases |
| Recipient | where the tokens end up |
With the Safe Sender toggle off, writes sign directly from the Turnkey EOA, which is also the token holder. One address plays several roles and reasoning stays simple.
With the toggle on, they separate: the EOA still signs the outer transaction, but the
Safe is msg.sender at the target and the Safe holds the funds. See
Safe Smart Accounts for the two routing modes.
That distinction matters immediately in the next section, because the dry run does not model it: simulation resolves its sender to the org’s wallet address, so for a Safe-routed org the simulated sender is not the account that pays. See Known limitation.
Write these addresses down before you continue. Every confusing result later in this guide resolves by asking which of them a message is talking about.
5. Preflight
Confirm, in order:
- the chain id is the testnet you chose, and
isEnabledis true - the token address is that token on that chain
- the amount is in human-readable units (
"0.1"), not base units - the account that actually holds the token has enough of it
- the recipient is an address you control
6. Simulate
Every execute tool and endpoint takes a simulate flag that estimates gas and catches
reverts without signing or broadcasting:
{
"chainId": "11155111",
"recipientAddress": "0xRecipient",
"tokenAddress": "0xToken",
"amount": "0.1",
"simulate": true
}simulate must be the JSON boolean true. The string "true" is rejected, deliberately,
so a typo cannot fall through to a real broadcast.
A deterministic dry-run failure answers HTTP 400 with wouldRevert: true. Classify the
body by a string code first, then by failureKind: insufficient_balance is an
attributed preflight failure, while failureKind: "revert" means the EVM call reverted.
Other validation failures are not confirmed reverts. A wrapper that treats every non-2xx
as “bad request” throws away these diagnostics.
Over MCP the two actionable cases arrive as an augmented error whose text names the
stage, reason, machine-readable code when present, simulated sender, and
low-level call target.
A successful dry run is not a guarantee of execution. It proves the call does not revert against current state, at the sender the simulator chose. State can change, and for a Safe-routed org that sender is not the paying account.
7. Execute
Broadcast by re-sending the request you just simulated, with simulate removed and an
Idempotency-Key header added:
curl -X POST https://app.keeperhub.com/api/execute/transfer \
-H "Authorization: Bearer $KEEPERHUB_API_KEY" \
-H "Idempotency-Key: first-verified-transfer-2026-08-08" \
-H "Content-Type: application/json" \
-d '{
"chainId": "11155111",
"recipientAddress": "0xRecipient",
"tokenAddress": "0xToken",
"amount": "0.1"
}'Change nothing else between the dry run and the broadcast, so the transaction you inspected is the transaction you send. The key must name the work, not the attempt, so a retry reuses it: see Choosing a stable key.
Save the returned executionId.
8. Verify
Poll the status endpoint, honouring the X-Poll-Interval-Hint header rather than a fixed
timer. A hint of 0 means the execution is terminal.
curl -H "Authorization: Bearer $KEEPERHUB_API_KEY" \
https://app.keeperhub.com/api/execute/$EXECUTION_ID/statusRead the receipt entry, not just the top-level status:
verified: true— this hash positively confirmed onchainreceiptStatus: "success"— it did not revertblockNumber,gasUsed— read from the fetched receipt
receiptStatus also takes the values reverted, safe_inner_failure (the outer
transaction succeeded but a wrapped inner call failed), not_found, and timeout.
safe_inner_failure is the one to watch for on a Safe-routed setup: a transaction that
“succeeded” at the top level while the inner transfer did not.
Then check the same hash yourself, against your own RPC: fetch the receipt, confirm the
status, and decode the Transfer log to confirm the token, recipient, and amount are what
you intended. Confirming the effect is a stronger statement than confirming the
transaction executed.
What each stage licenses you to say:
| Signal | What it proves |
|---|---|
202 Accepted | the request was queued |
success: true, status: "simulated", wouldRevert: false | the call did not revert against current state |
transactionHash present | a transaction was claimed |
status: "unconfirmed" | it was broadcast but its receipt is not yet readable — not a failure |
status: "completed" | every claimed hash verified onchain |
receiptStatus: "success" | that transaction did not revert |
| expected log decoded | the intended effect happened |
9. When the result is ambiguous
A timeout, a dropped connection, or receiptStatus: "timeout" means you do not know the
outcome. It does not mean the transfer failed.
KeeperHub models this state directly. When a broadcast transaction’s receipt cannot be
read conclusively, the execution settles as unconfirmed, which is non-terminal: the
status endpoint keeps telling you to poll rather than handing you an outcome, and the
record carries the hash. Do not re-send an unconfirmed execution — the transaction may
still land, and re-sending can move the funds twice.
Do not retry blindly. Instead:
- Look for a
transactionHashorexecutionIdyou already hold. - Ask the chain about that hash directly.
- If you must re-send, re-send with the same
Idempotency-Key, so a replay returns the original result instead of executing twice. A replayed response carriesidempotentReplay: true. - Treat a missing receipt as unknown, not as failure.
Never convert absence of evidence into a success.
10. Troubleshooting
HTTP 400 on a dry run. Symptom: a non-2xx that a generic wrapper reports as a bad
request. Likely cause: a deterministic simulation failure. How to inspect: read code
first, then failureKind, wouldRevert, and revertReason; over MCP, read the appended
diagnostic lines when present. Safe next step: fix the attributed cause and re-simulate.
Do not broadcast to “see what happens”.
The reported balance does not match what you expect. Symptom: a shortfall or insufficient-balance reason naming an address that looks wrong. Likely cause: the dry run resolved the sender to the org wallet, while a Safe holds the tokens. How to inspect: compare the sender in the diagnostic against the addresses from section 4. Safe next step: resolve your org’s signer mode first; do not fund an address just because a message named it.
422 WALLET_NOT_CONFIGURED. No wallet integration. See
Wallet Management.
403 on a tool that used to work. An OAuth token whose scope is too narrow.
Broadcasting needs mcp:write; a dry run only needs mcp:read.
completed but the effect is missing. Check receiptStatus for
safe_inner_failure, and decode the logs rather than trusting the top-level status alone.
11. Checklist
- Testnet chain id chosen,
isEnabledandisTestnetboth true - Wallet integration configured
- Addresses identified: signer, Safe, Roles modifier, token holder, recipient
- Token address correct for that chain, amount in human-readable units
- The account that actually holds the token is funded
- Dry run returns
success: trueandwouldRevert: false - Same body broadcast,
simulateremoved,Idempotency-Keyset executionIdsavedstatus: "completed"withverified: trueandreceiptStatus: "success"- Expected log decoded against your own RPC