End-user billing
Attributing usage to your own customers, per-seat quotas and caps, and rebilling patterns.
Many customers are platforms: they sell an agent product to their own customers, and run that product on workspaces.build. For them, "what did this cost" is not an organization-level question — it is a per-customer one.
The whole mechanism is one field.
{ "tier": 2, "template": "tpl_dev_polyglot", "end_user_id": "eu_customer_4821" }
The model
The organization is always the payer. End users are attribution subjects: usage is tagged with them, reportable by them, and limitable per them. The platform never bills an end user directly — what you charge your customers is your pricing decision, not ours.
end_user_id is captured on the usage event at emission. A session created without it produces
events that are permanently unattributed. There is no repair operation, because the association
only ever existed in your application's head at the moment of the request. Tag every create from
day one.
Creating end users
They are created implicitly on first use, so an unknown ID does not fail:
curl -sS "$WB_BASE_URL/sessions" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{ "tier": 2, "end_user_id": "eu_customer_4821" }'
Create them explicitly when you want metadata or limits up front:
curl -sS "$WB_BASE_URL/end-users" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{
"external_id": "cust-4821",
"metadata": { "plan": "scale", "company": "Acme", "signed_up": "2026-03-11" },
"quotas": { "concurrent_sessions": { "tier1": 20, "tier2": 5, "tier3": 2, "tier4": 1 } },
"spend_cap_minor": 50000
}'
external_id is your identifier. It is unique per organization and can be used in place of the
eu_ ID everywhere:
{ "end_user_id": "ext:cust-4821" }
Use ext: to avoid maintaining a mapping table. Use the eu_ ID if your own identifiers might be
reassigned.
What gets tagged
| Resource | Tagged | Note |
|---|---|---|
| Session | end_user_id at creation | Immutable |
| Run | Inherited from its session | |
| One-shot run | end_user_id on the request | |
| Tool call | end_user_id on the request | |
| Volume | end_user_id at creation | Storage attributed continuously |
| Snapshot | Inherited from the source session | |
| Browser profile | end_user_id at creation | Strongly recommended — a profile is one identity |
Reporting
curl -sS "$WB_BASE_URL/usage?period=last_month&group_by=end_user_id&order=amount_desc" \ -H "Authorization: Bearer $WB_API_KEY"
{
"period": { "start": "2026-09-01T00:00:00Z", "end": "2026-10-01T00:00:00Z" },
"currency": "USD",
"groups": [
{ "end_user_id": "eu_customer_4821", "external_id": "cust-4821", "amount_minor": 184220,
"quantity_by_meter": { "tier2.session_s": 841100, "cpu_ms": 2.1e8, "egress_gb": 42.1 } },
{ "end_user_id": "eu_customer_1190", "external_id": "cust-1190", "amount_minor": 92410,
"quantity_by_meter": { "tier3.session_s": 182200, "browser_page_s": 410220 } },
{ "end_user_id": null, "amount_minor": 1204,
"note": "untagged usage — sessions created without end_user_id" }
]
}
The null group is the leak. Watch it; a growing null group means a code path that forgets the tag.
Per customer, per meter, monthly
curl -sS "$WB_BASE_URL/usage/export?period=last_month&granularity=session&format=jsonl" \ -H "Authorization: Bearer $WB_API_KEY" -o usage.jsonl
One row per session with its meters, tags and rated cost — the right input for generating your own invoices.
{"session_id":"sess_2kQ9xwR4","end_user_id":"eu_customer_4821","external_id":"cust-4821","tier":2,
"started_at":"2026-09-14T10:02:11Z","ended_at":"2026-09-14T10:09:33Z",
"meters":{"tier2.session_s":442,"cpu_ms":118430,"egress_gb":0.031},"amount_minor":31}
Per-customer limits
The two controls that stop one customer affecting the others.
curl -sS -X PATCH "$WB_BASE_URL/end-users/eu_customer_4821" \
-H "Authorization: Bearer $WB_API_KEY" -H "Content-Type: application/json" \
-d '{
"quotas": { "concurrent_sessions": { "tier1": 20, "tier2": 5, "tier3": 2, "tier4": 1 } },
"spend_cap_minor": 50000,
"spend_cap_action": "block_new"
}'
Quota is synchronous and is the real ceiling. The cap is the budget backstop. Map both to your own plan tiers:
const PLAN_LIMITS = {
free: { tier1: 2, tier2: 0, tier3: 0, tier4: 0, capMinor: 500 },
pro: { tier1: 20, tier2: 5, tier3: 2, tier4: 0, capMinor: 5000 },
scale: { tier1: 100,tier2: 25, tier3: 10, tier4: 2, capMinor: 50000 },
}
await wb.endUsers.update(user.id, {
quotas: { concurrent_sessions: PLAN_LIMITS[user.plan] },
spendCapMinor: PLAN_LIMITS[user.plan].capMinor,
})
A free-plan customer with tier4: 0 simply cannot start a desktop, synchronously, at admission. No
amount of prompt injection changes that.
Rebilling patterns
Cost-plus
Export session-granularity usage, apply a markup, invoice.
const rows = await wb.usage.export({ period: 'last_month', granularity: 'session' })
const byCustomer = groupBy(rows, (r) => r.external_id)
for (const [customer, sessions] of byCustomer) {
const cost = sum(sessions.map((s) => s.amount_minor))
await billing.invoice(customer, { compute: Math.ceil(cost * 1.4) })
}
Transparent and matches your own cost exactly. Exposes your margin to anyone who knows the list price.
Credits
Define your own unit, map meters onto it, sell credit packs.
const CREDITS_PER = {
'tool_call': 0.01,
'tier1.session_s': 0.5,
'tier2.session_s': 2,
'tier3.session_s': 4,
'tier4.session_s': 15,
'egress_gb': 10,
}
Hides the underlying cost structure, lets you change providers without changing your pricing, and is what most agent platforms end up doing. Requires you to maintain the mapping as rates change.
Flat rate with a fair-use ceiling
Charge per seat, set per-end-user quotas at the fair-use boundary, and watch the distribution. The simplest to sell and the most exposed to a heavy user — which is exactly what the per-end-user quota is for.
Isolation between your customers
| Concern | Mechanism |
|---|---|
| Compute isolation | Separate sessions; separate isolation boundaries per tier |
| Data isolation | Separate volumes, snapshots and profiles per end user |
| Network isolation | Per-session egress policy; one customer's allowlist is not another's |
| Noisy-neighbour | Per-end-user concurrency quotas |
| Budget | Per-end-user spend caps |
| Blast radius | A separate project per customer when isolation requirements are strict |
For regulated customers, a project per end user gives separate keys, separate policies, separate
default regions and separate quotas, at the cost of more objects to manage. For most platforms,
end_user_id inside one project is the right granularity.
Common mistakes
Tagging only some paths. The retry path, the background job, the admin tool — all need the tag.
Watch the null group.
Using a mutable identifier. If your user IDs are reassigned on account deletion, the historical usage of a deleted customer reattaches to a new one. Use a stable surrogate.
Setting caps without quotas. A cap lags by up to 60 seconds. One customer's runaway loop can still consume the organization's concurrency in that window. Quotas are synchronous; set both.
Forgetting the browser profile. An untagged profile is shared identity across customers, which is a data-leak risk, not merely a reporting gap.