All docs / npm packages

@decentnetwork/billing-client

Typed client for the Decent Network billing service: nodes call hold / capture / release and never speak raw HTTP or compute prices.

Version
0.1.2 · npm
Platform
Node.js
Source
Source repository not public yet
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.2

Quickstart

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=ok

This 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

MethodHTTPReturnsNotes
hold({ userid, taskId, kind, seconds, service?, node? })POST /holdPromise<{ 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 /capturePromise<void>The task succeeded. Pass { kind, seconds } to charge actual work, or leave it out to charge the held estimate.
release(taskId)POST /releasePromise<void>The task failed or was cancelled, so the hold is refunded. Safe to call in any error path.
quote(kind, seconds, service?)POST /quotePromise<number>The service's estimate, for display
rates()GET /ratesPromise<{ 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 /chargePromise<void>Non-metered debit (memberships and similar). Idempotent per ref, so use a stable key and retries are safe.
credit(userid, amount, note)POST /creditPromise<void>Operator top-up
bindWallet(userid, chain, address)POST /bind-walletPromise<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 fetch and AbortController (Node.js 18 and later has both). Verified only with Node 24.3.0.
  • Published as an ES module (dist/index.js) with dist/index.d.ts. The package has no runtime dependencies and declares no engines field.

Version, source and changelog