Quickstart
Submit a task, poll for the result, and settle it on-chain.
This guide takes you from an API key to an on-chain state update. The router does the expensive off-chain work — fanning your task out to the operator network and aggregating their signatures — and hands back a ready-to-sign transaction. You submit that transaction from your own wallet, so the router never custodies your keys or funds.
The flow is three calls plus one on-chain submission:
- Submit a task to
POST /tasksand get atask_id. - Poll
GET /tasks/{task_id}until itsstatusisready. - Sign and submit the returned
payloadfrom your wallet.
Set these environment variables to follow along against the public testnet:
export ROUTER_URL="https://testnet.gaskiller.xyz"
export API_KEY="gk_..." # your API key (see step 1)
export RPC_URL="https://..." # an RPC endpoint for the payload's chain
export PRIVATE_KEY="0x..." # the wallet that submits the transaction1. Get an API key
Task submission and polling are authenticated with a bearer API key (it looks
like gk_…). Keys are provisioned by the Gas Killer team —
reach out to request one. Store it securely and
pass it as a bearer token on every request.
2. Submit a task
Send the task to POST /tasks with the key as a bearer token. call_data is the
ABI-encoded calldata as an array of byte values, and value is a 0x-prefixed hex
uint256. A 202 response returns a task_id you'll poll in the next step.
TASK_ID=$(curl -s -X POST "$ROUTER_URL/tasks" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": {
"target_address": "0x0000000000000000000000000000000000000001",
"from_address": "0x0000000000000000000000000000000000000002",
"call_data": [171, 205, 239, 1],
"value": "0x0",
"block_height": 1
}
}' | jq -r .task_id)
echo "$TASK_ID"const res = await fetch(`${process.env.ROUTER_URL}/tasks`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
body: {
target_address: "0x0000000000000000000000000000000000000001",
from_address: "0x0000000000000000000000000000000000000002",
call_data: [171, 205, 239, 1],
value: "0x0",
block_height: 1,
},
}),
})
const { task_id } = await res.json()
console.log(task_id)The 202 body is { "task_id": "…", "status": "queued" }. See
Submit a compute task for every field and error.
transition_index
Omit transition_index (or send null / "auto") to let the router assign the
next available state-transition slot at dequeue time — this is what makes safe
parallel submissions possible. Send an integer only when you need to target a
specific slot.
3. Poll until ready
Poll GET /tasks/{task_id} until status becomes ready. The task moves
queued → processing → ready; a ready task carries a payload object. If
it ends in failed or expired instead, the error field explains why.
# Poll every 2s until the task leaves the queue.
while :; do
TASK=$(curl -s "$ROUTER_URL/tasks/$TASK_ID" -H "Authorization: Bearer $API_KEY")
STATUS=$(echo "$TASK" | jq -r .status)
echo "status: $STATUS"
[ "$STATUS" = "ready" ] && break
case "$STATUS" in failed|expired) echo "$TASK" | jq .error; exit 1;; esac
sleep 2
done
echo "$TASK" | jq .payloadasync function pollUntilReady(taskId: string) {
while (true) {
const res = await fetch(`${process.env.ROUTER_URL}/tasks/${taskId}`, {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
})
if (!res.ok) {
const { error } = await res.json()
throw new Error(`${error.code}: ${error.message}`)
}
const task = await res.json()
if (task.status === "ready") return task.payload
if (task.status === "failed" || task.status === "expired") {
throw new Error(`task ${task.status}: ${task.error}`)
}
await new Promise((r) => setTimeout(r, 2000))
}
}
const payload = await pollUntilReady(task_id)A ready response looks like this:
{
"task_id": "3f8c1e02-9a4b-4c7d-8e1f-2b6a5c9d0e11",
"status": "ready",
"created_at": 1753180800,
"updated_at": 1753180812,
"error": null,
"payload": {
"to": "0x0000000000000000000000000000000000000001",
"data": "0x93de4531000000000000000000000000000000000000000000000000000000000000002a",
"value": "0x0",
"chain_id": 11155111,
"estimated_gas": 234000,
"valid_until_block": 22345678
}
}The payload is only valid until valid_until_block. Submit before that block;
afterwards — or if the target's on-chain state has already advanced —
GET /tasks/{task_id} returns 409 PAYLOAD_EXPIRED and you must submit a new
task. Always fetch the single task (not the list endpoint) immediately before
submitting, so you get the freshness-checked payload.
4. Sign and submit the payload
The payload is a complete transaction request. Sign it with your wallet and
broadcast it as-is — to, data, and value are all supplied by the
router. Use an RPC endpoint for the chain named in payload.chain_id (the
testnet settles on Sepolia, 11155111).
Extract the fields with jq, then broadcast with Foundry's
cast send:
TO=$(echo "$TASK" | jq -r .payload.to)
DATA=$(echo "$TASK" | jq -r .payload.data)
VALUE=$(echo "$TASK" | jq -r .payload.value)
cast send "$TO" "$DATA" \
--value "$VALUE" \
--rpc-url "$RPC_URL" \
--private-key "$PRIVATE_KEY"Submit with viem. Match the chain to payload.chain_id:
import { createWalletClient, http } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { sepolia } from "viem/chains"
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
const walletClient = createWalletClient({
account,
chain: sepolia,
transport: http(process.env.RPC_URL),
})
const hash = await walletClient.sendTransaction({
to: payload.to as `0x${string}`,
data: payload.data as `0x${string}`,
value: BigInt(payload.value),
gas: BigInt(payload.estimated_gas),
})
console.log("submitted:", hash)estimated_gas is a hint from the router's eth_estimateGas; most wallets and
libraries will re-estimate, so passing it is optional.
5. Handle errors
Every error uses the same envelope, with a stable code:
{ "error": { "code": "QUEUE_FULL", "message": "Service at capacity, please try again in a few minutes" } }RATE_LIMITED(429) means you've exceeded your key's request rate (60 requests/minute by default). Wait for theRetry-Afterheader value, in seconds, then retry.QUEUE_FULL,RPC_UNAVAILABLE(503) are transient — retry after a short delay.QUEUE_FULLalso sends aRetry-Afterheader.PAYLOAD_EXPIRED(409) means the payload is stale — submit a fresh task and poll again.4xxvalidation errors (INVALID_ADDRESS,STALE_BLOCK,TRANSITION_MISMATCH, …) mean the request needs fixing before it can succeed.
See Submit a compute task and Get task status for the full status-code and error matrix for each endpoint.