1. POST /transfers → create transfer, receive transferId2. POST /transfers/:id/prepare → receive preparedTxs (unsigned base64 txs)3. [CLIENT] Sign and broadcast keeperFundingTx FIRST4. POST /transfers/:id/confirm-broadcast → record keeperFundingSignature immediately5. [CLIENT] Sign and broadcast routeInitTxs → orchestratorInitTx → sessionInitTxs in order6. POST /transfers/:id/confirm-broadcast → record remaining signatures, activate transfer7. GET /transfers/:id → poll until completed | failed
Steps 3 and 5 happen entirely outside the API. No private key material is sent to the server.confirm-broadcast is called twice: once immediately after keeperFundingTx (step 4), and once after the rest (step 6). This prevents double-funding if deployment is interrupted and /prepare is called again.
preparedTxs contains four groups of transactions that must be signed in order. Each group uses either a VersionedTransaction (v0) or a legacy Transaction — the signing approach differs.
Field
Tx type
Broadcast order
Notes
keeperFundingTx
VersionedTransaction (v0)
1st
Broadcast and confirm first; record signature immediately via confirm-broadcast
routeInitTxs[]
VersionedTransaction (v0)
2nd
Server pre-signs ephemeral keys; preserve existing sigs
orchestratorInitTx
Legacy Transaction
3rd
Plain partial sign
sessionInitTxs[]
VersionedTransaction (v0)
4th
Server pre-signs; preserve existing sigs
The server partially pre-signs VersionedTransactions with ephemeral keypairs. Your signing step must add your signature to the existing slot without overwriting the server’s partial signatures.
Any field that is null in preparedTxs is already confirmed on-chain from a previous broadcast attempt — skip it.
Signed transactions must be broadcast in a strict order. Each group must reach confirmed status before the next group is sent — later transactions depend on accounts created by earlier ones.
keeperFundingTx must be broadcast and confirmed first. Call confirm-broadcast with just the keeperFundingSignature immediately after — before broadcasting anything else. This is required to prevent double-funding if deployment is interrupted.
Wait for each routeInitTx to reach confirmed before broadcasting the next one. Allow an additional 3 seconds after the last routeInitTx and after orchestratorInitTx for account state to propagate across RPC nodes, especially on devnet.
import timeimport requestsdef broadcast_and_confirm(base64_tx: str, label: str, rpc_url: str) -> str: """Send a signed transaction to the RPC and wait for confirmation.""" resp = requests.post(rpc_url, json={ "jsonrpc": "2.0", "id": 1, "method": "sendTransaction", "params": [base64_tx, {"encoding": "base64", "skipPreflight": False}], }, timeout=30) resp.raise_for_status() result = resp.json() if "error" in result: raise RuntimeError(f"{label} failed: {result['error']}") sig = result["result"] print(f" {label}: {sig}") for _ in range(12): time.sleep(5) status = requests.post(rpc_url, json={ "jsonrpc": "2.0", "id": 1, "method": "getSignatureStatuses", "params": [[sig], {"searchTransactionHistory": True}], }, timeout=30).json() val = (status.get("result", {}).get("value") or [None])[0] if val and val.get("confirmationStatus") in ("confirmed", "finalized"): return sig return sig # proceed after 60s timeoutdef broadcast_signed_txs( signed: dict, rpc_url: str, transfer_id: int, api_base: str, api_headers: dict) -> dict: """Broadcast all signed transactions in the correct order. Calls confirm-broadcast immediately after keeperFundingTx to prevent double-funding. Returns the final confirm-broadcast body.""" import uuid signatures: dict = {} # 1. keeperFundingTx FIRST — record immediately if signed.get("keeperFundingTx"): sig = broadcast_and_confirm(signed["keeperFundingTx"], "keeperFundingTx", rpc_url) signatures["keeperFundingSignature"] = sig requests.post( f"{api_base}/api/v1/transfers/{transfer_id}/confirm-broadcast", json={"routeInitSignatures": [], "keeperFundingSignature": sig}, headers={**api_headers, "Idempotency-Key": str(uuid.uuid4())}, timeout=30, ).raise_for_status() # 2. routeInitTxs route_sigs = [] for i, entry in enumerate(signed.get("routeInitTxs") or []): sig = broadcast_and_confirm(entry["base64"], f"routeInitTxs[{i}]", rpc_url) route_sigs.append(sig) if i < len(signed["routeInitTxs"]) - 1: time.sleep(3) if route_sigs: signatures["routeInitSignatures"] = route_sigs time.sleep(3) # 3. orchestratorInitTx if signed.get("orchestratorInitTx"): sig = broadcast_and_confirm(signed["orchestratorInitTx"], "orchestratorInitTx", rpc_url) signatures["orchestratorInitSignature"] = sig time.sleep(3) # 4. sessionInitTxs session_sigs = [] for i, b64 in enumerate(signed.get("sessionInitTxs") or []): sig = broadcast_and_confirm(b64, f"sessionInitTxs[{i}]", rpc_url) session_sigs.append(sig) if session_sigs: signatures["sessionInitSignatures"] = session_sigs return signatures
Solana blockhashes expire roughly 60 seconds after the /prepare call. If broadcast fails mid-way — due to expiry, an RPC error, or a process crash — call /prepare again with a new Idempotency-Key.The server inspects the chain and returns null for any group already confirmed. The signing and broadcast helpers above skip null fields automatically.
Drop the block below into your project’s CLAUDE.md (or any system prompt / context file your agent reads). It gives the agent everything it needs to call MultiHopper correctly without hallucinating endpoints or field names.
# MultiHopperREST API: https://multihopper.com/api/v1Auth: x-api-key header — mh_live_... (live) or mh_test_... (test)## Transfer flow — 3 API calls### 1. CreatePOST /transfersRequired fields: tokenMint, amountRaw, amountTokens, sourceOwner (sender wallet), recipientWallet, hops (integer, 3–10), arrivalSecondsOptional: tokenDecimals (default 6), tokenSymbol, tokenPriceUsd, externalId→ returns { id, status: "awaiting_signature", ... }### 2. PreparePOST /transfers/{id}/prepare→ returns { transfer, preparedTxs: { routeInitTxs[]: { base64 }, orchestratorInitTx: string, sessionInitTxs[]: string, keeperFundingTx: string, recentBlockhash, lastValidBlockHeight }}Sign each group with the sourceOwner keypair (see signing rules).BROADCAST ORDER (strict): keeperFundingTx FIRST, then routeInitTxs, then orchestratorInitTx,then sessionInitTxs.null fields are already confirmed on-chain — skip them.### 3. Confirm broadcast — call TWICEFirst call (immediately after keeperFundingTx, before anything else):POST /transfers/{id}/confirm-broadcast{ routeInitSignatures: [], keeperFundingSignature: "..." }Second call (after all remaining txs are broadcast and confirmed):POST /transfers/{id}/confirm-broadcast{ routeInitSignatures[], orchestratorInitSignature?, sessionInitSignatures?, keeperFundingSignature? }keeperFundingSignature is REQUIRED if /prepare emitted a keeperFundingTx. Returns MH_039 if missing.The two-call pattern prevents double-funding the keeper on resume.Blockhashes expire ~60 seconds after /prepare. Call /prepare again with a new Idempotency-Key onexpiry; null fields will reflect what is already on-chain.All POST mutations require an Idempotency-Key header (MH_070 if missing or invalid).## Signing rules- keeperFundingTx, routeInitTxs, sessionInitTxs → VersionedTransaction (v0) Add your signature to the correct slot in the existing signatures array. Do NOT call sign() or replace all signatures — server has pre-signed ephemeral keys.- orchestratorInitTx → Legacy Transaction, use partialSign.- Wait for each group to reach "confirmed" before broadcasting the next.- Add 3s delay after last routeInitTx and after orchestratorInitTx.## Polling transfer statusGET /transfers/{id}→ { status, phase, progress: { hopsCompleted, hopsTotal }, lastError, recovery }status: quote → awaiting_signature → processing → completed | failed | expired | refundedphase: quoted → deploying → executing → settled (failure: failed | recoverable | rescued | reclaimed | expired)If phase = "recoverable" (funds locked on-chain): POST /transfers/{id}/rescue/prepare → sign txs → POST /transfers/{id}/rescue/confirm## Key constraints- hops must be 3–10- arrivalSeconds minimum varies by hop count (MH_014 if too low)- externalId must be unique per integration key (MH_033 on duplicate)- All POST mutations require Idempotency-Key header## Common error codesMH_001 / MH_002 invalid or revoked API key (401)MH_012 amount below minimum (400)MH_013 hops out of range — must be 3–10 (400)MH_014 arrivalSeconds below minimum for hop count (400)MH_032 funding not completed within timeout (408)MH_033 duplicate externalId (409)MH_034 transfer expired (410)MH_039 keeperFundingSignature required but missing (400)MH_070 Idempotency-Key header missing or invalid (400)
When building an MCP-compatible agent (Claude, LangGraph, AutoGen), expose the API steps as individual tools and keep sign_and_broadcast as a local tool that holds wallet access.
Tool
Wraps
estimate_transfer
POST /api/v1/transfers/estimate
create_transfer
POST /api/v1/transfers
prepare_transfer
POST /api/v1/transfers/:id/prepare
sign_and_broadcast
local — signs with keypair, broadcasts to RPC
confirm_broadcast
POST /api/v1/transfers/:id/confirm-broadcast
get_transfer
GET /api/v1/transfers/:id
list_transfers
GET /api/v1/transfers
prepare_rescue
POST /api/v1/transfers/:id/rescue/prepare
confirm_rescue
POST /api/v1/transfers/:id/rescue/confirm
sign_and_broadcast is the only tool that requires wallet access. All other tools are pure HTTP wrappers and can be exposed without key material.
API Reference
Full endpoint documentation, error codes, and rate limits.
Webhooks
Receive real-time transfer lifecycle events instead of polling.