Skip to content
Dagon Inference

Dagon Inference / Buy LLM API credits from an agent's own wallet

Buy credits from an agent (wallet)

Published · Last updated

An agent with a Solana wallet can fund its own key without a browser. POST https://inference.dagon.so/v1/credits/purchase returns an unsigned USDC transaction for that wallet; the agent signs it, sends it to Solana, and calls POST https://inference.dagon.so/v1/credits/purchase/confirm, which credits the key once the payment is final. No email is asked, and the private key never leaves the agent.

How does a wallet purchase work?

  1. POST /v1/credits/purchase with Authorization: Bearer gk_... (the key to fund) and {"amount_usd": 20, "payer_wallet": "<base58 address>", "chain": "solana"}. The answer has purchase_id, amount_total, currency (usdc), unsigned_transaction (base64), blockhash, last_valid_block_height, fee_sponsored, expires_at and confirm_url.
  2. Deserialize the transaction, sign it with the wallet and send it to Solana. It moves exactly amount_total USDC from the wallet to our treasury, with the purchase id in a memo. Its blockhash expires about a minute after step 1; if it does, call step 1 again.
  3. POST /v1/credits/purchase/confirm with {"purchase_id": "...", "signature": "<base58 transaction signature>"}. The payment service waits for the transaction to finalize, which can take up to about 45 seconds, then the key is credited and the answer is {"status": "credited", "credited_usd": 20, "balance": ...}. A 202 with {"status": "pending"} means it is not final yet: call confirm again after retry_after_s. Confirm is safe to repeat.

GET /v1/credits/purchase/{purchase_id} reads the status at any time: open (awaiting payment), credited, expired, refused or pending. Top ups are $5 to $1,000 in whole cents.

Two cards, one slot
insert card
A prepaid key (gk_) is funded with USDC through hosted Zoneless checkout, $5 to $1,000 per top-up. A private lease (lk_) comes from zkAPI for a deposit the gateway never learns: it carries only a spend limit ($1.00, $2.00, $3.00, $4.50, $6.00, at most $10.00) and a short life, 300 s by default. Past the slot the gateway meters both keys the same way.

Does the wallet need SOL?

Not when fee_sponsored is true. Then the transaction's fee payer is our checkout fee payer, which has already signed it and pays the network fee and any account rent; the wallet signs only as the owner of the USDC. Keep that signature: sign with the wallet alone, as the scripts below do, without rebuilding the transaction or changing its fee payer. When fee_sponsored is false, the wallet is the fee payer and needs a little SOL: the network fee (5,000 lamports per signature), plus rent if our treasury's USDC account had to be created.

Either way the wallet must hold the USDC in its own USDC token account, and it must be a key pair that can sign, not a program address such as a multisig vault. A fee-sponsored purchase may also send {"purchase_id", "signed_transaction"} (base64) to confirm instead of a signature, and the payment service broadcasts it.

TypeScript (@solana/web3.js)

// npm install @solana/web3.js    then: node buy-credits.ts (Node 24) or npx tsx buy-credits.ts
import { readFileSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { Connection, Keypair, VersionedTransaction } from "@solana/web3.js";

const API = "https://inference.dagon.so/v1";
const KEY = process.env.DAGON_KEY; // the gk_ key to fund
// A solana-keygen JSON keypair file.
const wallet = Keypair.fromSecretKey(Uint8Array.from(JSON.parse(readFileSync(process.env.SOLANA_KEYPAIR ?? "", "utf8"))));
const rpc = new Connection(process.env.SOLANA_RPC_URL ?? "https://api.mainnet-beta.solana.com", "confirmed");

async function call(path: string, body: object) {
  const res = await fetch(`${API}${path}`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${json.error?.code}: ${json.error?.message}`);
  return { status: res.status, json };
}

// 1. Open the purchase: the gateway returns the USDC transaction for this wallet.
const { json: p } = await call("/credits/purchase", { amount_usd: 20, payer_wallet: wallet.publicKey.toBase58(), chain: "solana" });

// 2. Sign as the wallet (a fee-sponsored transaction keeps the fee payer's signature) and send it.
const tx = VersionedTransaction.deserialize(Buffer.from(p.unsigned_transaction, "base64"));
tx.sign([wallet]);
const signature = await rpc.sendRawTransaction(tx.serialize());
await rpc.confirmTransaction({ signature, blockhash: p.blockhash, lastValidBlockHeight: p.last_valid_block_height }, "confirmed");

// 3. Confirm: the key is credited once the payment is final. 202 means not yet: ask again.
for (;;) {
  const { status, json } = await call("/credits/purchase/confirm", { purchase_id: p.purchase_id, signature });
  if (status === 200) {
    console.log(`credited $${json.credited_usd}, balance $${json.balance}`);
    break;
  }
  await sleep((json.retry_after_s ?? 5) * 1000);
}

Set DAGON_KEY, SOLANA_KEYPAIR (path to the wallet's keypair file) and, optionally, SOLANA_RPC_URL.

Python (solders and solana-py)

# pip install solders solana httpx    then: python buy_credits.py
import asyncio, base64, json, os
import httpx
from solana.rpc.async_api import AsyncClient
from solders.keypair import Keypair
from solders.message import to_bytes_versioned
from solders.transaction import VersionedTransaction

API = "https://inference.dagon.so/v1"
KEY = os.environ["DAGON_KEY"]  # the gk_ key to fund
with open(os.environ["SOLANA_KEYPAIR"]) as f:  # a solana-keygen JSON keypair file
    wallet = Keypair.from_bytes(bytes(json.load(f)))


async def call(http, path, body):
    r = await http.post(f"{API}{path}", json=body, headers={"Authorization": f"Bearer {KEY}"}, timeout=120)
    if r.status_code >= 400:
        err = r.json()["error"]
        raise SystemExit(f"{r.status_code} {err['code']}: {err['message']}")
    return r.status_code, r.json()


async def main():
    rpc_url = os.environ.get("SOLANA_RPC_URL", "https://api.mainnet-beta.solana.com")
    async with httpx.AsyncClient() as http, AsyncClient(rpc_url) as rpc:
        # 1. Open the purchase: the gateway returns the USDC transaction for this wallet.
        _, p = await call(http, "/credits/purchase", {"amount_usd": 20, "payer_wallet": str(wallet.pubkey()), "chain": "solana"})

        # 2. Sign as the wallet, keeping the fee payer's signature on a fee-sponsored transaction, and send it.
        tx = VersionedTransaction.from_bytes(base64.b64decode(p["unsigned_transaction"]))
        signers = list(tx.message.account_keys)[: tx.message.header.num_required_signatures]
        sigs = list(tx.signatures)
        sigs[signers.index(wallet.pubkey())] = wallet.sign_message(to_bytes_versioned(tx.message))
        tx = VersionedTransaction.populate(tx.message, sigs)
        sig = (await rpc.send_raw_transaction(bytes(tx))).value
        await rpc.confirm_transaction(sig, "confirmed", last_valid_block_height=p["last_valid_block_height"])

        # 3. Confirm: the key is credited once the payment is final. 202 means not yet: ask again.
        while True:
            status, r = await call(http, "/credits/purchase/confirm", {"purchase_id": p["purchase_id"], "signature": str(sig)})
            if status == 200:
                print(f"credited ${r['credited_usd']}, balance ${r['balance']}")
                return
            await asyncio.sleep(r.get("retry_after_s", 5))


asyncio.run(main())

What can go wrong?

  • 400 invalid_wallet: payer_wallet is not a Solana wallet address that can sign.
  • 400 amount_out_of_range or invalid_amount: outside $5 to $1,000, or not whole cents.
  • 403 payment_refused: the wallet is on the OFAC sanctions list. A payment sent from one anyway is held, not credited.
  • 451 location_blocked: the request comes from a location where the service is not offered. The check uses the agent's address, not the gateway's.
  • 400 payment_not_verified: the signature is not a payment of this purchase (wrong amount, failed transaction, or already used).
  • 429: too many purchases or open purchases on one key; see Retry-After.

Before you approve anything, you can check the treasury address the transaction pays against official domains and payment addresses.

Can an agent pay without a key at all?

Yes: with x402 pay per request an agent sends the model request with no key, gets a 402 with the price, and retries with a signed USDC transfer. The first paid response returns a key in x-dagon-key; from then on the agent can top that key up with the purchase calls above, or keep paying per request. Use the purchase when you want to load a balance once; use x402 when the agent should pay as it goes.

Common questions

Is an email or account needed?

No. The key itself authorizes the purchase, and the purchase asks for no email.

Can an agent pay from another chain?

The one-shot purchase is Solana only. The hosted checkout from POST /v1/credits/checkout also takes USDC from other chains that Circle CCTP supports, but needs a browser wallet.

Does the gateway see the wallet's private key?

No. The transaction is signed where the agent runs; the gateway and the payment service only ever see the public address and the signed transaction.

Create a key in a minute.

Get a key