For agents
This page is written for a model or an automated client that swaps on behalf of a user. It is terse on purpose: every rule here is a contract, not advice.
What the service does
Drip Tools sells testnet tokens for real mainnet tokens, one way only. You make one payment on a mainnet FROM chain (ten EVM chains, Solana or TON) and the service pays the quoted testnet amounts to up to 10 000 recipients on one TO chain (EVM, Solana, TON or Bitcoin testnets). Base URL https://drip.tools/api, JSON over HTTPS, no auth, open CORS. Every swap is four steps: discover (GET /api/chains) → quote (POST /api/quote) → pay (one transaction on the FROM chain, exactly fromTotal) → track (GET /api/order?from_tx_hash=…). The payment is real money and cannot be reversed; the price is the TO amount × the listed rate, converted at the live Chainlink price of the FROM token, with no fee or spread. FROM-chain gas is yours.
1. Discover
GET https://drip.tools/api/chains?unwrap=true → a flat array of chain objects. The fields you use:
{
"chainId": "42161",
"isTestnet": false,
"isEnabled": true,
"rpc": ["https://arbitrum.drpc.org", "https://arbitrum-one-rpc.publicnode.com"],
"tokens": [
{ "symbol": "ETH", "address": "0x0000000000000000000000000000000000000000", "decimals": 18 },
{ "symbol": "USDC", "address": "0xaf88d065e77c8cc2239327c5edb3a432268e5831", "decimals": 6 }
],
"contracts": { "depository": "0x52361c9412DceC6A83980fb065D0276ec001fACE" }
}Optional fields: isPaused (present and true only while paused), family ("solana", "ton", "bitcoin"; absent = EVM), cluster (Solana).
| Field | Rule |
|---|---|
chainId | A string here. Solana FROM 1399811149, TON FROM 1667471768; EVM chains use their EVM id. |
isTestnet | false = FROM side (you pay there). true = TO side (recipients receive there). |
isEnabled / isPaused | Usable only when isEnabled: true and isPaused is absent or false. Otherwise skip the chain. |
family | Absent = EVM. "solana", "ton" or "bitcoin" otherwise. Selects the payment shape. |
rpc | Ordered: rpc[0] primary, the rest fallbacks. |
tokens[] | address 0x0000000000000000000000000000000000000000 = native coin on every chain; SPL tokens use the base58 mint. decimals converts amounts. Bitcoin testnet tokens add quoteDecimals and minTo. |
contracts.depository | FROM chains only: the pay target (EVM contract, Solana / TON treasury). Absent → not payable. |
Optional before quoting: GET /api/quotes?chainId=<toChainId>&symbol=<SYMBOL> → { "price": 0.25, "balance": "<base units>", "available": true }. available: false or a balance below your total means the quote will fail with NO_TO_PRICE / NO_LIQUIDITY.
2. Quote
POST https://drip.tools/api/quote with Content-Type: application/json. Every field is required:
{
"chainId": 8453,
"fromToken": "0x0000000000000000000000000000000000000000",
"sender": "0x9246ef7F2b7fEcE8AfD8a72271A76Cc2A8d8A3E5",
"toChainId": 421614,
"toToken": "0x0000000000000000000000000000000000000000",
"recipients": ["0x2F4edA5e7EEa6eb634C7FBb0acAFA04b8938F63A"],
"toAmounts": ["5000000000000000000"]
}| Field | Rule |
|---|---|
chainId, toChainId | Numbers. FROM must be an enabled mainnet, TO an enabled testnet. |
fromToken, toToken | An address from that chain's tokens[]. |
sender | The wallet that will sign and pay. EVM 0x… any case; Solana base58; TON any form. |
recipients | 1 to 10 000 addresses valid on the TO chain. |
toAmounts | Base-unit integer strings of the TO token, one per recipient. No decimals, hex or exponents. |
Response 200 — the fields you must use:
{
"quoteId": "0x1f5215a4577ef4548b550313d772594473e237c67514943751634818fdff9615",
"vault": "0x52361c9412dcec6a83980fb065d0276ec001face",
"fromToken": "0x0000000000000000000000000000000000000000",
"fromTotal": "517554316856097",
"deadline": 1784121105,
"signature": "0x23fbc0…1b",
"signer": "0x5C0917e114e49F3Cf266D6467ac129FEe42d77c5",
"usdTotal": "1.25"
}| Field | Use |
|---|---|
quoteId | The only reference carried on-chain: pay argument, Solana memo, or TON comment. |
vault | Pay target, canonical form (EVM lowercased, Solana base58, TON UQ…). Equals contracts.depository. |
fromToken | The token to pay with, canonical form. |
fromTotal | The exact amount to pay, base units (wei, token units, lamports, nanotons). |
deadline | Epoch seconds. The payment must land at or before it. Equals expiry. |
signature | EIP-712 signature by signer. Verifying it is optional: Verifying the quote signature. |
The response also carries issuedAt, expiry, fromAmounts and toAmounts (bookkeeping).
Errors are { "error": "<message>", "code": "<CODE>" }. Decide by HTTP status and code:
| Response | Action |
|---|---|
400 + code | Fix the request; never resend it unchanged. BELOW_MIN_TOTAL raise amounts · TO_TOO_PRECISE / TO_TOO_SMALL / BELOW_SOL_RENT_MIN fix the amount · NO_LIQUIDITY lower amounts or pick another TO token · BAD_TO_CHAIN / BAD_FROM_CHAIN pick another chain · TOO_MANY split the batch. |
429 JSON RATE_LIMITED | Sleep Retry-After seconds, then retry. |
429 empty body | Edge flood guard: sleep a few seconds, then retry. |
503 WARMING_UP | Sleep retryAfterSec, then retry. |
503 NO_FROM_PRICE / NO_TO_PRICE / SIGNING_DISABLED, 500 NO_VAULT | Not now: another token or chain, or try much later. |
Every code: Errors.
3. Pay
One transaction on the FROM chain. Recipients and amounts are not in it; they live in the signed quote. Pick the shape by the FROM chain's family and the token.
EVM, native coin
{
"to": "<quote.vault>",
"function": "pay(bytes32 quoteId, uint256 deadline)",
"args": ["<quote.quoteId>", "<quote.deadline>"],
"value": "<quote.fromTotal>"
}EVM, ERC-20 (approve, then pay)
[
{ "to": "<quote.fromToken>", "function": "approve(address spender, uint256 amount)",
"args": ["<quote.vault>", "<quote.fromTotal>"], "onlyIf": "allowance(sender, quote.vault) < fromTotal" },
{ "to": "<quote.vault>", "function": "payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)",
"args": ["<quote.fromToken>", "<quote.fromTotal>", "<quote.quoteId>", "<quote.deadline>"], "value": "0" }
]Ethereum-mainnet USDT: if the allowance is non-zero but too small, approve(vault, 0) first.
EVM, ERC-20 with EIP-2612 permit (one transaction)
{
"to": "<quote.vault>",
"function": "payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)",
"args": ["<quote.fromToken>", "<quote.fromTotal>", "<quote.quoteId>", "<quote.deadline>", "<permitDeadline>", "<v>", "<r>", "<s>"],
"permit": { "owner": "<sender>", "spender": "<quote.vault>", "value": "<quote.fromTotal>" }
}Only for tokens that implement permit (native USDC does, Ethereum USDT does not). Full example: Examples → ERC-20 with permit.
ABI for all EVM variants:
const depositoryAbi = parseAbi([
'function pay(bytes32 quoteId, uint256 deadline) payable',
'function payWithToken(address token, uint256 amount, bytes32 quoteId, uint256 deadline)',
'function payWithTokenPermit(address token, uint256 amount, bytes32 quoteId, uint256 deadline, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)',
])Reverts: Expired (after deadline), MissingQuote, AmountTooLow / ZeroAmount, TokenTransferMismatch, TokenNotAllowed, DirectPaymentNotAllowed (plain transfer, no call), EnforcedPause. A reverted transaction pays nothing (gas aside) and does not consume the quote.
Solana (family: "solana")
{
"feePayer": "<sender>",
"instructions": [
"SPL only: ATA CreateIdempotent(payer = sender, owner = quote.vault, mint = quote.fromToken)",
"native SOL: SystemProgram.transfer(from = sender, to = quote.vault, lamports = quote.fromTotal)",
"SPL token: TransferChecked(sender ATA -> quote.vault ATA, mint = quote.fromToken, amount = quote.fromTotal, decimals)",
"Memo(program MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr, data = UTF-8 quote.quoteId, no signer accounts)"
],
"trackRef": "<transaction signature>"
}SPL tokens use the classic Token program. Keep about 0.003 SOL for the fee and the treasury's token-account rent. Payments are indexed at finalized, typically 15–30 s after confirmation.
TON (family: "ton")
{
"from": "<sender wallet>",
"message": {
"to": "<quote.vault, the UQ… address as returned>",
"value": "<quote.fromTotal> nanotons",
"bounce": false,
"body": "text comment = <quote.quoteId> (op 0 + UTF-8, one cell)"
},
"sendMode": 3,
"validUntil": "<quote.deadline>",
"trackRef": "<quote.quoteId>"
}Mode 3 = PAY_GAS_SEPARATELY | IGNORE_ERRORS (mode 1 also works). Mode 0 takes the forward fee out of the value, the treasury receives less than fromTotal, and the payment is held for review. @ton/ton does not set it for you: pass sendMode explicitly. One mnemonic yields a different address per wallet version (W5 / v4r2); quote the address you actually pay from. Payments are indexed 10–20 s after they land. Full shape and rules: Paying from Solana & TON.
4. Track
GET https://drip.tools/api/order?from_tx_hash=<ref>, where ref is the EVM transaction hash, the Solana transaction signature, or the quoteId on TON.
{
"data": [
{
"status": "completed",
"id": "abc123…",
"from": { "chainId": "8453", "symbol": "ETH", "amount": "0.00051755", "address": "0x<payer>", "txHash": "0x<payment>" },
"to": { "chainId": "421614", "symbol": "ETH", "amount": "5.00000000", "address": "0x<recipient>", "txHash": "0x<payout>", "ts": 1784120990 }
}
],
"refund": null
}| Field | Meaning |
|---|---|
data | One order per recipient; [] until the payment is indexed. |
status | processing → completed | failed. pending is reserved and never produced. |
to.txHash | The testnet payout transaction; null until dispensed. Proof of delivery. |
refund | null, or { "status": "refund_pending" | "refunded", "reason", "from", "refundTxHash", "refundedAt" }. |
Invariants
Real money
A payment cannot be cancelled. A payment that breaks invariants 1–4 does not become an order and is not refunded automatically.
- Pay exactly
fromTotalbase units ofquote.fromTokentoquote.vault, from the wallet you quoted assender, beforedeadline; never round, pad or convert the amount. - One payment carries exactly one
quoteId, and onequoteIdis paid by exactly one payment. - Never pay a quote twice: once a payment transaction is broadcast, only track it, even if the receipt wait failed or timed out.
- A payment with the wrong payer, token or amount, or a second payment of an honoured quote, is held for operator review with no automatic refund; recover it only via support with the transaction hash.
- A quote is free and non-binding and expires 180 s after issue, so request it immediately before paying and request a new one if fewer than 20 s of it remain.
- A late payment reverts with
Expiredon EVM (nothing paid) and, on Solana / TON, lands and is refunded with reasonexpiredinstead of becoming an order. - The minimum order is $1 for one recipient and $5 for a multisend (more than one recipient), else
BELOW_MIN_TOTAL. - One quote holds up to 10 000 recipients paid by one transaction; split larger batches into several quotes.
- TO amounts sit on a 4-decimal grid of the TO token (whole sats on Bitcoin), and every recipient on a Solana TO chain gets at least 0.001 SOL.
- Skip chains with
isEnabled: falseorisPaused: true, and FROM chains withoutcontracts.depository. - Rate limits are 600 requests / min per IP on every route, 10 quotes / min per IP and 30 quotes / min per
sender, and a429answers{ "error": "RATE_LIMITED", "retryAfterSec": n }with aRetry-Afterheader to sleep on. chainIdis a string in/api/chainsand in order objects but a number inPOST /api/quote, so coerce before comparing.- EVM addresses compare case-insensitively (quotes return lowercase, orders checksummed), Solana base58 keys are case-sensitive and never re-cased, and a TON payer is normalized to the non-bounceable
UQ…form. - Quote amounts are base-unit integer strings while order amounts are human decimals with varying trailing zeros, so parse them as decimals and never compare them as strings.
- Read the pay target at runtime (
contracts.depositoryorquote.vault) and never send funds to it without a quote.
To build a TO amount on the grid: BigInt(Math.round(x * 1e4)) * 10n ** BigInt(decimals - 4) for a token with 4 or more decimals.
When to stop polling
- Poll every 5 s. EVM payouts land a median 3 s after the payment is indexed; Solana adds 15–30 s of finality, TON 10–20 s.
- Stop when
data.length > 0and every order iscompletedorfailed. Afailedorder's FROM slice is refunded (reason: "failed_order", recorded within a few minutes; for a partial batch capped so the delivered part still cost at least $1). - Stop when
datais empty andrefundis set: the payment was refused (expired,unknown_quote,unattributed,ambiguous_memo,bad_signature) and no order will appear. - Stop when the EVM receipt says
reverted: nothing was paid; quote again if needed. - Stop and report when
dataandrefundstay empty well past the deadline (10 minutes is generous): the payment was most likely held for review (invariant 4). - TON:
from_tx_hash=<quoteId>resolves only once the payment is honoured (afterwards the order'sfrom.txHash, 64 hex characters, works too). A refused TON payment never resolves byquoteId; find its refund inGET /api/orders?address=<UQ…>&view=batches(rows withisRefund: true).GET /api/orders/loaddoes not resolve aquoteId; passdata[0].id. - Large batches:
&paginate=1&limit=0returns onlysummary: { recipients, statusCounts, fromTotal, toTotal, completedTs, lastPayout }; stop whensummary.recipients > 0andsummary.completedTsis non-null. - Many payments:
POST /api/orders/statuswith{ "ids": [...] }(500 ids at most) instead of one lookup per payment. Order ids are case-sensitive. - Optional:
GET /api/orders/load?ref=<txHash|orderId>for a queue ETA, andGET /api/orders/stream?address=<payer>(Server-Sent Events, not rate-limited) whoseorders-changedevent lists FROM tx hashes to re-fetch. If the stream answers503, poll.
Details: Orders, Order lifecycle & refunds.
Machine-readable resources
| Resource | Content |
|---|---|
llms.txt | Index of every page, one line each. |
llms-full.txt | Every page in one markdown file. |
openapi.json | The whole API as OpenAPI 3.1. |
Any page + .md | The page as markdown, e.g. https://docs.drip.tools/guide/agents.md. |
| Examples | Runnable JavaScript and Python scripts for every payment shape; DRY_RUN=1 requests a real quote and builds the payment but sends nothing. |
Recipe: one swap, EVM native coin
JavaScript with viem. Pays ETH on Base, delivers 5 testnet ETH on Arbitrum Sepolia. Replace the four constants; everything else is the contract above.
import { createPublicClient, createWalletClient, http, fallback, parseAbi } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
const API = 'https://drip.tools/api'
const NATIVE = '0x0000000000000000000000000000000000000000'
const FROM_CHAIN_ID = 8453 // Base (number in POST /quote)
const TO_CHAIN_ID = 421614 // Arbitrum Sepolia
const RECIPIENT = '0x…' // the user's testnet wallet
const TO_AMOUNT = '5000000000000000000' // 5 ETH in wei, on the 4-decimal grid
const account = privateKeyToAccount(process.env.PRIVATE_KEY) // the payer = `sender`
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function api(path, init) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${API}${path}`, init)
const body = await res.json().catch(() => null) // the edge 429 has no JSON body
if (res.status === 429 || body?.error === 'WARMING_UP') {
await sleep(1000 * Number(res.headers.get('retry-after') ?? 2))
continue
}
if (!res.ok) throw new Error(`${res.status} ${body?.code ?? body?.error}`)
return body
}
throw new Error('still rate limited')
}
// 1. Discover
const chains = await api('/chains?unwrap=true')
const from = chains.find((c) => c.chainId === String(FROM_CHAIN_ID))
const to = chains.find((c) => c.chainId === String(TO_CHAIN_ID))
if (!from?.contracts?.depository || !from.isEnabled || from.isPaused) throw new Error('FROM chain not payable')
if (!to?.isTestnet || !to.isEnabled || to.isPaused) throw new Error('TO chain not available')
// 2. Quote, right before paying
const quote = await api('/quote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chainId: FROM_CHAIN_ID, fromToken: NATIVE, sender: account.address,
toChainId: TO_CHAIN_ID, toToken: NATIVE, recipients: [RECIPIENT], toAmounts: [TO_AMOUNT],
}),
})
if (quote.deadline - Date.now() / 1000 < 20) throw new Error('quote nearly expired: quote again')
// 3. Pay once, exactly fromTotal
const transport = fallback(from.rpc.map((url) => http(url))) // rpc[0] first
const wallet = createWalletClient({ account, transport })
const hash = await wallet.writeContract({
address: quote.vault,
abi: parseAbi(['function pay(bytes32 quoteId, uint256 deadline) payable']),
functionName: 'pay',
args: [quote.quoteId, BigInt(quote.deadline)],
value: BigInt(quote.fromTotal),
chain: null,
})
// From here on this quote is spent: never pay it again, only track `hash`.
const receipt = await createPublicClient({ transport })
.waitForTransactionReceipt({ hash }).catch(() => null) // optional; the API is the source of truth
if (receipt?.status === 'reverted') throw new Error('reverted: nothing was paid')
// 4. Track until every order is terminal
const terminal = (o) => o.status === 'completed' || o.status === 'failed'
const stopAt = Date.now() + 10 * 60_000 // then report: likely held for review
while (Date.now() < stopAt) {
const { data, refund } = await api(`/order?from_tx_hash=${hash}`)
if (data.length && data.every(terminal)) {
for (const o of data) console.log(o.to.address, o.status, o.to.txHash)
if (refund) console.log('refund', refund.status, refund.reason) // failed slices
break
}
if (!data.length && refund) { console.log('refused', refund.reason); break }
await sleep(5000)
}For ERC-20, Solana and TON, swap step 3 for the matching shape above; the other steps are identical except the track ref (Solana signature, TON quoteId).