@decentnetwork/billing-client
Typed client for the Decent Network billing service: nodes call hold / capture / release and never speak raw HTTP or compute prices.
npm install @decentnetwork/billing-client@decentnetwork/billing-client is the typed client that compute-node operators use to talk to the Decent Network billing service. A node reports work (task kind plus seconds of source media), and the billing service decides what that costs. The node wraps each paid task in hold() → capture(), or release() on failure, and never computes money itself. The package is one small ES module with TypeScript declarations and no runtime dependencies. It uses the global fetch.
When to use it
Use it when you run a node that does paid work for users (for example audio or video generation for a service such as interview) and the billing service has given you its URL and a shared token. Typical cases:
- Reserve a user's balance before starting a task, and fail fast with "please top up" when it is empty.
- Charge actual work when the task finishes, and refund the reservation when it fails.
- Show a user their balance or history in your app.
You do not need it to build on the network itself. For messaging, files and calls between peers, see @decentnetwork/peer.
Install
npm install @decentnetwork/billing-client@0.1.2Quickstart
The real service's address and token come from its operator. Its usual base URL is an AgentNet virtual IP, because the service binds 127.0.0.1 on its own host and is reached over the virtual LAN. To keep this sample self-contained, it starts a small local stub that speaks the same HTTP envelope as the service. The stub's amounts are placeholders, and the sample never prints them.
// stub-server.mjs
// A local stand-in for the billing service: same paths, same JSON envelope
// ({ ok: true, ... } / { ok: false, error }), same x-billing-token header.
// It keeps holds in memory and never computes real prices.
import { createServer } from "node:http";
export function startStub({ token, port = 0, broke = new Set() }) {
const holds = new Map();
const log = [];
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://stub");
let body = {};
for await (const chunk of req) body = JSON.parse(chunk.toString() || "{}");
log.push(`${req.method} ${url.pathname} token=${req.headers["x-billing-token"] === token ? "ok" : "bad"}`);
const send = (status, data) => { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(data)); };
if (req.headers["x-billing-token"] !== token) return send(401, { ok: false, error: "bad token" });
switch (`${req.method} ${url.pathname}`) {
case "GET /balance": return send(200, { ok: true, balance: 0, held: 0 });
case "POST /hold":
if (broke.has(body.userid)) return send(400, { ok: false, error: "insufficient balance" });
holds.set(body.taskId, body);
return send(200, { ok: true, amount: 0 });
case "POST /capture":
if (!holds.delete(body.taskId)) return send(404, { ok: false, error: `no hold for task ${body.taskId}` });
return send(200, { ok: true, entry: {} });
case "POST /release": holds.delete(body.taskId); return send(200, { ok: true });
case "GET /history": return send(200, { ok: true, entries: [] });
default: return send(404, { ok: false, error: "not found" });
}
});
return new Promise((resolve) => server.listen(port, "127.0.0.1", () =>
resolve({ url: `http://127.0.0.1:${server.address().port}`, log, close: () => server.close() })));
}// quickstart.mjs
import { BillingClient, BillingError } from "@decentnetwork/billing-client";
import { startStub } from "./stub-server.mjs";
const stub = await startStub({ token: "dev-token", broke: new Set(["broke-user"]) });
const billing = new BillingClient({ baseUrl: stub.url, token: "dev-token", timeoutMs: 5000 });
const NODE = "my-node-userid";
// One task, the way a node runs it: hold -> work -> capture (or release).
async function runTask(userid, taskId, work) {
await billing.hold({ userid, taskId, kind: "audio", seconds: 42, service: "interview", node: NODE });
try {
const actualSeconds = await work();
await billing.capture(taskId, { kind: "audio", seconds: actualSeconds });
return "captured";
} catch (err) {
await billing.release(taskId);
return `released (${err.message})`;
}
}
console.log("task-1:", await runTask("alice", "task-1", async () => 40));
console.log("task-2:", await runTask("alice", "task-2", async () => { throw new Error("TTS failed"); }));
try {
await runTask("broke-user", "task-3", async () => 10);
} catch (err) {
if (err instanceof BillingError && err.code === "insufficient") console.log("task-3: ask the user to top up");
else throw err;
}
const bad = new BillingClient({ baseUrl: stub.url, token: "wrong" });
await bad.history("alice").catch((e) => console.log("wrong token:", e.name, e.code, e.status, e.message));
console.log("balance keys:", Object.keys(await billing.balance("alice")).join(", "));
console.log("requests seen by the stub:\n " + stub.log.join("\n "));
stub.close();Verified output (npm @decentnetwork/billing-client@0.1.2, Node 24.3.0, 2026-11-06):
$ node quickstart.mjs
task-1: captured
task-2: released (TTS failed)
task-3: ask the user to top up
wrong token: BillingError http 401 bad token
balance keys: balance, held
requests seen by the stub:
POST /hold token=ok
POST /capture token=ok
POST /hold token=ok
POST /release token=ok
POST /hold token=ok
GET /history token=bad
GET /balance token=okThis verifies the client's request paths, headers, JSON bodies and error mapping against a stub. It was not run against a live billing service.
API reference
From dist/index.d.ts. All amounts the service returns are integers in micro-USD. The client passes them through and does not do any arithmetic on them.
new BillingClient(opts)
interface BillingClientOptions {
baseUrl: string; // e.g. "http://<billing virtual IP>:<port>" over AgentNet
token: string; // shared secret, sent as the x-billing-token header
timeoutMs?: number; // per request, default 10000
}
type TaskKind = "audio" | "video";Methods
| Method | HTTP | Returns | Notes |
|---|---|---|---|
hold({ userid, taskId, kind, seconds, service?, node? }) | POST /hold | Promise<{ amount: number }> | Freeze the estimate when a task starts. service (e.g. "interview") selects the node revenue split. node is your Carrier userid, which earns the split on capture. Throws BillingError with code: "insufficient" when the user cannot pay. |
capture(taskId, actual?) | POST /capture | Promise<void> | The task succeeded. Pass { kind, seconds } to charge actual work, or leave it out to charge the held estimate. |
release(taskId) | POST /release | Promise<void> | The task failed or was cancelled, so the hold is refunded. Safe to call in any error path. |
quote(kind, seconds, service?) | POST /quote | Promise<number> | The service's estimate, for display |
rates() | GET /rates | Promise<{ rates: Record<string, number>; services: Record<string, { nodeShareBps: number }> }> | The service's price card, for display only and never for local math |
balance(userid) | GET /balance?userid= | Promise<{ balance: number; held: number }> | A node's own earnings use userid = "node:<userid>" |
history(userid) | GET /history?userid= | Promise<Array<Record<string, unknown>>> | Ledger entries |
charge({ userid, amount, service, note, ref }) | POST /charge | Promise<void> | Non-metered debit (memberships and similar). Idempotent per ref, so use a stable key and retries are safe. |
credit(userid, amount, note) | POST /credit | Promise<void> | Operator top-up |
bindWallet(userid, chain, address) | POST /bind-wallet | Promise<void> | Attribute future on-chain deposits from this wallet to this userid |
The service decides which token may call what. In the billing service source, a node token is limited to balance, rates, quote, hold, capture, release, charge and history. credit and bindWallet need an operator (admin) token and otherwise return HTTP 403.
BillingError
class BillingError extends Error {
name: "BillingError";
code: "insufficient" | "http"; // "insufficient" when the message matches /insufficient/i
status?: number; // HTTP status
}A BillingError is thrown when the response is not 2xx or the body has ok: false. The message is the service's error string. Two other failures surface as raw errors, not BillingError (both observed in testing):
- A non-JSON response (for example an HTML 502 from a proxy) throws
SyntaxError. - A timeout throws an
AbortError(DOMException).
Handle both in the same catch as BillingError and call release().
Configuration
There are only the three constructor options: baseUrl, token and timeoutMs. The client reads no environment variables or config files. Keep token out of source control, for example by reading it from an environment variable your own service defines.
Platforms
- Any runtime with a global
fetchandAbortController(Node.js 18 and later has both). Verified only with Node 24.3.0. - Published as an ES module (
dist/index.js) withdist/index.d.ts. The package has no runtime dependencies and declares noenginesfield.
Version, source and changelog
- Verified version: 0.1.2 (
lateston npm, published 2026-08-10; earlier versions 0.1.0 and 0.1.1). - npm: https://www.npmjs.com/package/@decentnetwork/billing-client
- Source repository is not public yet. Browse the published code at https://www.npmjs.com/package/@decentnetwork/billing-client?activeTab=code
- License: MIT.
- Changelog: none is published. The package also ships no README.