Customer units and credit caps

Overview

A customer unit is a node in a customer’s own hierarchy: a department, a tenant, a team, a project. Units let you see where a customer’s credits are being spent and cap the spend of any part of that hierarchy for a period.

Units are structure only. They hold no balance and no orders; the customer does. A signal names the unit it should count toward, and a credit cap on a unit limits how many credits of one currency that unit and everything under it may spend per period.

Customer units are a different feature from multi-entity customers. That page is about billing several customer accounts through a parent account. This page is about the hierarchy inside one customer.

Customer units are enabled per organization. Until they are enabled for yours, the unit and cap routes answer 403 with the code FEATURE_NOT_ENABLED.

Addressing

Every route lives under the customer, addressed as on the other customer routes: by Paid’s customer id, or by your own id under /external/.

/api/v2/customers/{id}/customer-units
/api/v2/customers/external/{externalId}/customer-units

Within a customer, a unit is identified by its externalId: your own key for it. It is required when you create the unit, unique within the customer, and immutable. Every unit route, every parent reference and every signal names a unit by it. Paid keeps an internal id for each unit but never returns or accepts it.

A unit key you create is any non-empty string of at most 255 characters (Unicode code points) other than . and ... The same bound applies to the key a signal carries. Roots that Paid created for customers predating the feature (see below) copied the customer’s identifier verbatim, so such a root’s key may be longer; every route still accepts it in the path.

The tree

The units of a customer form a tree: one root, and every other unit names a parent that exists.

  • The root. When you create a customer with an externalId that is usable as a unit key (non-empty, at most 255 characters, not . or ..), Paid creates the root unit at the same moment, keyed by a copy of that external id. The root’s key is immutable from then on, whatever later happens to the customer’s external id. A customer created without a usable external id has no units until you create the root yourself, by creating a unit with no parentExternalId, under a key of your choosing. Paid creates no other unit.
  • Customers that predate customer units already have a root. When the feature shipped, Paid created one root for every existing customer, keyed by the first of these the customer had: its externalId, its Paid display id (cus_…), or its Paid uuid. Its externalType is customer and its name is the customer’s name. List the customer’s units and look for isRoot: true before creating a root; creating one on such a customer answers 409 ROOT_EXISTS.
  • One active root. Creating a unit without a parent while the customer already has an active root answers 409 ROOT_EXISTS. The root cannot be moved (ROOT_UNIT_IMMOVABLE), but it is ordinary in everything else: rename, type, cap, annotate or delete it like any other unit. Once the root is deleted (childless, no cap), the customer can be given a new root.
  • Parents. A parent must exist and be active (PARENT_NOT_FOUND, PARENT_NOT_ACTIVE). A unit cannot be moved under itself or one of its descendants (PARENT_CYCLE), and the tree is at most 16 levels deep (DEPTH_EXCEEDED).
  • Seat-billed customers. Customers on seat-based billing cannot have units: every write answers 409 SEAT_BILLING. The one exception is ending a cap, which stays available as clean-up for a customer that moved to seat billing while a cap was still open.

Creating units

$curl -X POST https://api.agentpaid.io/api/v2/customers/external/acme/customer-units \
> -H "Authorization: Bearer $PAID_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{
> "externalId": "eu-tenant",
> "name": "EU tenant",
> "externalType": "tenant",
> "parentExternalId": "acme",
> "metadata": { "region": "eu-west-1" }
> }'

externalId is the only required field. name (up to 255 characters) defaults to it and is never used to address the unit, externalType (up to 64 characters) is a free label for your own structural vocabulary, and metadata is any JSON object. Here parentExternalId is acme, the root Paid created when the customer was created with the external id acme.

Every unit response carries externalId, name, externalType, status (ACTIVE or DELETED), parentExternalId (null on the root), isRoot, metadata, createdAt and updatedAt. There is no unit id in any response.

Listing and reading

GET …/customer-units lists a customer’s units flat, in creation order, with parentExternalId on each so you can assemble the tree. It is paginated with the usual limit and offset query parameters and shows active units by default; add status=DELETED to see deleted ones, externalType= to filter by type, or parentExternalId= to list one unit’s direct children. Unknown query parameters are a 400. GET …/customer-units/{externalCustomerUnitId} returns one unit, deleted or not.

Changing units

PATCH …/customer-units/{externalCustomerUnitId} renames, re-types, re-parents or annotates a unit. Send only the fields you want to change, at least one; externalId cannot change. Set externalType or metadata to null to clear them.

Re-parenting moves the unit with everything under it. Spend already recorded keeps naming the unit it landed on. Caps, however, are evaluated on the tree as it is now: from the move on, the unit’s spend in the running cap period counts toward its new ancestors’ caps and no longer toward the old ones.

Deleting units

DELETE …/customer-units/{externalCustomerUnitId} soft-deletes a unit. It stays readable with status: DELETED, keeps the spend attributed to it, still receives the spend of signals that name its key, and cannot be reactivated. A delete is refused while the unit still has active children (HAS_ACTIVE_CHILDREN) or a cap that is in force or scheduled (HAS_ACTIVE_CAP): end the cap first. A deleted unit cannot be changed or capped (409 CUSTOMER_UNIT_DELETED).

Attributing spend to a unit

Add a customerUnit node to a signal to say which unit its spend belongs to:

1{
2 "signals": [
3 {
4 "eventName": "tokens_used",
5 "customer": { "externalCustomerId": "acme" },
6 "customerUnit": { "externalCustomerUnitId": "eu-tenant" },
7 "attribution": { "externalProductId": "assistant" },
8 "data": { "tokens": 1200 }
9 }
10 ]
11}

The unit does not have to exist yet. Paid records the spend under the customer and the unit’s key; it counts toward the unit, and toward the caps above it, as soon as a unit with that key exists. A signal never creates a unit: that is always your call through the API. A key longer than 255 characters fails validation (400).

Spend from a signal that names no unit stays on the customer and counts toward no cap, not even the root’s.

This holds only while customer units are enabled for your organization. Before that, signals are accepted but their customerUnit node is ignored and no key is recorded, so spend sent before enablement is never attributed to a unit afterwards.

Credit caps

A cap limits the credits of one currency that a unit and everything under it may spend per period. A cap on the root covers every unit of the customer, which makes it the customer-wide cap for unit-attributed spend: only spend whose signal named a unit counts toward any cap, including the root’s. Spend that named no unit counts toward none. A cap is addressed by the unit and a credits currency; cap versions have no id of their own.

Setting a cap

$curl -X PUT https://api.agentpaid.io/api/v2/customers/external/acme/customer-units/eu-tenant/cap \
> -H "Authorization: Bearer $PAID_API_KEY" \
> -H "Content-Type: application/json" \
> -d '{ "amount": 10000, "frequency": "MONTHLY" }'
  • amount is in credits of the cap’s currency, per period, and must be positive.
  • creditsCurrencyId may be omitted only when your organization has exactly one credits currency, which is then used; otherwise it is required (400 CREDITS_CURRENCY_REQUIRED). A currency that does not exist is 404 CREDITS_CURRENCY_NOT_FOUND.
  • frequency is MONTHLY, the default. Periods start on the day of the month of effectiveFrom, in UTC, clamped in shorter months.
  • effectiveFrom is an ISO 8601 timestamp and defaults to now. Spend earlier in the period that contains it still counts toward the cap. A future value schedules the cap; it starts applying then.

Caps are versioned and never edited in place. Setting a cap records a new version and touches none of the existing ones; where versions overlap, the newest applies. The response is the version just recorded as policy (amount, frequency, effectiveFrom, effectiveUntil: null, createdAt, source), with currentPeriod when it is already in force and usage is available, null otherwise. Setting a cap on a deleted unit is 409 CUSTOMER_UNIT_DELETED.

Reading usage

GET …/customer-units/{externalCustomerUnitId}/cap?creditsCurrencyId=… returns the version in force as policy and its currentPeriod: the window (start inclusive, end exclusive, UTC), used (the credits of that currency spent by the unit and its descendants in the window) and remaining (amount - used, negative once the cap is exceeded). Usage is computed on request from the ledger by the billing service; the read is advisory, since spend may land between this read and the next one. No cap in force for that unit and currency is 404 CAP_NOT_FOUND; when the billing service cannot answer, the read fails with 500 BILLING_SERVICE_UNAVAILABLE and can be retried.

Ending a cap

DELETE …/customer-units/{externalCustomerUnitId}/cap?creditsCurrencyId=… ends the cap: every open version of that currency on the unit, including scheduled ones and older overlapping ones, stops applying now, so nothing can resurface or activate later. The response carries endedVersions (how many were closed, at least one) and policy, the version that was in force at the time with its effectiveUntil set, or null when only scheduled versions were ended. To start over, end the cap and set a new one.

When a cap is reached

Paid evaluates caps on a schedule, about every three minutes, and sends the billing-credit-cap-reached webhook once per cap and period when a unit’s spend reaches its cap. See Webhooks for the payload. Reaching a cap does not stop Paid from recording spend; it tells you, so that you can act.

Error codes

CodeStatusMeaning
FEATURE_NOT_ENABLED403Customer units are not enabled for your organization.
CUSTOMER_UNIT_NOT_FOUND404No customer, or no unit with that key on this customer.
CUSTOMER_UNIT_EXISTS409A unit with this externalId already exists on the customer.
ROOT_EXISTS409The customer already has an active root; name a parent.
PARENT_NOT_FOUND, PARENT_NOT_ACTIVE409The parent does not exist, or is deleted.
ROOT_UNIT_IMMOVABLE409The root cannot be re-parented.
PARENT_CYCLE409The new parent is the unit itself or one of its descendants.
DEPTH_EXCEEDED409The tree would exceed 16 levels.
CUSTOMER_UNIT_DELETED409The unit is deleted; it cannot be changed or capped.
HAS_ACTIVE_CHILDREN, HAS_ACTIVE_CAP409The unit cannot be deleted yet.
SEAT_BILLING409The customer is on seat-based billing; units are not available.
CAP_NOT_FOUND404No cap in force for that unit and currency.
CREDITS_CURRENCY_REQUIRED400Your organization has several credits currencies; name one.
CREDITS_CURRENCY_NOT_FOUND404The credits currency named does not exist.
BILLING_SERVICE_UNAVAILABLE500The cap usage could not be computed right now; retry.

Also available

The same operations are exposed as MCP tools generated from the API reference: paid_list_customer_units, paid_create_customer_unit, paid_get_customer_unit, paid_update_customer_unit, paid_delete_customer_unit, paid_get_customer_unit_cap, paid_set_customer_unit_cap and paid_end_customer_unit_cap, each with a …_by_external_id twin that addresses the customer by your own id.