Skip to content
Go To Dashboard

Attribute runs and cost to your customers

This page is for a platform that runs Sapiom agents on behalf of its own customers and needs the cost of each customer’s runs, for example to bill them or to price a plan. It covers starting runs from your server, attributing each run to a customer, reading each run’s cost once it is final, and checking that the per-customer totals add up to what Sapiom billed you.

  • Your platform is one Sapiom organization. Your customers exist only in your own system; they have no Sapiom account and never hold a Sapiom API key.
  • One deployed agent serves every customer. Each run receives the customer in its input.
  • Your server starts every run, sets idempotencyKey to your own job ID, and stores the returned executionId against the customer.
  • Your server reads each run’s cost from GET /v1/workflows/executions/:id/spend and adds it to that customer’s total once the run’s spend is settled.

Every request on this page goes to https://api.sapiom.ai with your API key in the x-api-key header. Keep the key on your server. Starting a run needs org.write; every read needs org.read. The Executions API reference lists every field, filter, and error.

  1. Accept the customer in the agent’s input

    Section titled “Accept the customer in the agent’s input”

    Declare the customer in the entry step’s input schema, so a run started without one fails validation instead of running unattributed:

    import { defineAgent, defineStep, terminate } from "@sapiom/agent";
    import { z } from "zod/v4";
    const inputSchema = z.object({
    customerId: z.string().min(1),
    topic: z.string(),
    });
    const research = defineStep({
    name: "research",
    next: [],
    terminal: true,
    inputSchema,
    async run(input, ctx) {
    // ...work for input.customerId
    return terminate({ customerId: input.customerId });
    },
    });
    export const agent = defineAgent<z.infer<typeof inputSchema>>({
    name: "customer-research",
    entry: "research",
    steps: { research },
    });

    Deploy the agent once. Note its definition ID; every run below uses it.

  2. Start the run with your job ID as the idempotency key

    Section titled “Start the run with your job ID as the idempotency key”
    const SAPIOM = "https://api.sapiom.ai";
    const headers = {
    "content-type": "application/json",
    "x-api-key": process.env.SAPIOM_API_KEY!,
    };
    async function startRun(job: { id: string; customerId: string; topic: string }) {
    const res = await fetch(`${SAPIOM}/v1/workflows/executions`, {
    method: "POST",
    headers,
    body: JSON.stringify({
    definitionId: process.env.SAPIOM_AGENT_DEFINITION_ID,
    input: { customerId: job.customerId, topic: job.topic },
    idempotencyKey: job.id,
    }),
    });
    if (!res.ok) throw new Error(`start failed: ${res.status} ${await res.text()}`);
    const { status, executionId } = (await res.json()) as {
    status: "enqueued" | "already_exists";
    executionId: string;
    };
    await db.runs.upsert({ executionId, customerId: job.customerId, jobId: job.id });
    return executionId;
    }

    An idempotency key is unique across your whole tenant, not per agent. When a key was already used, the response is status: "already_exists" with the existing run’s executionId, and no second run starts. So a retry after a timeout or a crash between the request and your database write returns the same run, and the mapping you store is the same either way. If two of your agents can share job IDs, prefix the key with the agent, for example research-${job.id}.

  3. Store the mapping when you start the run. Sapiom does not record your customer separately: the run list does not return run input, so your own table is how you find a customer’s runs.

    If an agent dispatches other agents, each dispatched run is a separate execution with its own executionId and its own spend. GET /v1/workflows/executions/:id lists a run’s direct dispatches in children[].executionId; attribute each child to the same customer as its parent, and walk the children’s children the same way.

  4. Poll GET /v1/workflows/executions/:id until status is completed, failed, or cancelled. running and paused are not terminal; a paused run is waiting for a signal. A failed or cancelled run can still have cost, so read its spend too.

  5. const res = await fetch(`${SAPIOM}/v1/workflows/executions/${executionId}/spend`, { headers });
    const spend = (await res.json()) as {
    executionId: string;
    status: string;
    settled: boolean;
    usage: { meter: string; quantity: number; unit: string | null; listValueUsd: string | null }[];
    listValueUsd: string | null;
    billedUsd: string;
    };
    if (!spend.settled) return; // read it again later
    await db.runs.update(executionId, { listValueUsd: spend.listValueUsd, billedUsd: spend.billedUsd });

    Bill a run only when settled is true. Every meter except sandbox compute is recorded by the time the run ends. Sandbox compute arrives afterwards, usually within an hour and in rare cases up to about 6 hours later. settled turns true 8 hours after the later of the run’s finish and its last sandbox shutdown, once nothing is still pending. Until then the figures can still grow. Read unsettled runs again later, for example from a job that runs every hour.

    The response carries two dollar figures:

    FieldMeaning
    listValueUsdThe run’s usage at your list price. It does not depend on your plan. Use it to price your own plans or to show a customer what their runs consumed.
    billedUsdWhat Sapiom charged your organization for this run. It is "0" for usage inside your plan’s included quota. Use it to reconcile with your Sapiom bill.

    usage has one line per meter, for example LLM tokens, sandbox compute, and each capability. A line with listValueUsd: null is usage that has no USD price; it is never reported as "0". listValueUsd at the top level sums the priced lines.

  6. Sum the decimal strings exactly. Do not parse them into JavaScript numbers: amounts carry up to 18 decimal places, and floating-point addition drifts. Use a decimal library, or integers of 10⁻¹⁸ USD:

    const SCALE = 18n;
    function toUnits(usd: string): bigint {
    const negative = usd.startsWith("-");
    const [whole, frac = ""] = usd.replace("-", "").split(".");
    const units = BigInt(whole || "0") * 10n ** SCALE + BigInt((frac + "0".repeat(18)).slice(0, 18));
    return negative ? -units : units;
    }
    function fromUnits(units: bigint): string {
    const negative = units < 0n;
    const abs = negative ? -units : units;
    const frac = (abs % 10n ** SCALE).toString().padStart(18, "0").replace(/0+$/, "");
    return `${negative ? "-" : ""}${abs / 10n ** SCALE}${frac ? `.${frac}` : ""}`;
    }
    const totals = new Map<string, bigint>();
    for (const run of await db.runs.settledInPeriod(periodStart, periodEnd)) {
    totals.set(run.customerId, (totals.get(run.customerId) ?? 0n) + toUnits(run.billedUsd));
    }

GET /v1/workflows/executions lists your tenant’s runs, newest first. Filter by agent with definitionId, by state with status (comma-separated, for example status=completed,failed,cancelled), and by start time with from and to. Page with limit (at most 500) and offset. List rows do not include input, so the list alone cannot tell you a run’s customer; use it to find runs your table is missing, then read each one with GET /v1/workflows/executions/:id, whose input carries the customerId.

Each list row has a cost object with listUsd and billedUsd. It comes from a rollup that can lag behind, and it does not say whether the run is settled. Read /spend for anything you bill.

Reconcile with your organization’s spend

Section titled “Reconcile with your organization’s spend”

Over a closed set of runs, the sum of billedUsd equals the billed debits on your organization’s USD ledger account in the same window. To check it:

  1. GET /v1/accounts returns your accounts in data. Take the one whose asset.symbol is USD.

  2. async function billedDebits(accountId: string, from: string, to: string): Promise<bigint> {
    let total = 0n;
    let after: string | null = null;
    do {
    const query = new URLSearchParams({
    "filter[from_date]": from,
    "filter[to_date]": to,
    "page[limit]": "100",
    });
    if (after) query.set("page[after]", after);
    const res = await fetch(`${SAPIOM}/v1/accounts/${accountId}/entries?${query}`, { headers });
    const page = (await res.json()) as {
    data: { type: string; status: string; amountUsd: string }[];
    links?: { next?: string | null };
    };
    for (const entry of page.data) {
    if (entry.status === "CANCELLED") continue;
    if (entry.type !== "metering_charge" && entry.type !== "x402_payment") continue;
    total -= toUnits(entry.amountUsd); // debits are negative
    }
    const next = page.links?.next;
    after = next ? new URL(next, SAPIOM).searchParams.get("page[after]") : null;
    } while (after);
    return total;
    }

    Entries are newest first. metering_charge entries are usage charges. x402_payment entries are per-call payments. amountUsd is negative for a debit.

  3. Compare the debits from the window’s start until now with the sum of billedUsd over every run started in the window, once every one of those runs is settled. A streamed-overage charge covers usage across several runs and is split between them pro rata, so allow a rounding difference of up to 10⁻¹² USD per run.

The two totals match exactly only when the window holds nothing but those runs:

  • x402_payment entries are not in billedUsd. A run that makes a per-call x402 payment adds a debit that no run’s billedUsd includes. Look the payment up in the transactions list (below) to attribute it.
  • Usage outside runs. Calls your API key makes directly, not from inside a run, are charged to the organization but belong to no run.
  • Window edges. A run started just before the window can have sandbox compute charged inside it, and a run started inside it can be charged after its end. Pick a window that starts while nothing is running, and compare only after every run in it is settled.

GET /v1/transactions lists the transactions behind per-call payments, newest first. It needs org.transactions.read; org.transactions.write and a full-access key include it.

FilterTakes
filter[trace_external_id]A run’s executionId. Returns the transactions recorded during that run.
filter[service]A service ID (UUID), the serviceId on a transaction. Not a service name.
filter[agent]An agent ID (UUID), the agentId on a transaction. Not an agent name.
filter[from_date], filter[to_date]ISO 8601 timestamps. Without them the list covers the last 30 days.
page[limit], page[after]Page size (1 to 100, default 20) and the cursor from links.next.

filter[service] and filter[agent] accept only IDs. A name, or any other value that is not a UUID, returns 400. To filter by a service, take its serviceId from a transaction you already have.