Products (Plans) API
Manage products — the pricing plans assigned to customers.
Create Product
POST /api/v1/products
Body fields:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Customer-facing plan name (max 100 chars) |
currency | string | no | Optional, and your environment decides. Every plan is denominated in your environment's own currency, so a value naming a different one is refused with 422 rather than quietly replaced. Omit it unless you want the check. |
description | string | no | Internal description (max 255 chars) |
subscription | object | no | Subscription config — see below |
services | array | yes | Service pricing entries (see below). At least one is required |
entitlements | array | no | Inline entitlements attached to this product. See Entitlements API. |
Service entry:
| Field | Type | Required | Description |
|---|---|---|---|
service_id | string | yes | The service being priced. Its id, or its name |
pricing_model | string | no | pay_as_you_go, free_quota_overage, free_quota_block, or prohibited; any other value is rejected. Omit it and the position charges price_per_unit per unit, exactly as pay_as_you_go does, except that a pricing_rule still needs the model named. An allowance needs its model named too, so a free_quota sent with no pricing_model is rejected |
price_per_unit | number | no | Price per billing unit. 0 is a valid price; negatives are rejected |
pricing_rule | string | no | Derive the price from the service's vendor cost instead of setting it. Only fixed_uplift today. See Pricing from vendor cost |
uplift_amount | number | no | Amount added to the vendor cost. Required with pricing_rule; 0 is valid |
free_quota | number | no | Free units per period before charging, in the service's billing unit. Capped per service: see How large a quota may be |
item_pricing | array | no | Per-item overrides within this service, each carrying item_id plus any of pricing_model, price_per_unit, pricing_rule, uplift_amount, free_quota. Each field means for one item what it means for the service |
model_quotas | array | no | One allowance shared by every priced dimension of a model, each carrying model_id plus pricing_model and free_quota. See One allowance across a model's dimensions |
Subscription object:
| Field | Type | Required | Description |
|---|---|---|---|
monthly_fee | number | yes | Recurring monthly fee, in your environment's currency (use 0 for usage-only plans) |
annual_fee | number | no | Optional discounted yearly price. Omit (or null) on create for a monthly-only plan. When set, customers can be assigned on an annual term (billed yearly as a 12-month commitment). On update the field is acted on only when present: send a number to set or change the yearly price, send null to clear the annual term, and omit it to leave the term exactly as it is. Clearing a term customers are already billed on is refused with 409. A yearly price is capped at twelve times the monthly fee, on create as on update, and a request that leaves the pair above that ceiling is refused with 400. With a monthly fee of 0 the ceiling is 0, so such a plan cannot carry a yearly price at all. Only a request that changes one of the two amounts is judged, so a plan stored above the ceiling can still be updated in every other respect; to correct it, raise monthly_fee to at least a twelfth of the yearly price. |
The fee goes only in this object: a monthly_fee sent at the top level of a create or an update is ignored, and so is a free_trial_days inside subscription. Add a free trial with POST /api/v1/products/{product_id}/trial. The plan id is always assigned by the billing backend and comes back in the answer.
Request:
{
"name": "Pro Plan",
"subscription": {
"monthly_fee": 99.00,
"annual_fee": 950.00
},
"services": [
{
"service_id": "123",
"pricing_model": "pay_as_you_go",
"price_per_unit": 0.005,
"item_pricing": [
{
"item_id": "GPT-4-TURBO",
"price_per_unit": 0.003
}
]
}
]
}
services must name at least one service. Omitting the field, or sending services: [], is rejected
with 422: a plan that prices no service cannot charge usage, so it is not sellable. This is the same
rule the Create Plan wizard applies, and the same one the update path applies.
Pricing from vendor cost
Instead of setting price_per_unit, a service or an item override can be priced as vendor cost
plus a fixed amount:
{
"service_id": "123",
"pricing_model": "pay_as_you_go",
"pricing_rule": "fixed_uplift",
"uplift_amount": 10.00
}
With a recorded vendor cost of 65.00 per billing unit, the customer is charged 75.00 and the margin is exactly the fixed fee.
A cost of 0 is a cost a price can be built from: the price is then the fixed fee on its own,
which is what a vendor charging nothing plus your fixed fee comes to, and it follows the cost again in
both directions, so a cost that falls back to 0 is followed down on the next save.
Five rules the endpoint enforces, each answering with 422 rather than guessing:
pricing_rulerequiresuplift_amount. A fixed fee of0is valid and means the price equals the cost; a negative one is rejected.uplift_amountwithoutpricing_ruleis rejected.pricing_rulecannot be combined withprice_per_unit. Pick one.pricing_ruleneedspricing_modelnamed explicitly, aspay_as_you_goorfree_quota_overage. Leaving the model out is refused here, even though an omitted model charges per unit everywhere else.- A service with no vendor cost to read at all cannot be priced this way, meaning one carrying no vendor tariff, since nothing could ever rebuild such a price.
The position with nothing to stand on is refused and the response names it, the item included. A price already saved is exempt, since it charges the figure it was built with and refusing it would make the plan impossible to edit at all. Nothing is created or changed when a refusal fires.
The price is resolved when the plan is saved, not on every charge. If the service's vendor cost
changes afterwards, the plan keeps the price it was saved with. Get Product reports
pricing_rule, uplift_amount and basis_cost (the cost the price was computed from), so the
difference is visible. The plan list carries no pricing at all, derived or typed, so read a
single plan to see its rule.
A price you deliberately left behind is not undone by the next save. When a review settles a
price as keep (see Reprice a Service's Plans), later
saves of that plan write the price it already charges rather than recomputing it, so an edit to the
name or the fee cannot move it. Get Product reports that decision as kept_cost, the
vendor cost it was taken against.
It holds until the vendor cost moves again, you rebuild the price, or you change the
uplift_amount — asking for the cost plus a different amount is a decision about the price too, so
the save prices it from the cost recorded now.
To find every plan in that state, and to rebuild them, see List Plans Priced From an Outdated Cost and Reprice a Service's Plans.
basis_cost is null when the service has no recorded vendor cost. That plan is charging the
fixed fee on its own, which is not the same as a service whose cost is genuinely 0.
Response: the created plan in full, in exactly the shape Get Product returns: the
services with their resolved ids and prices, the item-level overrides, and the subscription fees. A
POST followed by a GET describe the same plan, so there is no need to re-read it just to see what
was saved.
Note that the response describes what was actually configured, which is not always everything the request listed:
- A
free_quotaonly applies to the two quota pricing models (free_quota_overageandfree_quota_block). Sent with any other model it configures nothing and is not reported. - Under
free_quota_blockthe quota is what gates usage, andprohibitedblocks outright, so neither carries a per-unit price. A price sent alongside them is not part of the resulting plan. - Two settings block, and they block the same way at either level.
prohibitedblocks, and so doesfree_quota_blockwith nofree_quotaor afree_quotaof 0: with no allowance to spend, there is nothing left to permit. Both write a rate the billing backend refuses, on a whole service and on a single item alike, and both read back asprohibited. - An item outranks its service. A blocked service still serves any item you leave permissive, so to block a service completely, leave its items blocked too. That is also the escape hatch: give one item a real allowance under a blocked service and only that item is usable.
- An
item_pricingentry naming an item that does not exist is ignored rather than rejected, so it does not appear in the response either. The exception is an entry that blocks: rather than leave the item billable, it fails the whole request. Check the response if you are unsure one landed. - An
item_idis matched without regard to case, sogpt-4-turboprices the item stored asGPT-4-TURBO. Reading the plan back names each item as the catalog stores it, whatever casing you sent, and a price derived from vendor cost keeps its fixed fee and its basis across that difference. The create response names it as stored too, including where the billing backend cannot be read straight after the write. Only an ID the catalog holds in no casing comes back as you sent it. - One service's
item_pricingmay name each item once. Two entries naming the same item, including two spellings of one ID that differ only in case, are refused with422naming both, and nothing is written. Two different services may each price the same item. - Inline
entitlementsare applied after the plan itself, so read the plan back to see them.
How large a quota may be
A free_quota is stated in the service's billing unit, but the billing backend counts it in the
service's measurement units, and it stores no more than 2,147,483,647 of them. So the
maximum depends on the service's ratio: at 1,000,000 measurement units per billing unit, the largest
quota is 2147.48364. At a ratio of 1 it is the full 2,147,483,647.
A quota above that is refused with 422 before anything is written, naming the maximum for each service the request oversized:
{"error":{"code":"VALIDATION_ERROR","message":"A quota is counted in the service's own measurement units, and the billing backend stores no more than 2147483647 of them. Too large: text-generation (10000, at most 2147.48364)"}}
The same ceiling applies to a model_quotas allowance. It is measured once for the whole model
rather than once per dimension, because one entry carries it, and the refusal names it as
shared allowance on <model_id>.
The ratio is fixed when the service is created and cannot be changed afterwards, so a plan that needs a larger allowance needs a service billed in a coarser unit.
One allowance across a model's dimensions
In the API a model is an item whose price is set separately per dimension. It mints one item per
dimension, so OPUS46 becomes OPUS46-IN and OPUS46-OUT. Giving each of them a free_quota
grants the customer the allowance twice. Use model_quotas to grant it once, shared:
{
"service_id": "tokens",
"item_pricing": [
{"item_id": "OPUS46-IN", "pricing_model": "pay_as_you_go", "price_per_unit": 0.30},
{"item_id": "OPUS46-OUT", "pricing_model": "pay_as_you_go", "price_per_unit": 0.10}
],
"model_quotas": [
{"model_id": "OPUS46", "pricing_model": "free_quota_overage", "free_quota": 10}
]
}
Four units of OPUS46-IN and three of OPUS46-OUT leave three, not six and not thirteen.
Past the allowance each dimension is charged at its own price, or the service blocks when you
send free_quota_block.
- The allowance covers every dimension of the model, and it is set on the plan, never on the service.
- Omitting
model_quotaskeeps the allowances a plan already grants; sending the field clears every allowance it does not restate. An empty list clears them all, and so does a list whose entries grant nothing, such as afree_quotaof0. That is the opposite ofitem_pricing, where an omitted list drops the overrides, so send the field only when you mean to change it. - A dimension of a model carrying a shared allowance takes
pay_as_you_goorprohibitedinitem_pricing.free_quota_overageandfree_quota_blockare refused with 400, with or without afree_quotaof the dimension's own. With one, the billing backend would honour only one of the two allowances; without one, blocking would stop the dimension from its first unit and overage would read back aspay_as_you_goat the item's price, which is 0 when you send none. - Naming the same model twice in one service, or naming a model with no items in the environment, is refused with 400. Every refusal happens before anything is written, so a rejected request changes nothing.
- Reading the plan back reports
item_ids,unpriced_item_idsandmatches_model. A dimension counts toward the allowance only in a plan that prices it, soitem_idslists the covered codes this plan prices andunpriced_item_idsthe covered codes it does not. Usage of an unpriced code is charged at a broader price and does not count toward the allowance. matches_modelisfalseonce the model has gained or lost a dimension since the allowance was created, and while any code inunpriced_item_idsis listed. The allowance keeps covering exactly the codes it was built with, so an added dimension is not silently included and a dropped one is not silently dropped. To bring the allowance up to date, see Review and apply a model's dimensions.GET /customers/{id}/quotashows one row for the shared allowance, carryingmodel_idand the covereditem_ids. It reports an overage price only while every covered dimension resolves to the same one, since each is charged at its own.
Review and apply a model's dimensions
When a model gains or loses a dimension, the allowances already granted on it keep covering the codes they were built with. Two calls review that difference and settle it:
GET /api/v1/items/models/{model_id}/quota-coverage
POST /api/v1/items/models/{model_id}/apply-quota-coverage
Both answer the same shape:
{
"model_id": "OPUS46",
"item_ids": ["OPUS46-IN", "OPUS46-OUT"],
"model_item_ids": ["OPUS46-CCH", "OPUS46-IN", "OPUS46-OUT"],
"matches": false,
"missing": ["OPUS46-CCH"],
"extra": [],
"plans": [
{
"plan_id": "1417",
"plan_name": "Starter",
"service_ids": ["246"],
"unpriced": [
{"item_id": "OPUS46-CCH", "service_id": "246", "reason": "not_priced_yet"}
],
"priced": []
}
]
}
item_idsis what the allowance covers today;model_item_idsis what the model's dimensions are now.matchesistrueonly when they agree and every plan prices each dimension.missinglists dimensions the allowance does not cover yet, so their usage is charged from the first unit.extralists codes still covered that are no longer dimensions.plansnames every plan granting that allowance. One allowance is shared, so a change to what it covers reaches all of them at once, and there is no way to apply it to one plan and not another. Read this list before you confirm.- Each plan lists
service_ids, the services it grants the allowance under, andunpriced, the dimensions it does not price. Their usage is charged at a broader price and does not count toward the allowance. Each entry carries areason:not_priced_yet: applying gives the dimension the price it inherits (a broader item's, else the service default) as its own.vendor_cost: the price it inherits follows vendor cost, so price it in the plan itself.no_default_price: the plan has no price for it to copy, so add an override in the plan.unknown: the plan's prices could not be read. Such an entry does not setmatchestofalse, and applying writes nothing to that plan.
pricedlists what aPOSTpriced in that plan, withprice_per_unitandprohibited. It is empty on aGET.- Applying moves what the allowance covers and prices the dimensions it can. A
not_priced_yetdimension gets the price it inherits as its own: the plan's price for a broader code that covers it, or otherwise its default price, blocked if that is blocked. What a customer is charged does not change. A dimension whose current price follows vendor cost, and every plan whose prices could not be read, are left as they are and still report inunpriced. A price reaches only its own plan. - Applying changes what the allowance covers, never how much of it is left. A customer part-way through the allowance keeps their remaining units.
- The answer to a
POSTis read back from the billing backend rather than echoed, so anything that could not be applied still reports undermissingor in a plan'sunpriced. - Applying when
matchesis alreadytruechanges nothing and answers the same state.
The plan editor drives both calls; see Plans for the screens.
Refusals: 401 when the billing session has expired, which the client answers by refreshing;
404 when no plan grants the model a shared allowance, or when the model has no dimensions in
this environment; 409 when more than one destination group carries that model's name and each
one grants an allowance, so which plans a change would reach cannot be said; and 502 when the
billing backend will not say what the allowance covers or which plans grant it, since a short list
would understate what applying changes. The POST also answers 502 when what the allowance
covers differs from the model's dimensions, the billing backend resolves none of the codes it
differs by, and no dimension could be priced either, so nothing was changed.
One refusal on the POST reads differently: it says the allowance was given the model's
dimensions but reading it back answered something else. The write landed, so read the coverage
again rather than assuming nothing happened. Applying a second time is safe either way.
List Products
GET /api/v1/products
Returns all active products with basic info. Use the get endpoint for full pricing details.
Both this endpoint and Get Product return deletable on every plan. It is false
after the billing backend takes hold of the plan: a customer on it now, or the billing history that a
customer leaves behind by moving off it. PortaAIM offers no way to release such a plan, so treat a
false as settled.
Both also return currency, the ISO 4217 code every fee and price on the plan is denominated in.
It is your environment's own currency and it is read-only: you cannot set it per plan, and it is
null only if the billing backend reports none.
Both take with_timestamps, off by default, which fills created_at and updated_at.
created_at is when the billing backend created the plan; updated_at is when the plan was last
changed through PortaAIM, so a plan nobody has edited yet carries none. Every other answer,
including the one a create or an edit returns, leaves both fields out; read the plan again with
with_timestamps to see them.
Get Product
GET /api/v1/products/{product_id}
Returns full product details including all service pricing configuration.
List Products In Use
GET /api/v1/products/usage
Returns the IDs of products referenced by at least one customer/account in the current environment.
Response:
{
"in_use_product_ids": ["123", "456", "789"]
}
This is a membership check only: no counts are returned. Backed by an environment-scoped aggregated query against the billing engine, so it stays cheap regardless of customer volume. The set includes products referenced by terminated customers as well.
This set is not what blocks a delete: read deletable on the plan for that. This endpoint answers the narrower question of who is on a plan right now.
Trial products are reported separately — a customer on a trial plan marks the trial's own product ID, not its parent. This matches how the billing engine attributes accounts and gives the UI a true picture of which trial variants are in use independent of their paid parents.
A trial in this set does block its parent plan's delete, because the two are deleted together. Read linked_trial.deletable on the parent for that answer rather than matching product IDs yourself.
Update Product
PATCH /api/v1/products/{product_id}
Update pricing, services, or subscription fee. Only include the fields you want to change.
services is the plan's full intended service list, not a list of additions: entries are added
or repriced, and any service missing from it is removed from the plan. Omit the field to leave the
plan's services untouched.
An empty services: [] is rejected with 422, on update as on creation: a plan must have at least
one service, otherwise it can no longer charge usage. Removing services down to the last one is
allowed, going to zero is not. To retire a plan, deactivate it (is_active: false) and stop
offering it; deletion only works while the plan has no billing history.
Delete Product
DELETE /api/v1/products/{product_id}
A plan the billing backend has taken hold of cannot be deleted, and reassigning its customers does not release it: the billing history stays. Read deletable on the plan before offering the action.
A plan that has a linked free trial is deleted together with that trial: this one request removes
both, and the trial is removed first so an interrupted call never leaves the trial behind on its
own. The request is refused with 409 when either half has been used, and nothing is deleted in
that case. Read linked_trial.deletable on the plan for the trial's half of that answer, and
deletable for the plan's own.
The call is safe to repeat. A product that is already gone answers success rather than an error, so a client that never received the first answer can simply send the request again.
Trial Management
Create Trial
POST /api/v1/products/{product_id}/trial
Creates a trial variant of the product.
Request:
{
"name": "Free Trial - Pro Plan",
"default_trial_duration_days": 14,
"services": [
{
"service_id": "123",
"pricing_model": "free_quota_block",
"free_quota": 100
}
]
}
default_trial_duration_days is the length a customer put on this trial gets when they name no
length of their own. Omit it and a customer started on this trial without a length has no end
date, and is not billed until you end the trial.
services takes the same service entries as Create Product, and a trial may only
cap what it offers. A service or item that would charge from its first unit is refused with 400,
which covers pay_as_you_go, a pricing_model left out, and free_quota_overage carrying no
allowance.
Every service you name has to be one the paid plan sells. One it does not is refused with 422,
naming the service, and nothing is created.
product_id has to name a paid plan the billing backend holds. One it does not hold is refused with
404. A trial carries no trial of its own, so naming one is refused with 422.
Update Trial
PATCH /api/v1/products/{product_id}/trial
Takes the Create Trial body without name, and answers with the paid plan. The services you send
are the ones the trial offers afterwards: one you leave out is removed from the trial, one you add
is added to it, and an allowance of zero blocks the service for the length of the trial. Sending no services
at all leaves the set as it stands and changes only the trial's length, and the same refusals as
the create apply. Each service entry has to name its pricing model: a bare service_id is refused,
because it would provision the service as charging from its first unit. The body carries no
subscription fee, because a trial is never charged, and no name: a trial keeps the name it was
created with, and a name sent here is ignored.
A default_trial_duration_days you leave out keeps the trial's default length as it is. Send
null to remove the default, so that customers started on the trial afterwards without a length
have no end date.
The service rule applies here too, with one exception: a service the trial already prices is
accepted even after the plan stops selling it, so you can keep saving the trial until you take that
service out of it. PATCH /api/v1/products/{product_id} aimed at the trial's own id is refused on
the same terms, because the rule belongs to the trial rather than to the route.
Disable Trial
DELETE /api/v1/products/{product_id}/trial
Clone Product
POST /api/v1/products/{product_id}/clone
Clones the product to the other environment (live → sandbox or sandbox → live).
A plan with no services cannot be copied: the request is refused with 409
(error.code: plan_has_no_services) and nothing is written to the target environment. Such plans
exist only from before creation required a service. Add one to the source plan first.
More generally, a source plan carrying anything the create contract refuses is rejected with 409
(error.code: plan_cannot_be_copied) before the target is touched. error.details.fields lists every
field at fault, each naming the row it came from, for example services.1.service_id.
A plan is copied only into an environment that bills in the same currency. Both environments are
denominated once, when they are created, and a copy keeps the source plan's numbers, so copying
across a currency boundary would leave 900 meaning something else. The request is refused with 409
(error.code: plan_cannot_be_copied) and no error.details.fields, and error.message names both
currencies. A source plan the billing backend reports no currency for is refused the same way, since
nothing can then say whether the copy would change what its prices mean.
A refusal raised while the plan is being created in the target environment arrives under that same
409 (error.code: plan_cannot_be_copied), carrying no error.details.fields: error.message names
the target environment and gives the reason. A price derived from vendor cost reaches this when the
target environment records no cost for the service it prices from, so record that cost there and copy
again. Nothing is written to the target environment beforehand.
The name override is checked on this request instead, as a 422: it follows the same 1 to 100
character rule as a plan name. Omit the field, rather than sending an empty string, to keep the source
plan's name.