01Connect your walletOpen the dashboard and sign in with your wallet.
02Deposit USDGDashboard, Developers tab: approve and deposit USDG into your buyer balance. A deposit counts after 12 blocks.
03Create an API keyCreate a key in the same tab. It is shown once and looks like sk-spdl-...
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
Header
Meaning
X-Spindle-Lane
Request header on chat completions: `open` (default) or `sealed` (in testing).
X-Spindle-Job-Id
Response header: the job id, also the receipt id.
X-Spindle-Receipt
Response header: `/v1/receipts/<jobId>`.
X-Spindle-Charge-Micros
Response 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.
Streaming (`"stream": true`) is standard SSE. The final chunk includes `usage` and `spindle: { jobId, chargeMicros, receiptUrl }`, then `data: [DONE]`.
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.
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.
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.
Create. POST /v1/x402/sessions { payer, capMicros, ttlSec } returns { sessionId, typedData, surchargeMicros, expiresAt }. Cap is at most 5 USDG, ttl at most 3600 seconds.
Open. Sign typedData, then POST /v1/x402/sessions/:id/open { signature } returns { sessionToken, spendableMicros }. The token is shown once.
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.
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.
sha256 of the canonical JSON of your request. Hash your own request the same way to confirm the node ran what you sent.
outputDigest
sha256 of the output: the UTF-8 text for chat, little-endian float32 bytes for embeddings, the image bytes for images.
Verify it yourself
GET /v1/receipts/:jobId and base64-decode `cose`.
Decode the COSE_Sign1. Check alg is -47 and that kid equals the receipt nodeKey.
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).
Compare the signed payload to the API view: jobId, nodeKey, model and usage.
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.
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 limit
60 requests per minute per API key (x402: per wallet).
max_tokens
Defaults to 1024 and is capped at 8192. Your balance must cover the maximum charge: (prompt tokens + max_tokens) times the rate.
Sealed lane
X-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.