Skip to main content

Services API

Manage service definitions — the categories of AI capabilities in your billing catalog.

Create Service​

POST /api/v1/services

Request:

{
"code": "AI-TOKENS",
"name": "LLM Tokens",
"measurement_unit": "tokens",
"billing_unit": "1M tokens",
"units_per_billing_unit": 1000000,
"default_cost_per_unit": 2.00
}
FieldTypeRequiredDescription
codestringYesAlphanumeric identifier used in API calls. Letters, digits, dashes, and underscores only. Max 32 characters.
namestringYesDisplay name
measurement_unitstringYesThe raw unit your system reports (e.g., tokens, bytes, seconds). Max 16 characters.
billing_unitstringYesCustomer-facing unit for pricing and invoices (e.g., "1M tokens", "GB"). Max 16 characters.
units_per_billing_unitnumberYesHow many measurement units equal one billing unit. Must be a whole number between 1 and 2,147,483,647.
default_cost_per_unitnumberNoVendor cost per billing unit. Omit or set 0 for a service with no vendor cost tracked, but note that sending any item_costs entry records a cost whatever this field says.
item_costsarrayNoPer-item vendor cost overrides: [{ "item_id": "...", "cost_per_unit": 1.50 }]. An item_id is matched without regard to case, so gpt-4-turbo records its cost against the item stored as GPT-4-TURBO.

The response includes a server-computed has_vendor_cost: boolean field: true when default_cost_per_unit > 0, or when the service records any item_costs entry at all, whatever that entry's cost. An entry of 0 counts: it exists only because it was sent, so it is a recorded cost, whereas a default of 0 is what a service gets when no cost was entered. It is recomputed on every read, so it always reflects the service's current pricing. A price derived from vendor cost does not ask this flag: it needs a cost it can read, and a recorded 0 is one, so such a price is accepted whatever this flag reports. Whether customers are billed (and how) is configured per Plan via pricing_model.

The response also carries created_at, the time the service was created, and the same value comes back on every later read. It is empty when that time is not known: a service created before PortaAIM recorded creation times, or one created directly in the billing engine, which reports no creation time of its own. Read an empty value as unknown, not as recent.

The response also carries item_costs: the per-item costs the service records, with each item named the way the catalog stores it. It describes what was saved rather than what you sent, so an entry naming an item that does not exist is absent from it, while the create still succeeds. PATCH answers the same way, with the whole set the service holds after the change.

Get Service​

GET /api/v1/services/{service_id}

Returns one service, with the vendor cost it records for each item. Use it to confirm what a create or an update saved, or to read one service without fetching the whole catalog.

Response:

{
"id": "171",
"code": "ai-tokens",
"name": "LLM Tokens",
"measurement_unit": "tokens",
"billing_unit": "1M tokens",
"billing_unit_ratio": 1000000,
"default_cost_per_unit": 2.0,
"has_vendor_cost": true,
"created_at": "2026-09-09T12:33:48Z",
"item_costs": [
{ "item_id": "GPT-4-TURBO", "cost_per_unit": 0.25 }
]
}

item_costs is an empty list when every item costs the service default, and null when the service records no vendor cost at all. An unknown service id answers 404.

List Services​

GET /api/v1/services

Returns all services with their associated items.

List Services In Use​

GET /api/v1/services/usage

Returns a map of service ID → names of the plans that reference it. Used by the catalog UI to disable deletion of services that are still attached to a plan (and to explain why). A service is "in use" whenever at least one plan references it.

Response:

{
"service_usage": {
"123": ["Starter", "Pro"],
"456": ["Enterprise"]
}
}

Derived by scanning every plan's service pricing, including plans hidden from the catalog UI — the billing engine blocks deletion regardless of a plan's visibility. A service absent from the map is referenced by no plan, which clears the most common obstacle to deleting it but not every one: see Delete Service for the quota and leftover-pricing cases this map does not cover.

List Plans Priced From an Outdated Cost​

GET /api/v1/services/stale-prices

A plan can price a service as vendor cost plus a fixed fee. The price that formula produces is written into the plan when the plan is saved, so it stays where it was when the vendor cost later changes. This endpoint reports every plan in that state.

Response:

{
"stale_prices": {
"393": [
{
"plan_id": "908",
"plan_name": "Starter",
"item_id": null,
"priced_at": 75.0,
"reprices_to": 80.0,
"basis_cost": 65.0,
"vendor_cost": 70.0,
"uplift_amount": 10.0,
"kept": false
}
]
}
}

Keyed by the service whose cost moved. priced_at is the price the plan's own formula produced when it was last saved, reprices_to what that same formula yields from vendor_cost, basis_cost the cost the standing price was built from, and uplift_amount the formula's fixed fee over it, so the arithmetic can be checked. item_id names the item when the formula sits on one item rather than the whole service. Plans priced by a typed amount never appear: there is no formula to fall out of date.

kept is true when this price was reviewed against vendor_cost and deliberately left where it is. It still differs from its own formula, so it stays listed rather than disappearing — treat it as decided, not as outstanding work. A cost that moves again clears the flag: the decision answered for a figure that no longer applies.

Every price this report can list has a cost to rebuild from, a recorded 0 included, and rebuilding onto a cost of 0 writes uplift_amount.

Only a service whose vendor cost cannot be read at all is absent from the report, because there is nothing to compare its prices against.

The report is computed fresh on every call, so a plan leaves it as soon as its price is rebuilt.

Preview a Cost Change​

POST /api/v1/services/{service_id}/preview-cost-change

What changing this service's vendor costs would do to the plan prices derived from them, answered before anything is saved. The request is the cost half of Update Service, so the answer describes the save you are about to make. Nothing is saved, so a repeated call is safe. It is a POST because the proposed costs travel in the request body.

Request:

{
"default_cost_per_unit": 70.0,
"item_costs": [
{ "item_id": "GPT-4", "cost_per_unit": 12.0 }
]
}

Both fields mean here what they mean on the update: an omitted default_cost_per_unit leaves the recorded default alone, an omitted item_costs leaves every override alone, and a sent list replaces the whole set. So an override the list omits is retired, and the prices built on it start following whatever broader code still covers those items, or the service default where none does. That is a cost change for them, reported like any other.

Response:

{
"impact": [
{
"plan_id": "908",
"plan_name": "Starter",
"item_id": null,
"priced_at": 75.0,
"reprices_to": 80.0,
"basis_cost": 65.0,
"current_vendor_cost": 65.0,
"proposed_vendor_cost": 70.0,
"uplift_amount": 10.0,
"kept": false
}
]
}

One entry per price the change would move, for this service only. priced_at is what the plan charges today and reprices_to what its own formula yields from proposed_vendor_cost, so one row carries both sides of the decision. An empty list means the change moves no price. A proposed 0 is a cost a rebuild can build on, and it rebuilds to uplift_amount.

Nothing is written. A proposal matching the recorded costs returns nothing.

No proposal is refused, including one that lowers the last cost to zero while plans price by formula: those prices move down to uplift_amount, and they are listed in impact like any other move.

A 422 means service_id is not a service id, or a cost was sent as a negative number.

Reprice a Service's Plans​

POST /api/v1/services/{service_id}/reprice-plans

Settles the prices listed above for one service. Nothing else triggers this: changing a vendor cost never reprices anything on its own, because that would change what your customers are charged without you asking.

Request (optional):

{
"prices": [
{ "plan_id": "908", "action": "reprice" },
{ "plan_id": "909", "item_id": "GPT-4", "action": "keep" }
]
}

Send no body and every stale price is rebuilt. Send one and each price is settled as you decided: reprice rebuilds it from the cost recorded now, keep leaves it charging what it charges and records that as a decision, so it stops being reported as unreviewed until the cost moves again. Keeping a price leaves the cost change on your margin instead of passing it to your customers: it earns less when the cost rose, more when it fell. Both are legitimate — a price you cannot raise yet, or a cut you would rather keep.

A stale price the list does not name is left untouched: it fell behind after your review was drawn. Sending no body rebuilds every price that is out of date, including ones a review chose to keep — name the prices when those decisions matter.

Response:

{
"plans": [
{
"plan_id": "908",
"plan_name": "Starter",
"item_id": null,
"outcome": "repriced",
"new_price": 80.0,
"detail": null
}
]
}

One entry per price touched, so a plan appears once for a service-level formula and once more for each item override it carries. outcome is repriced when the price was rebuilt, kept when it was left standing by choice, skipped when that price no longer uses the formula, and failed with a detail explaining what went wrong. An empty list means nothing you asked to settle was still out of date.

Each price is rebuilt from the vendor cost current at that moment, not from the figure the report showed, so a cost that moves again between the two calls is honoured rather than overwritten with a stale number.

A failure needs no special handling. Whatever fails is still out of date, so it stays in the report and calling this again finishes the job. There is no partial state to clean up.

A 422 means that service_id is not a service id, or that no vendor tariff exists for this service to read a cost from. That happens when the tariff was removed in the billing engine directly, or the service was left without one by a create or a delete that failed part way. A service that records costs and prices them all at zero is not refused: each of its prices is answered on its own, so a price you want to leave standing can still be settled.

Update Service​

PATCH /api/v1/services/{service_id}

Removing this service's vendor cost is accepted while a plan prices the service as vendor cost plus a fixed fee. Zeroing default_cost_per_unit and sending an empty item_costs are the same removal, and both go through: each price built on that cost moves down to its fixed fee, and it follows the cost back up once one is recorded. See what a removal would move before you send it with Preview a Cost Change.

Request:

{
"name": "AI Language Model Tokens",
"billing_unit": "per 1K tokens"
}
FieldTypeDescription
namestringNew display name. Omit to leave unchanged; an empty string is rejected.
measurement_unitstringNew raw measurement unit. Omit to leave unchanged.
billing_unitstringNew customer-facing billing unit. Omit to leave unchanged.
default_cost_per_unitnumberNew default vendor cost per billing unit. Omit to leave unchanged.
item_costsarrayThe per-item vendor cost overrides the service should have. Omit to leave them unchanged; send the full list to change one, since any item missing from a list you do send has its override removed. An item_id is matched without regard to case, and the service reads back naming each item as the catalog stores it, whatever casing you sent.
descriptionstringNew service description. Omit to leave unchanged.

code is not accepted here and cannot be changed — it's the identifier POST /charging matches against, so renaming it would silently break charging for the service. Renaming name is always safe: it changes only the display label everywhere the service is shown, and never touches the code your integration charges against.

units_per_billing_unit is ignored here. The ratio between the measurement unit and the billing unit is fixed when the service is created, so create a new service to bill in a different unit.

Delete Service​

DELETE /api/v1/services/{service_id}

Returns 204 No Content on success.

A service that is still in use cannot be deleted. The request is refused with 409 Conflict and a message naming what blocks it. There are three cases:

Still included in a plan — the most common one:

{"error":{"code":"CONFLICT","message":"This service can't be deleted while a plan still includes it. Remove it from 'Starter', 'Pro' first, then retry."}}

Still used by a quota — a quota configuration grants or limits this service:

{"error":{"code":"CONFLICT","message":"This service can't be deleted while a quota still applies to it. Remove it from the quota settings of 'Pro' first, then retry."}}

A leftover from an unfinished plan change — pricing or a quota configuration for this service still exists outside any plan. There is no screen for it, so this one needs support rather than an action on your side:

{"error":{"code":"CONFLICT","message":"This service can't be deleted because pricing for it still exists outside any plan — usually left over from a plan change that did not finish. Contact support to have it cleared."}}

All of these checks run before anything is changed, so a refused delete leaves the service and its cost configuration completely intact and it keeps charging normally. The catalog UI disables the delete action for services a plan includes in the first place (see List Services In Use), so you will normally only meet these responses when calling the API directly.

One further 409 can appear mid-request, when the billing backend has a pending change on the service's pricing:

{"error":{"code":"CONFLICT","message":"This service can't be deleted right now because its pricing is locked by a pending change in the billing backend. Nothing was changed — try again shortly, and contact support if it persists."}}

As the message says, nothing is changed — retry shortly. The wording is deliberate: if part of the service's cost configuration had already been removed and could not be put back, the message says so instead, and asks you to contact support before using the service again.

And one 409 reports a state that a retry cannot fix — the billing engine refused the delete after the service's cost configuration had already been removed:

{"error":{"code":"CONFLICT","message":"The billing backend refused to delete this service, and its cost configuration had already been removed, so the service can no longer charge. Something still references it that PortaAIM cannot see. Contact support: retrying will not clear it."}}

This is rare: it means something references the service that PortaAIM cannot query in advance. The service still exists but can no longer charge, so it needs support rather than a retry.

A 401 means your session expired mid-request. Sign in again and retry — the retry is what completes or cleanly refuses the delete. Do not assume the service is untouched: the request may already have removed part of its cost configuration, and while that is the case the service cannot charge. If a retry does not resolve it, contact support.