Spindle
ASSEMBLING THE NETWORK001
COMPUTE / VERIFY / CHOOSE
Developers

Build on Spindle

An OpenAI-compatible API on verifiable GPU compute. Pay with an API key or per call with x402, and check every job against a signed receipt.

Quickstart

  1. 01Connect your walletOpen the dashboard and sign in with your wallet.
  2. 02Deposit USDGDashboard, Developers tab: approve and deposit USDG into your buyer balance. A deposit counts after 12 blocks.
  3. 03Create an API keyCreate a key in the same tab. It is shown once and looks like sk-spdl-...
  4. 04Call the APIPoint any OpenAI SDK at the Spindle base URL and pass the key as the bearer token.

The base URL is https://api.spindleneobank.com/v1. In the OpenAI SDKs set base_url = "https://api.spindleneobank.com/v1" (Python) or baseURL (Node) and use your sk-spdl-... key as the API key.

examples/curl.sh
curl.sh
#!/usr/bin/env bash
# Spindle API-key quickstart with curl: models, chat, streaming chat, embeddings.
#   SPINDLE_API_URL  default https://api.spindleneobank.com
#   SPINDLE_API_KEY  required (sk-spdl-...), from Dashboard > Developers
#   SPINDLE_MODEL / SPINDLE_EMBED_MODEL  optional, otherwise the first available chat / embeddings model
set -euo pipefail

API="${SPINDLE_API_URL:-https://api.spindleneobank.com}/v1"
: "${SPINDLE_API_KEY:?set SPINDLE_API_KEY (create one in Dashboard > Developers)}"
AUTH="Authorization: Bearer $SPINDLE_API_KEY"
fail() { echo "FAIL curl.sh: $*" >&2; exit 1; }

echo "== GET /models"
MODELS=$(curl -fsS "$API/models") || fail "GET /models"
echo "$MODELS" | jq -r '.data[] | "\(.id)\t\(.kind)\tavailable=\(.available)"'
CHAT_MODEL="${SPINDLE_MODEL:-$(echo "$MODELS" | jq -r '[.data[] | select(.kind=="chat" and .available)][0].id // empty')}"
EMBED_MODEL="${SPINDLE_EMBED_MODEL:-$(echo "$MODELS" | jq -r '[.data[] | select(.kind=="embeddings" and .available)][0].id // empty')}"
[ -n "$CHAT_MODEL" ] || fail "no available chat model (is a node connected?)"

echo "== POST /chat/completions (non-stream) model=$CHAT_MODEL"
RES=$(curl -fsS -D /tmp/spindle-headers.$$ "$API/chat/completions" -H "$AUTH" -H 'content-type: application/json' \
  -d "{\"model\":\"$CHAT_MODEL\",\"max_tokens\":64,\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in five words.\"}]}") || fail "chat"
echo "$RES" | jq -r '.choices[0].message.content'
JOB=$(grep -i '^x-spindle-job-id:' /tmp/spindle-headers.$$ | tr -d '\r' | cut -d' ' -f2)
grep -i '^x-spindle-\(job-id\|receipt\|charge-micros\):' /tmp/spindle-headers.$$ | tr -d '\r'
rm -f /tmp/spindle-headers.$$
[ -n "$JOB" ] || fail "missing X-Spindle-Job-Id"
echo "$RES" | jq -e '.spindle.chargeMicros' >/dev/null || fail "missing spindle.chargeMicros"

echo "== POST /chat/completions (stream)"
STREAM=$(curl -fsSN "$API/chat/completions" -H "$AUTH" -H 'content-type: application/json' \
  -d "{\"model\":\"$CHAT_MODEL\",\"stream\":true,\"max_tokens\":64,\"messages\":[{\"role\":\"user\",\"content\":\"Count to five.\"}]}") || fail "stream"
echo "$STREAM" | grep '^data: {' | sed 's/^data: //' | jq -rj '.choices[0].delta.content // empty'; echo
echo "$STREAM" | grep -q '^data: \[DONE\]' || fail "stream did not end with [DONE]"
echo "$STREAM" | grep '^data: {' | sed 's/^data: //' | jq -e 'select(.spindle) | .spindle.jobId' >/dev/null || fail "no spindle meta in final chunk"

if [ -n "$EMBED_MODEL" ]; then
  echo "== POST /embeddings model=$EMBED_MODEL"
  curl -fsS "$API/embeddings" -H "$AUTH" -H 'content-type: application/json' \
    -d "{\"model\":\"$EMBED_MODEL\",\"input\":[\"hello\",\"world\"]}" | jq -e '.data | length == 2' >/dev/null || fail "embeddings"
else
  echo "== skipping embeddings (no embeddings model available)"
fi

echo "PASS curl.sh (job $JOB, receipt $API/receipts/$JOB)"
examples/openai-node.mjs
openai-node.mjs
// Official OpenAI Node SDK against Spindle. Needs SPINDLE_API_KEY. Run: node openai-node.mjs
import OpenAI from 'openai';

const baseURL = `${process.env.SPINDLE_API_URL ?? 'https://api.spindleneobank.com'}/v1`;
const apiKey = process.env.SPINDLE_API_KEY;
if (!apiKey) throw new Error('set SPINDLE_API_KEY');
const client = new OpenAI({ baseURL, apiKey });

try {
  const models = (await client.models.list()).data;
  const model = process.env.SPINDLE_MODEL ?? models.find((m) => m.kind === 'chat' && m.available)?.id;
  if (!model) throw new Error('no available chat model (is a node connected?)');

  // Non-stream. withResponse() exposes the Spindle headers.
  const { data, response } = await client.chat.completions
    .create({ model, max_tokens: 64, messages: [{ role: 'user', content: 'Say hello in five words.' }] })
    .withResponse();
  const jobId = response.headers.get('x-spindle-job-id');
  console.log(data.choices[0].message.content);
  console.log('job', jobId, 'receipt', response.headers.get('x-spindle-receipt'));
  console.log('charge (micros USDG)', response.headers.get('x-spindle-charge-micros'), data.spindle);
  if (!jobId) throw new Error('missing X-Spindle-Job-Id');

  // Stream. The final chunk carries usage and spindle:{ jobId, chargeMicros, receiptUrl }.
  const stream = await client.chat.completions.create({
    model,
    stream: true,
    stream_options: { include_usage: true },
    max_tokens: 64,
    messages: [{ role: 'user', content: 'Count to five.' }],
  });
  let meta;
  for await (const chunk of stream) {
    process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
    if (chunk.spindle) meta = chunk.spindle;
  }
  console.log('\nstream job', meta?.jobId);
  if (!meta?.jobId) throw new Error('no spindle meta in the final chunk');

  console.log(`PASS openai-node.mjs (job ${jobId})`);
} catch (err) {
  console.error('FAIL openai-node.mjs:', err.message ?? err);
  process.exit(1);
}
examples/openai-python.py
openai-python.py
"""Official OpenAI Python SDK against Spindle. Needs SPINDLE_API_KEY. Run: python openai-python.py"""
import os
import sys

from openai import OpenAI

base_url = os.environ.get("SPINDLE_API_URL", "https://api.spindleneobank.com") + "/v1"
api_key = os.environ.get("SPINDLE_API_KEY")
if not api_key:
    sys.exit("FAIL openai-python.py: set SPINDLE_API_KEY")
client = OpenAI(base_url=base_url, api_key=api_key)

try:
    models = client.models.list().data
    model = os.environ.get("SPINDLE_MODEL") or next(
        (m.id for m in models if getattr(m, "kind", None) == "chat" and getattr(m, "available", False)), None
    )
    if not model:
        raise RuntimeError("no available chat model (is a node connected?)")

    # Non-stream. with_raw_response exposes the Spindle headers.
    raw = client.chat.completions.with_raw_response.create(
        model=model, max_tokens=64, messages=[{"role": "user", "content": "Say hello in five words."}]
    )
    completion = raw.parse()
    job_id = raw.headers.get("x-spindle-job-id")
    print(completion.choices[0].message.content)
    print("job", job_id, "receipt", raw.headers.get("x-spindle-receipt"))
    print("charge (micros USDG)", raw.headers.get("x-spindle-charge-micros"))
    if not job_id:
        raise RuntimeError("missing X-Spindle-Job-Id")

    # Stream. The final chunk carries usage and spindle={jobId, chargeMicros, receiptUrl}.
    meta = None
    stream = client.chat.completions.create(
        model=model, stream=True, max_tokens=64, messages=[{"role": "user", "content": "Count to five."}]
    )
    for chunk in stream:
        if chunk.choices and chunk.choices[0].delta.content:
            print(chunk.choices[0].delta.content, end="", flush=True)
        extra = getattr(chunk, "model_extra", None) or {}
        if extra.get("spindle"):
            meta = extra["spindle"]
    print()
    if not meta:
        raise RuntimeError("no spindle meta in the final chunk")
    print(f"PASS openai-python.py (job {job_id})")
except Exception as err:  # noqa: BLE001
    sys.exit(f"FAIL openai-python.py: {err}")

All files live in app/examples/ with a README; each prints a PASS line and exits non-zero on failure.

Models and pricing

Prices are read live from the API in your browser. Buyer prices include the 20% platform share; you pay the exact metered amount for each call.

Loading live models and prices from https://api.spindleneobank.com/v1 ...

Endpoints

Authenticate buyer calls with Authorization: Bearer sk-spdl-..., or pay per call with x402. Public routes need no authentication.

Spindle headers
HeaderMeaning
X-Spindle-LaneRequest header on chat completions: `open` (default) or `sealed` (in testing).
X-Spindle-Job-IdResponse header: the job id, also the receipt id.
X-Spindle-ReceiptResponse header: `/v1/receipts/<jobId>`.
X-Spindle-Charge-MicrosResponse header (non-stream): the exact charge in USDG micros. Streams carry it in the final chunk.

GET/v1/models

Models that are enabled. `available` is true when at least one online node serves the model.

Auth: none

response
{ "object": "list", "data": [{
  "id": "llama-3.1-8b-instruct", "object": "model", "owned_by": "spindle",
  "lanes": ["open"], "kind": "chat", "available": true,
  "pricing": { "unitKind": "tokens_1m", "buyerMicrosPerUnit": "200000" }
}] }

POST/v1/chat/completions

OpenAI chat completions, streaming or not. Every response carries a `spindle` object with the job id, the exact charge and the receipt URL.

Auth: API key, X-PAYMENT or X-Spindle-Session

request
{ "model": "llama-3.1-8b-instruct", "stream": false, "max_tokens": 256,
  "messages": [{ "role": "user", "content": "Say hello in five words." }] }
response
{ "id": "chatcmpl-...", "object": "chat.completion", "model": "llama-3.1-8b-instruct",
  "choices": [{ "index": 0, "message": { "role": "assistant", "content": "Hello there, nice to meet you." }, "finish_reason": "stop" }],
  "usage": { "prompt_tokens": 14, "completion_tokens": 8, "total_tokens": 22 },
  "spindle": { "jobId": "0b7e0f3e-...", "chargeMicros": "5", "receiptUrl": "/v1/receipts/0b7e0f3e-..." } }
  • Streaming (`"stream": true`) is standard SSE. The final chunk includes `usage` and `spindle: { jobId, chargeMicros, receiptUrl }`, then `data: [DONE]`.
  • Accepted fields: model, messages, stream, max_tokens, temperature, top_p, stop, seed.

POST/v1/embeddings

OpenAI embeddings. `input` is a string or an array of strings.

Auth: API key, X-PAYMENT or X-Spindle-Session

request
{ "model": "bge-small-en", "input": ["hello", "world"] }
response
{ "object": "list", "model": "bge-small-en",
  "data": [{ "object": "embedding", "index": 0, "embedding": [0.012, -0.04, "..."] }],
  "usage": { "prompt_tokens": 2, "total_tokens": 2 },
  "spindle": { "jobId": "...", "chargeMicros": "1", "receiptUrl": "/v1/receipts/..." } }

POST/v1/images/generations

Image generation. Served only when a connected node advertises the images capability; otherwise `503 NO_CAPACITY`.

Auth: API key, X-PAYMENT or X-Spindle-Session

request
{ "model": "sdxl", "prompt": "a lighthouse at dusk", "n": 1, "size": "1024x1024" }
response
{ "created": 1790000000, "data": [{ "b64_json": "..." }],
  "spindle": { "jobId": "...", "chargeMicros": "40000", "receiptUrl": "/v1/receipts/..." } }

GET/v1/receipts/:jobId

The node-signed receipt for a job. Public by job id, which is an unguessable uuid.

Auth: none

response
{ "jobId": "0b7e0f3e-...", "nodeId": "...", "onchainNodeId": "7", "nodeKey": "0x...20 bytes...",
  "lane": "open", "model": "llama-3.1-8b-instruct", "usage": { "p": 14, "c": 8 },
  "cose": "<base64 COSE_Sign1>", "payload": { "...decoded..." },
  "verified": true, "reason": null, "attestationRef": null, "epoch": 12 }
  • `epoch` is null until the job is included in an epoch (epochs close hourly).

GET/v1/epochs/latest

The latest epoch and its Merkle root.

Auth: none

response
{ "epoch": 12, "root": "0x...", "totalEarned": "123456789",
  "postedAt": "2026-10-23T10:00:00Z", "txHash": "0x...", "claimableAt": "2026-10-23T11:00:00Z" }

GET/v1/epochs/:n/tree

The full Merkle tree of epoch n in OpenZeppelin StandardMerkleTree dump format. Immutable once posted.

Auth: none

response
{ "format": "standard-v1", "leafEncoding": ["address", "uint256"],
  "tree": ["0x...root...", "0x..."], "values": [{ "value": ["0xowner...", "4000000"], "treeIndex": 3 }] }

x402 per-call payment

No key to create: send the request, receive a price, sign it with your wallet and retry. Payment uses Permit2 on USDG with a Spindle witness, on chain 4663.

1. The challenge

A request with no authentication gets 402. The PAYMENT-REQUIRED header holds base64 of this JSON, and the body carries the same JSON.

PAYMENT-REQUIRED (decoded)
{
  "x402Version": 2,
  "resource": { "url": "https://api.example/v1/chat/completions", "description": "Spindle inference", "mimeType": "application/json" },
  "accepts": [{
    "scheme": "exact",
    "network": "eip155:4663",
    "asset": "<USDG address>",
    "amount": "25000",
    "payTo": "<BuyerVault address>",
    "maxTimeoutSeconds": 300,
    "extra": {
      "assetTransferMethod": "permit2-witness",
      "permit2": "0x000000000022D473030F116dDEE9F6B43aC78BA3",
      "spender": "<BuyerVault address>",
      "witnessType": "SpindlePayment(bytes32 paymentId,uint256 surcharge,uint256 validAfter)",
      "paymentId": "0x...32 bytes...",
      "surcharge": "5000",
      "validAfter": "1790000000",
      "deadline": "1790003600",
      "nonce": "<random 248-bit uint256>",
      "priceBasis": { "unitKind": "tokens_1m", "buyerMicrosPerUnit": "200000", "maxTokens": 1024 }
    }
  }]
}

2. Approve Permit2 once

Approve USDG to Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3) from your wallet. It is a normal transaction, so you pay the gas, and it is needed once per wallet. See ensurePermit2Allowance in x402-lib.ts below.

3. Sign the SpindlePayment witness

EIP-712 typed data
domain:      { name: "Permit2", chainId: 4663, verifyingContract: <permit2> }
primaryType: PermitWitnessTransferFrom
message: {
  permitted: { token: <asset>, amount: <cap = accepts[0].amount> },
  spender:   <BuyerVault>,
  nonce:     <extra.nonce>,
  deadline:  <extra.deadline>,
  witness:   { paymentId, surcharge, validAfter }       // type SpindlePayment
}
types.SpindlePayment = [
  { name: "paymentId",  type: "bytes32" },
  { name: "surcharge",  type: "uint256" },
  { name: "validAfter", type: "uint256" }
]

4. Retry with X-PAYMENT

headers
X-PAYMENT: base64(JSON({
  "x402Version": 2, "scheme": "exact", "network": "eip155:4663",
  "payload": {
    "payer": "0x...", "paymentId": "0x...",
    "permit":  { "permitted": { "token": "0x...", "amount": "25000" }, "nonce": "...", "deadline": "..." },
    "witness": { "paymentId": "0x...", "surcharge": "5000", "validAfter": "..." },
    "signature": "0x..."
  }
}))

X-PAYMENT-RESPONSE: base64(JSON({ "paymentId": "0x...", "used": "9000", "surcharge": "5000", "settlement": "batched" }))
  • Cap semantics: the amount you sign is a cap (maximum job charge plus the surcharge). You pay the actual usage, never more than the cap.
  • Surcharge: a small per-payment fee that covers settlement gas. It is paid once per payment or session and goes to the treasury.
  • Settlement is batched: the keeper settles closed payments every 10 minutes, or as soon as 20 are waiting. Your signature is not spent until then.
  • Exposure cap: Spindle limits the total unsettled caps per wallet (default 20 USDG). Above it you get a 402 until earlier payments settle.
  • The signature deadline must be at least 30 minutes ahead so the batch has time to settle.
  • Permit2 approval: USDG has no gasless approval, so approve USDG to Permit2 once from your wallet (a normal transaction, you pay gas). After that every payment is a signature.
  • If x402 is paused you get a 402 that says to use an API key with a deposited balance.
examples/x402-client.ts
x402-client.ts
// x402 "exact": pay per call with a signed Permit2 cap, no API key needed.
//   PRIVATE_KEY=0x... bun x402-client.ts      (dev: an anvil test key; SPINDLE_API_URL, RPC_URL optional)
import { privateKeyToAccount } from 'viem/accounts';
import {
  API,
  buildPaymentHeader,
  chatBody,
  ensurePermit2Allowance,
  firstChatModel,
  fromB64json,
  readChallenge,
} from './x402-lib.ts';

const pk = process.env.PRIVATE_KEY as `0x${string}` | undefined;
if (!pk)
  throw new Error('set PRIVATE_KEY (a wallet holding USDG and a little ETH for the one-time approval)');
const account = privateKeyToAccount(pk);

try {
  const url = `${API}/v1/chat/completions`;
  const body = JSON.stringify(chatBody(await firstChatModel(), 'Say hello in five words.'));
  const headers = { 'content-type': 'application/json' };

  // 1. No auth: the gateway answers 402 with the price (cap = max charge + surcharge).
  const challengeRes = await fetch(url, { method: 'POST', headers, body });
  const accept = await readChallenge(challengeRes);
  console.log(
    `402 challenge: cap ${accept.amount} micros (surcharge ${accept.extra.surcharge}), pay to ${accept.payTo}`,
  );

  // 2. One-time Permit2 approval of USDG (a normal transaction, you pay gas).
  const chainId = Number(accept.network.split(':')[1]);
  const approval = await ensurePermit2Allowance(
    account,
    accept.asset,
    accept.extra.permit2,
    BigInt(accept.amount),
    chainId,
  );
  console.log('permit2 allowance:', approval);

  // 3. Sign the SpindlePayment witness and retry the same request. You pay actual usage, up to the cap.
  const xPayment = await buildPaymentHeader(account, accept);
  const res = await fetch(url, { method: 'POST', headers: { ...headers, 'x-payment': xPayment }, body });
  if (!res.ok) throw new Error(`paid retry failed: ${res.status} ${await res.text()}`);
  const out = (await res.json()) as {
    choices: { message: { content: string } }[];
    spindle: { jobId: string };
  };
  console.log(out.choices[0]?.message.content);

  const paid = res.headers.get('x-payment-response');
  if (!paid) throw new Error('missing X-PAYMENT-RESPONSE');
  const settlement = fromB64json<{ used: string; surcharge: string; settlement: string }>(paid);
  console.log('X-PAYMENT-RESPONSE', settlement, 'receipt', res.headers.get('x-spindle-receipt'));
  console.log(
    `PASS x402-client.ts (job ${out.spindle.jobId}, used ${settlement.used} micros, settled in batch later)`,
  );
} catch (err) {
  console.error('FAIL x402-client.ts:', err instanceof Error ? err.message : err);
  process.exit(1);
}
examples/x402-lib.ts
x402-lib.ts
// Shared x402 helpers for x402-client.ts and x402-session.ts (viem only, no Spindle packages).
import {
  type Account,
  type Address,
  createPublicClient,
  createWalletClient,
  defineChain,
  type Hex,
  http,
  maxUint256,
  parseAbi,
} from 'viem';

export const API = process.env.SPINDLE_API_URL ?? 'https://api.spindleneobank.com';
export const RPC_URL = process.env.RPC_URL ?? 'http://127.0.0.1:7245';

export interface Accept {
  scheme: 'exact';
  network: string;
  asset: Address;
  amount: string; // cap = max charge + surcharge, USDG micros
  payTo: Address;
  extra: {
    permit2: Address;
    spender: Address;
    paymentId: Hex;
    surcharge: string;
    validAfter: string;
    deadline: string;
    nonce: string;
  };
}
export interface Challenge {
  x402Version: number;
  accepts: Accept[];
}

export const b64json = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64');
export const fromB64json = <T>(s: string): T => JSON.parse(Buffer.from(s, 'base64').toString('utf8')) as T;

/** Reads the 402 challenge from the PAYMENT-REQUIRED header (the body carries the same JSON). */
export async function readChallenge(res: Response): Promise<Accept> {
  if (res.status !== 402) throw new Error(`expected 402, got ${res.status}: ${await res.text()}`);
  const header = res.headers.get('payment-required');
  const challenge = header ? fromB64json<Challenge>(header) : ((await res.json()) as Challenge);
  const accept = challenge.accepts?.[0];
  if (accept?.scheme !== 'exact') throw new Error('no exact scheme in the challenge');
  return accept;
}

const PERMIT2_TYPES = {
  PermitWitnessTransferFrom: [
    { name: 'permitted', type: 'TokenPermissions' },
    { name: 'spender', type: 'address' },
    { name: 'nonce', type: 'uint256' },
    { name: 'deadline', type: 'uint256' },
    { name: 'witness', type: 'SpindlePayment' },
  ],
  TokenPermissions: [
    { name: 'token', type: 'address' },
    { name: 'amount', type: 'uint256' },
  ],
  SpindlePayment: [
    { name: 'paymentId', type: 'bytes32' },
    { name: 'surcharge', type: 'uint256' },
    { name: 'validAfter', type: 'uint256' },
  ],
} as const;

/** The Permit2 typed data for a challenge: you sign a cap, Spindle settles actual usage up to it. */
export function paymentTypedData(a: Accept) {
  const chainId = Number(a.network.split(':')[1]);
  return {
    domain: { name: 'Permit2', chainId, verifyingContract: a.extra.permit2 },
    types: PERMIT2_TYPES,
    primaryType: 'PermitWitnessTransferFrom' as const,
    message: {
      permitted: { token: a.asset, amount: BigInt(a.amount) },
      spender: a.extra.spender,
      nonce: BigInt(a.extra.nonce),
      deadline: BigInt(a.extra.deadline),
      witness: {
        paymentId: a.extra.paymentId,
        surcharge: BigInt(a.extra.surcharge),
        validAfter: BigInt(a.extra.validAfter),
      },
    },
  };
}

/** Signs the witness and returns the X-PAYMENT header value. */
export async function buildPaymentHeader(account: Account, a: Accept): Promise<string> {
  if (!account.signTypedData) throw new Error('account cannot sign typed data');
  const td = paymentTypedData(a);
  const signature = await account.signTypedData(td);
  return b64json({
    x402Version: 2,
    scheme: 'exact',
    network: a.network,
    payload: {
      payer: account.address,
      paymentId: a.extra.paymentId,
      permit: {
        permitted: { token: a.asset, amount: a.amount },
        nonce: a.extra.nonce,
        deadline: a.extra.deadline,
      },
      witness: {
        paymentId: a.extra.paymentId,
        surcharge: a.extra.surcharge,
        validAfter: a.extra.validAfter,
      },
      signature,
    },
  });
}

/** Turns the JSON typed data from POST /v1/x402/sessions (uint256 as strings) into viem input. */
export function reviveTypedData(td: {
  domain: Record<string, unknown>;
  types: Record<string, { name: string; type: string }[]>;
  primaryType: string;
  message: Record<string, unknown>;
}) {
  const revive = (type: string, v: unknown): unknown => {
    const fields = td.types[type];
    if (fields && v && typeof v === 'object') {
      return Object.fromEntries(
        Object.entries(v as Record<string, unknown>).map(([k, x]) => {
          const f = fields.find((f) => f.name === k);
          return [k, f ? revive(f.type, x) : x];
        }),
      );
    }
    return /^uint\d*$/.test(type) && typeof v === 'string' ? BigInt(v) : v;
  };
  const domain = { ...td.domain } as Record<string, unknown>;
  if (typeof domain.chainId === 'string') domain.chainId = Number(domain.chainId);
  return { ...td, domain, message: revive(td.primaryType, td.message) } as never;
}

const erc20 = parseAbi([
  'function allowance(address owner, address spender) view returns (uint256)',
  'function approve(address spender, uint256 amount) returns (bool)',
]);

/** One-time (per wallet) Permit2 approval of USDG. This is a normal transaction: the buyer pays gas. */
export async function ensurePermit2Allowance(
  account: Account,
  token: Address,
  permit2: Address,
  needed: bigint,
  chainId: number,
): Promise<'approved' | 'already-approved'> {
  const chain = defineChain({
    id: chainId,
    name: 'Robinhood Chain',
    nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
    rpcUrls: { default: { http: [RPC_URL] } },
  });
  const publicClient = createPublicClient({ chain, transport: http(RPC_URL) });
  const allowance = await publicClient.readContract({
    address: token,
    abi: erc20,
    functionName: 'allowance',
    args: [account.address, permit2],
  });
  if (allowance >= needed) return 'already-approved';
  const wallet = createWalletClient({ account, chain, transport: http(RPC_URL) });
  const hash = await wallet.writeContract({
    address: token,
    abi: erc20,
    functionName: 'approve',
    args: [permit2, maxUint256],
  });
  await publicClient.waitForTransactionReceipt({ hash });
  return 'approved';
}

export function chatBody(model: string, content: string) {
  return { model, max_tokens: 64, messages: [{ role: 'user', content }] };
}

export async function firstChatModel(): Promise<string> {
  if (process.env.SPINDLE_MODEL) return process.env.SPINDLE_MODEL;
  const { data } = (await (await fetch(`${API}/v1/models`)).json()) as {
    data: { id: string; kind: string; available: boolean }[];
  };
  const m = data.find((x) => x.kind === 'chat' && x.available);
  if (!m) throw new Error('no available chat model (is a node connected?)');
  return m.id;
}

x402 sessions

For many calls, sign one cap and reuse it. A session has the same checks and the same Permit2 approval as a single payment, and it pays the surcharge once.

Spindle settles actual usage up to your signed cap. The signature authorises a maximum; it is never spent in full unless you use it in full.

  1. Create. POST /v1/x402/sessions { payer, capMicros, ttlSec } returns { sessionId, typedData, surchargeMicros, expiresAt }. Cap is at most 5 USDG, ttl at most 3600 seconds.
  2. Open. Sign typedData, then POST /v1/x402/sessions/:id/open { signature } returns { sessionToken, spendableMicros }. The token is shown once.
  3. Use. Send X-Spindle-Session: <token> on any buyer call. Each call reserves its maximum charge from the remaining cap and settles the actual charge. When the cap is used up you get 402 SESSION_EXHAUSTED.
  4. Close. POST /v1/x402/sessions/:id/close (or let it expire). GET /v1/x402/sessions/:id returns { status, usedMicros, spendableMicros, settleTx } with the same header, until status is settled.
examples/x402-session.ts
x402-session.ts
// x402 session: sign ONE cap ($1), make many calls with X-Spindle-Session, close, wait for settlement.
// Spindle settles actual usage up to your signed cap. Needs PRIVATE_KEY (USDG holder).
//   PRIVATE_KEY=0x... bun x402-session.ts      WAIT_SETTLED=1 polls until the keeper settles (default: 0)
import { privateKeyToAccount } from 'viem/accounts';
import { API, chatBody, ensurePermit2Allowance, firstChatModel, reviveTypedData } from './x402-lib.ts';

const pk = process.env.PRIVATE_KEY as `0x${string}` | undefined;
if (!pk) throw new Error('set PRIVATE_KEY');
const account = privateKeyToAccount(pk);
const json = { 'content-type': 'application/json' };

async function call<T>(path: string, init: RequestInit): Promise<T> {
  const res = await fetch(`${API}${path}`, init);
  if (!res.ok) throw new Error(`${init.method ?? 'GET'} ${path} -> ${res.status} ${await res.text()}`);
  return (await res.json()) as T;
}

try {
  // 1. Create the session; the response carries the Permit2 typed data to sign.
  const created = await call<{
    sessionId: string;
    typedData: Parameters<typeof reviveTypedData>[0];
    surchargeMicros: string;
  }>('/v1/x402/sessions', {
    method: 'POST',
    headers: json,
    body: JSON.stringify({ payer: account.address.toLowerCase(), capMicros: '1000000', ttlSec: 600 }),
  });
  const msg = created.typedData.message as { permitted: { token: `0x${string}`; amount: string } };
  const permit2 = created.typedData.domain.verifyingContract as `0x${string}`;
  const chainId = Number(created.typedData.domain.chainId);
  console.log('session', created.sessionId, 'surcharge', created.surchargeMicros);

  // 2. One-time Permit2 approval, then sign and open.
  console.log(
    'permit2 allowance:',
    await ensurePermit2Allowance(
      account,
      msg.permitted.token,
      permit2,
      BigInt(msg.permitted.amount),
      chainId,
    ),
  );
  const signature = await account.signTypedData(reviveTypedData(created.typedData));
  const opened = await call<{ sessionToken: string; spendableMicros: string }>(
    `/v1/x402/sessions/${created.sessionId}/open`,
    {
      method: 'POST',
      headers: json,
      body: JSON.stringify({ signature }),
    },
  );
  console.log('open, spendable', opened.spendableMicros, 'micros');
  const auth = { ...json, 'x-spindle-session': opened.sessionToken };

  // 3. Three calls on the same session.
  const model = await firstChatModel();
  for (const n of [1, 2, 3]) {
    const res = await call<{
      choices: { message: { content: string } }[];
      spindle: { jobId: string; chargeMicros: string };
    }>('/v1/chat/completions', {
      method: 'POST',
      headers: auth,
      body: JSON.stringify(chatBody(model, `Say the number ${n} in words.`)),
    });
    console.log(`call ${n}:`, res.choices[0]?.message.content?.trim(), '| charge', res.spindle.chargeMicros);
  }

  // 4. Close; settlement happens in the next batch (every 10 min or 20 closing payments).
  const closed = await call<{ status: string }>(`/v1/x402/sessions/${created.sessionId}/close`, {
    method: 'POST',
    headers: auth,
  });
  let status = await call<{ status: string; usedMicros: string }>(`/v1/x402/sessions/${created.sessionId}`, {
    headers: auth,
  });
  console.log('closed ->', closed.status, '| used', status.usedMicros);
  if (process.env.WAIT_SETTLED === '1') {
    const deadline = Date.now() + 15 * 60_000;
    while (status.status !== 'settled' && Date.now() < deadline) {
      await new Promise((r) => setTimeout(r, 10_000));
      status = await call(`/v1/x402/sessions/${created.sessionId}`, { headers: auth });
      console.log('status', status.status);
    }
    if (status.status !== 'settled') throw new Error(`not settled, status ${status.status}`);
  }
  console.log(`PASS x402-session.ts (session ${created.sessionId}, status ${status.status})`);
} catch (err) {
  console.error('FAIL x402-session.ts:', err instanceof Error ? err.message : err);
  process.exit(1);
}

Receipts and verification

Each completed job has a receipt signed by the node that ran it. The response gives you the job id in X-Spindle-Job-Id.

receipt structure
COSE_Sign1 (RFC 9052, CBOR tag 18), algorithm ES256K (-47)
protected header : { 1: -47, 4: kid = nodeKey (20-byte address of the node key) }
payload (CBOR)   : { v:1, jobId, nodeKey, model, modelDigest, kind, lane,
                     usage:{ p, c, n? }, inputDigest, outputDigest,
                     startedAt, finishedAt, agentVersion, attestation? }
signature        : secp256k1 compact r||s over sha256(Sig_structure)
Sig_structure    : ["Signature1", protected, h'', payload]
Digests
FieldMeaning
inputDigestsha256 of the canonical JSON of your request. Hash your own request the same way to confirm the node ran what you sent.
outputDigestsha256 of the output: the UTF-8 text for chat, little-endian float32 bytes for embeddings, the image bytes for images.

Verify it yourself

  1. GET /v1/receipts/:jobId and base64-decode `cose`.
  2. Decode the COSE_Sign1. Check alg is -47 and that kid equals the receipt nodeKey.
  3. Verify the secp256k1 signature over sha256(Sig_structure) with the node public key (the example recovers it from the signature and matches its address to the signed kid, or takes NODE_PUBKEY).
  4. Compare the signed payload to the API view: jobId, nodeKey, model and usage.
  5. Find the job in an epoch: `epoch` on the receipt, then GET /v1/epochs/:epoch/tree. The leaf for the node owner is keccak256(keccak256(abi.encode(owner, cumulativeMicros))). Verify its proof against the tree root.
  6. Compare the tree root with SpindleClaims.roots(epoch) on chain. A matching root means the tree is the one the contract accepts claims against.

A receipt proves that a registered node signed an attested run with these digests and this usage. It does not prove that the output is correct or good.

examples/verify-receipt.ts
verify-receipt.ts
// Fetch a receipt, verify the node's COSE signature, then verify your leaf in the epoch Merkle tree
// against the root posted on-chain.
//   bun verify-receipt.ts <jobId>
//   OWNER_ADDRESS=0x...  node owner whose leaf to prove (default: the receipt's owner)
//   NODE_PUBKEY=0x...    optional override (default: the receipt's nodePubkey, else recovered from the signature)
//   RPC_URL + CLAIMS_ADDRESS  optional, compare the tree root with SpindleClaims.roots(epoch)
import { createPublicClient, http, parseAbi } from 'viem';
import { checkEpochTree, checkReceipt, type ReceiptView, type TreeDump } from './receipt-lib.ts';

const API = process.env.SPINDLE_API_URL ?? 'https://api.spindleneobank.com';
const jobId = process.argv[2] ?? process.env.JOB_ID;

async function get<T>(path: string): Promise<T> {
  const res = await fetch(`${API}${path}`);
  if (!res.ok) throw new Error(`GET ${path} -> ${res.status} ${await res.text()}`);
  return (await res.json()) as T;
}

try {
  if (!jobId)
    throw new Error('usage: bun verify-receipt.ts <jobId>  (the X-Spindle-Job-Id of a finished job)');
  const view = await get<ReceiptView>(`/v1/receipts/${jobId}`);
  const r = checkReceipt(view, process.env.NODE_PUBKEY ?? view.nodePubkey);
  console.log(
    `receipt ${view.jobId} model=${view.model} lane=${view.lane} usage=${view.usage.p}/${view.usage.c} server-verified=${view.verified}`,
  );
  console.log('inputDigest ', r.payload.inputDigest);
  console.log('outputDigest', r.payload.outputDigest);
  if (!r.signatureValid) throw new Error('COSE signature does NOT verify against the node key');
  if (!r.payloadMatchesView) throw new Error('signed payload differs from the API view');
  console.log('COSE_Sign1 ES256K signature valid for node', view.nodeKey);

  if (view.epoch == null) {
    console.log(
      'PASS verify-receipt.ts (signature only: the job is not in an epoch yet, epochs close hourly)',
    );
  } else {
    const tree = checkEpochTree(
      await get<TreeDump>(`/v1/epochs/${view.epoch}/tree`),
      process.env.OWNER_ADDRESS ?? view.owner,
    );
    console.log(`epoch ${view.epoch}: ${tree.leaves} leaves, root ${tree.root}`);
    if (tree.ownerProofValid === false)
      throw new Error('owner leaf/proof does not verify (is OWNER_ADDRESS in this epoch?)');
    if (tree.ownerProofValid)
      console.log(`owner leaf proven, cumulative earned ${tree.cumulativeMicros} micros`);

    if (process.env.RPC_URL && process.env.CLAIMS_ADDRESS) {
      const client = createPublicClient({ transport: http(process.env.RPC_URL) });
      const onchain = await client.readContract({
        address: process.env.CLAIMS_ADDRESS as `0x${string}`,
        abi: parseAbi(['function roots(uint256 epoch) view returns (bytes32)']),
        functionName: 'roots',
        args: [BigInt(view.epoch)],
      });
      if (onchain.toLowerCase() !== tree.root.toLowerCase())
        throw new Error(`on-chain root ${onchain} != tree root ${tree.root}`);
      console.log('tree root matches SpindleClaims.roots(epoch)');
    } else {
      console.log('(set RPC_URL and CLAIMS_ADDRESS to also compare the root with the chain)');
    }
    console.log('PASS verify-receipt.ts');
  }
} catch (err) {
  console.error('FAIL verify-receipt.ts:', err instanceof Error ? err.message : err);
  process.exit(1);
}
examples/receipt-lib.ts
receipt-lib.ts
// Receipt + Merkle verification helpers (used by verify-receipt.ts and the offline test).
// Reuses the same verifier the Spindle backend and node use.
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { bytesToHex, concatBytes } from '@noble/hashes/utils.js';
import { StandardMerkleTree } from '@openzeppelin/merkle-tree';
import { publicKeyToAddress } from 'viem/accounts';
import { decodeReceipt, receiptFromBase64, verifyReceipt } from '../src/shared/receipt.ts';

export interface ReceiptView {
  jobId: string;
  nodeKey: string;
  nodePubkey?: string;
  owner?: string;
  model: string;
  lane: string;
  usage: { p: number; c: number; n?: number };
  cose: string; // base64 COSE_Sign1
  verified: boolean;
  epoch: number | null;
}
export type TreeDump = Parameters<typeof StandardMerkleTree.load>[0];

const bstr = (b: Uint8Array) => {
  const n = b.length;
  const head =
    n < 24
      ? [0x40 | n]
      : n < 256
        ? [0x58, n]
        : n < 65536
          ? [0x59, n >> 8, n & 255]
          : [0x5a, n >>> 24, (n >> 16) & 255, (n >> 8) & 255, n & 255];
  return concatBytes(Uint8Array.from(head), b);
};

/**
 * The public receipt exposes the node's address (`nodeKey`), not its public key. ES256K signatures
 * are recoverable, so we recover the candidate keys and keep the one whose address is the signed kid.
 * If you hold the node's registered public key, pass it as `publicKey` instead.
 */
export function nodePublicKeyFor(cose: Uint8Array): string {
  const d = decodeReceipt(cose);
  // Sig_structure = ["Signature1", protected, h'', payload]
  const sigStructure = concatBytes(
    Uint8Array.of(0x84, 0x6a),
    new TextEncoder().encode('Signature1'),
    bstr(d.protectedBytes),
    Uint8Array.of(0x40),
    bstr(d.payloadBytes),
  );
  const digest = sha256(sigStructure);
  for (const recovery of [0, 1]) {
    const pub = secp256k1.Signature.fromBytes(d.signature, 'compact')
      .addRecoveryBit(recovery)
      .recoverPublicKey(digest)
      .toBytes(false);
    const hex = `0x${bytesToHex(pub)}` as const;
    if (publicKeyToAddress(hex).toLowerCase() === d.protected.kid) return hex;
  }
  throw new Error('no public key matches the receipt kid');
}

export interface ReceiptCheck {
  signatureValid: boolean;
  payloadMatchesView: boolean;
  payload: ReturnType<typeof decodeReceipt>['payload'];
}

/** COSE signature against the node key, and the signed payload against the API's view of the job. */
export function checkReceipt(view: ReceiptView, publicKey?: string): ReceiptCheck {
  const cose = receiptFromBase64(view.cose);
  const { payload } = decodeReceipt(cose);
  let signatureValid = false;
  try {
    signatureValid = verifyReceipt(cose, publicKey ?? nodePublicKeyFor(cose));
  } catch {
    // no key hashes to the signed kid: the signature is not by the node
  }
  return {
    signatureValid,
    payloadMatchesView:
      payload.jobId === view.jobId &&
      payload.nodeKey.toLowerCase() === view.nodeKey.toLowerCase() &&
      payload.model === view.model &&
      payload.usage.p === view.usage.p &&
      payload.usage.c === view.usage.c,
    payload,
  };
}

export interface MerkleCheck {
  root: `0x${string}`;
  leaves: number;
  ownerProofValid: boolean | null; // null when no owner given
  cumulativeMicros: string | null;
}

/** Loads the epoch tree, checks it is internally consistent, and verifies the owner's leaf proof. */
export function checkEpochTree(dump: TreeDump, owner?: string): MerkleCheck {
  const tree = StandardMerkleTree.load(dump);
  tree.validate();
  let ownerProofValid: boolean | null = null;
  let cumulativeMicros: string | null = null;
  if (owner) {
    ownerProofValid = false;
    for (const [i, v] of tree.entries()) {
      if (String(v[0]).toLowerCase() !== owner.toLowerCase()) continue;
      const proof = tree.getProof(i);
      ownerProofValid = StandardMerkleTree.verify(tree.root, tree.leafEncoding as string[], v, proof);
      cumulativeMicros = String(v[1]);
    }
  }
  return {
    root: tree.root as `0x${string}`,
    leaves: [...tree.entries()].length,
    ownerProofValid,
    cumulativeMicros,
  };
}

Limits and errors

Limits
Rate limit60 requests per minute per API key (x402: per wallet).
max_tokensDefaults to 1024 and is capped at 8192. Your balance must cover the maximum charge: (prompt tokens + max_tokens) times the rate.
Sealed laneX-Spindle-Lane: sealed routes to attested nodes with encrypted prompts. It is in testing and may return 503 NO_CAPACITY or 404 for sealed keys.
ErrorsOpenAI-compatible routes return { "error": { "message", "type", "code" } }.
Error codes
CodeHTTPMeaning
VALIDATION400The request body or query is invalid.
UNAUTHENTICATED401Missing or unknown API key, or session token.
FORBIDDEN403The key may not do this.
NOT_FOUND404Unknown job, epoch or session.
CONFLICT409The request conflicts with current state.
PAYMENT_REQUIRED402x402 challenge (see PAYMENT-REQUIRED), or x402 is paused.
INSUFFICIENT_BALANCE402Your deposited balance cannot cover the maximum charge of this request.
SESSION_EXHAUSTED402The x402 session has no spendable cap left for this request.
RATE_LIMITED42960 requests per minute per key (or per wallet for x402). See Retry-After.
NO_CAPACITY503No online node can serve this model and lane right now. Nothing was charged.
UPSTREAM_UNAVAILABLE502The node failed or timed out. The reservation is released.
INTERNAL500Unexpected error.

OpenAPI

The machine-readable description of the public and buyer API is at https://api.spindleneobank.com/v1/openapi.json.