# Bolt Protocol — API guide for agents Bolt Protocol is a payment relay on Stacks (Bitcoin L2). You build and sign a Stacks transaction marked as *sponsored*; Bolt pays the STX network fee and broadcasts it. You pay Bolt in sBTC or USDCx instead, so an address that holds no STX can still transact. - Base URL: `https://boltproto.org/api` (Stacks **mainnet**) - No API key, no signup. Requests and responses are JSON. - Non-custodial: your private key never leaves your wallet, and Bolt cannot move your tokens. The one balance Bolt holds is prepaid credit, if you choose to deposit it (see [Prepaid gas credit](#2-prepaid-gas-credit)). - Amounts are integers in the token's smallest unit: sats for sBTC (8 decimals), micro-USDCx for USDCx (6 decimals). Pick a flow: | You want to | Use | Cost | |---|---|---| | Send sBTC or USDCx to a Stacks address | [Gasless transfer](#1-gasless-transfer) | from 10 sats, or 100 micro-USDCx, per transfer | | Call any other contract without STX | [Prepaid gas credit](#2-prepaid-gas-credit) | the fee you declare, from 10 sats, per call, plus 10 sats per deposit | ## Contracts (mainnet) | What | Identifier | |---|---| | Bolt sBTC contract | `SP3QZNX3CGT6V7PE1PBK17FCRK1TP1AT02ZHQCMVJ.boltproto-sbtc-v2` | | Bolt USDCx contract | `SP3QZNX3CGT6V7PE1PBK17FCRK1TP1AT02ZHQCMVJ.boltproto-usdcx-v1` | | sBTC asset | `SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token` | | USDCx asset | `SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx::usdcx-token` | ## Rules for every transaction you send 1. It is a `contract-call` with `sponsored: true` and `fee: 0`. 2. You sign it with your own key. 3. Use your account's next nonce (from any Stacks API, for example `GET https://api.hiro.so/extended/v3/principals/{address}/nonces`). If Bolt answers `Invalid nonce: …`, nothing is charged; what to do depends on the `code` (see [Errors](#errors)). 4. Send the hex-encoded serialized transaction as `serializedTx`. **If you lose the response** (timeout, dropped connection), do not rebuild the transaction: a rebuilt one carries a new nonce and runs the operation a second time. Send the same `serializedTx` again. A transaction can only run once per nonce, so this is safe: either it goes through now, or Bolt answers `code: NONCE_PENDING` or `NONCE_MISMATCH`, which means that nonce was already used. On a prepaid-credit call that answer carries the `txid` Bolt broadcast for it. Otherwise look your nonce up in `GET https://api.hiro.so/extended/v1/address/{address}/transactions`. Every write endpoint answers `{ "txid": "...", "fee": }` once the transaction is **broadcast**, not confirmed. `txid` is 64 hex characters with no `0x` prefix. Track confirmation on any Stacks API, for example `GET https://api.hiro.so/extended/v3/transactions/0x{txid}` (that API wants the prefix, so add it) and wait for `status: "success"`. ## 1. Gasless transfer `POST /api/v2/transaction/transfer?token=sbtc` or `?token=usdcx` Body: `{ "serializedTx": "" }` The transaction must call `transfer-stacks-to-stacks` on the Bolt contract for that token: ``` functionArgs: [ uint amount, principal recipient, (optional (buff 34)) memo, uint fee ] postConditionMode: Deny postConditions: [ sends exactly (amount + fee) of the token asset ] ``` - `fee` is what you pay Bolt, in the token itself: at least `10` (sats) for sBTC, at least `100` (micro-USDCx) for USDCx. - `recipient` receives exactly `amount`. - Your balance of the token must be at least `amount + fee`. On-chain this is a call to the Bolt contract with two token transfers inside: the fee to Bolt, then `amount` to the recipient. A service that verifies a payment by looking for a direct `transfer` call on the token contract will not recognize it. For such a payment, send a plain SIP-010 `transfer` on [prepaid credit](#2-prepaid-gas-credit) instead. Response `201`: `{ "txid": "…", "fee": 10 }` Errors (`400`, body `{ "message": "...", "statusCode": 400 }`): | message | what to do | |---|---| | `Invalid nonce: …` | see `NONCE_PENDING` and `NONCE_MISMATCH` under [Errors](#errors) | | `Transaction fee is less than the minimum required fee` | raise the `fee` argument | | `Insufficient … balance. Available: …, Required: …` | fund the wallet or lower `amount` | | any other message about the transaction, contract, function or post conditions | the transaction does not follow the rules above; fix it | | `Transaction too large` | drop post conditions or arguments the call does not need | | `transaction rejected` | the network refused the transaction; when the body has a `reason` field, it is the node's rejection code (see the list under prepaid gas credit) | ## 2. Prepaid gas credit For contract calls that are not a Bolt transfer: a plain SIP-010 `transfer`, a registry call, a DeFi call. You keep a credit balance in sats at Bolt and each sponsored call debits it. Credit is sBTC only: it is deposited in sBTC and spent in sats. There is no USDCx credit. Bolt holds the credit: this part is custodial. What you do not use can be withdrawn back to your address (step 4). What it costs: each deposit `10` sats, each call the fee you declare (from `10`), each withdrawal `10`. One call alone is at least `20` sats (deposit and call), `30` if you also withdraw a remainder; credit pays off over several calls. ### Step 1 — deposit credit `POST /api/v1/transaction/sbtc-token` Body: `{ "serializedTx": "" }` The transaction calls `deposit-fee-fund` on `SP3QZNX3CGT6V7PE1PBK17FCRK1TP1AT02ZHQCMVJ.boltproto-sbtc-v2`: ``` functionArgs: [ uint amount, uint fee ] postConditionMode: Deny postConditions: [ sends exactly (amount + fee) of sBTC ] ``` `amount` becomes your credit; `fee` (at least `10` sats) pays for this deposit itself and is charged on top: you send `amount + fee` and your credit is exactly `amount`. This deposit is sponsored too, so it needs no STX. ### Step 2 — read the balance `GET /api/v1/sponsor/sbtc-token/balance/{address}` Response `200`: `{ "balance": "1000" }` (sats, as a string) Call this after the deposit transaction confirms; the credit is available after that. ### Step 3 — get a call sponsored `POST /api/v1/sponsor/sbtc-token/transaction` Body: `{ "serializedTx": "", "fee": "10" }` - `serializedTx`: any sponsored `contract-call` you signed (rules above). Bolt's own contracts cannot be called this way; use the gasless transfer. - `fee`: integer string, in sats, debited from your credit. You can compute the minimum before sending; see below. Response `201`: `{ "txid": "…", "fee": 10 }` How the fee works: - You choose `fee`. The minimum is `10` sats, which covers a transaction of up to 500 bytes (the length of `serializedTx` in bytes, half its hex length). - A larger transaction needs 1 sat per 50 bytes, rounded up: 3,000 bytes need `60`. - A higher `fee` buys priority: Bolt pays the network 50 micro-STX per sat of `fee` (this rate can change), so the minimum of `10` pays 500 micro-STX. The minimum is enough when the network is not congested. The most you can declare is 50 times the minimum for that transaction. - The fee is debited when the request is accepted and returned to your credit if the network refuses the transaction. You pay only for calls that are broadcast. A call that is broadcast and later fails on-chain is still paid. Very large transactions may be refused. Protect yourself with post-conditions, as with any Stacks transaction. Errors (`400`; the `code` column is the `code` field of the body, see [Errors](#errors)): | code | message | what to do | |---|---|---| | `NONCE_PENDING` | `Invalid nonce: … is already pending.` | a transaction with that nonce is waiting to confirm; do not rebuild. If the body has `txid`, it is this call, already broadcast and paid | | `NONCE_MISMATCH` | `Invalid nonce: transaction has …, the next nonce … is …` | the nonce is not your next one. If the body has `txid`, this call was already broadcast and paid; otherwise sign again with the next nonce | | `FEE_TOO_LOW` | `Fee is less than the minimum required fee: ` | raise `fee` to at least `minimumFee` | | `FEE_TOO_HIGH` | `Fee is more than the maximum allowed: ` | lower `fee` to at most `maximumFee` | | `INSUFFICIENT_CREDIT` | `Insufficient sponsor credit balance. Required: …, Current balance: …` | deposit more, or read the balance after a confirmed deposit | | `RETRY_SAME` | `… Nothing was charged; send the same transaction again.` | send the same transaction again | | `NODE_REJECTED` | `Transaction rejected by the network: …` | the fee was returned; fix what `reason` names, then sign and send again | | `UNAVAILABLE` | `… Try again later.` | try again later | | `NOT_REFUNDED` | `… The fee could not be returned to your credit …` | the fee was debited and not returned; do not send again, Bolt was notified | | `STATUS_UNKNOWN` | `Transaction status unknown; …` | the fee was debited and Bolt could not confirm the broadcast; do not send again or rebuild. Look `txid` up; Bolt was notified | | `INVALID_REQUEST`, `INVALID_TRANSACTION` | any other message | the request or the transaction does not follow the rules above | Only `NOT_REFUNDED` and `STATUS_UNKNOWN` leave a debit behind; every other refusal costs nothing. An accepted call is not a trial: it is broadcast and paid, even if it then fails on-chain. `reason` is a field of the body (also quoted in the message): the Stacks node's own rejection code. The common ones: | reason | what to fix | |---|---| | `BadNonce` | the nonce you signed is not the next one for your address; the details carry the expected value | | `FeeTooLow` | raise `fee` | | `TooMuchChaining` | too many of your transactions are pending; wait for them to confirm, then sign and send again | | `NoSuchContract` | the contract ID does not exist on mainnet | | `NoSuchPublicFunction` | the contract has no public function with that name | | `BadFunctionArgument` | the arguments do not match the function's signature | ### Step 4 — withdraw unused credit `POST /api/v1/sponsor/sbtc-token/withdraw` Body: `{ "address": "", "amount": "500", "signedAt": "", "signature": "" }` - `amount`: sats of credit to take back. Bolt keeps `10` sats and sends the rest as sBTC to `address`. It must be more than `10`. - `signature`: your Stacks message signature (RSV, 65 bytes, hex) over exactly `Bolt credit withdrawal | {address} | {amount} | {signedAt}`. It is a signed message, not a transaction, so you need no STX. - The sBTC goes to the address that signed, never to another one. - `signedAt` must be within 5 minutes of now, and each signed request is paid once. - If you lose the response, send the same body again, at any time: Bolt answers `409` with the `status` of the original request and its `txid`. Response `201`: `{ "txid": "…", "amount": "500", "fee": 10, "received": "490" }` The credit is debited when the request is accepted. If the transfer then fails on-chain, the credit is put back the next time the balance is read. Errors: | code | status and message | what to do | |---|---|---| | `INVALID_SIGNATURE` | `400 Invalid signature` | sign the exact text above with the key of `address` | | `SIGNATURE_EXPIRED` | `400 signedAt is too old or in the future` | sign again with the current time | | `INVALID_REQUEST` | `400 Amount must be greater than the withdrawal fee: 10` | raise `amount` | | `INVALID_REQUEST` | `400 Invalid amount: …` | send `amount` as a string of digits (`"500"`, not `500`) | | `INSUFFICIENT_CREDIT` | `400 Insufficient sponsor credit balance. …` | read the balance and lower `amount` | | `ALREADY_PROCESSED` | `409 Withdrawal already processed` | this request was received before; read `status` below | | `UNAVAILABLE` | `400 … Nothing was charged; try again later.` | sign a new request later; the credit is untouched | | `NOT_REFUNDED` | `400 … the credit could not be restored …` | the credit was debited and not put back; do not send again, Bolt was notified | | `STATUS_UNKNOWN` | `400 Withdrawal status unknown; …` | do not sign a new request; send the same body later to read its `status` | `status` of a repeated request (`409`): | status | meaning | |---|---| | `sent` | paid; `txid` is the transfer, waiting to confirm | | `confirmed` | paid and confirmed; `txid` is the transfer | | `not_paid` | refused or failed; the credit is in your balance. Sign a new request | | `pending` | received moments ago, no outcome yet; send the same body again later | | `unknown` | Bolt could not establish the outcome and was notified; do not sign a new request | ## Errors Every refusal has a `message` and an HTTP status. Refusals of the prepaid credit endpoints also carry `code`: a stable value to branch on. So do two refusals on every endpoint: a nonce refusal (`NONCE_PENDING`, `NONCE_MISMATCH`) and a `serializedTx` that cannot be read or is not a signed sponsored contract call (`INVALID_TRANSACTION`). The messages can be reworded; the codes cannot. Body: `{ "statusCode": 400, "message": "…", "error": "Bad Request", "code": "…" }`, plus `reason`, `txid`, `minimumFee`, `maximumFee` or `status` where the tables above say so. ## Availability - `GET /api/health` answers `{ "status": "ok", … }` when Bolt is up. - `503` on a write endpoint: try again shortly; nothing was debited or sent. - `429 Too many requests` comes with a `Retry-After` header in seconds; wait that long before sending again. ## Example (Node.js, `@stacks/transactions` v7) Gasless sBTC transfer: ```js import { makeContractCall, Cl, PostConditionMode, getAddressFromPrivateKey } from '@stacks/transactions'; const senderKey = process.env.STACKS_PRIVATE_KEY; const sender = getAddressFromPrivateKey(senderKey, 'mainnet'); const amount = 1000; // sats const fee = 10; // sats, paid to Bolt const tx = await makeContractCall({ contractAddress: 'SP3QZNX3CGT6V7PE1PBK17FCRK1TP1AT02ZHQCMVJ', contractName: 'boltproto-sbtc-v2', functionName: 'transfer-stacks-to-stacks', functionArgs: [Cl.uint(amount), Cl.principal('SP…recipient'), Cl.none(), Cl.uint(fee)], postConditionMode: PostConditionMode.Deny, postConditions: [{ type: 'ft-postcondition', address: sender, condition: 'eq', amount: amount + fee, asset: 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token', }], senderKey, network: 'mainnet', sponsored: true, fee: 0, }); const res = await fetch('https://boltproto.org/api/v2/transaction/transfer?token=sbtc', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ serializedTx: tx.serialize() }), }); console.log(res.status, await res.json()); // 201 { txid, fee } ``` Variations: - **USDCx transfer**: `contractName: 'boltproto-usdcx-v1'`, `fee` of at least `100`, asset `SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE.usdcx::usdcx-token`, URL `…/transfer?token=usdcx`. - **Memo**: replace `Cl.none()` with `Cl.some(Cl.bufferFromAscii('order-42'))` (up to 34 bytes). - **Credit deposit**: `functionName: 'deposit-fee-fund'`, `functionArgs: [Cl.uint(amount), Cl.uint(fee)]`, same post-condition, POST to `/api/v1/transaction/sbtc-token`. - **Sponsored call on credit**: build any `makeContractCall({ …, sponsored: true, fee: 0 })` and POST `{ serializedTx, fee: "10" }` to `/api/v1/sponsor/sbtc-token/transaction`.