Skip to main content

Products (Plans) API

Manage products — the pricing plans assigned to customers.

Create Product​

POST /api/v1/products

Body fields:

FieldTypeRequiredDescription
namestringyesCustomer-facing plan name (max 100 chars)
currencystringnoOptional, 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.
descriptionstringnoInternal description (max 255 chars)
subscriptionobjectnoSubscription config — see below
servicesarrayyesService pricing entries (see below). At least one is required
entitlementsarraynoInline entitlements attached to this product. See Entitlements API.

Service entry:

FieldTypeRequiredDescription
service_idstringyesThe service being priced. Its id, or its name
pricing_modelstringnopay_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_unitnumbernoPrice per billing unit. 0 is a valid price; negatives are rejected
pricing_rulestringnoDerive the price from the service's vendor cost instead of setting it. Only fixed_uplift today. See Pricing from vendor cost
uplift_amountnumbernoAmount added to the vendor cost. Required with pricing_rule; 0 is valid
free_quotanumbernoFree units per period before charging, in the service's billing unit. Capped per service: see How large a quota may be
item_pricingarraynoPer-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_quotasarraynoOne 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:

FieldTypeRequiredDescription
monthly_feenumberyesRecurring monthly fee, in your environment's currency (use 0 for usage-only plans)
annual_feenumbernoOptional 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
}
]
}
]
}
caution

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_rule requires uplift_amount. A fixed fee of 0 is valid and means the price equals the cost; a negative one is rejected.
  • uplift_amount without pricing_rule is rejected.
  • pricing_rule cannot be combined with price_per_unit. Pick one.
  • pricing_rule needs pricing_model named explicitly, as pay_as_you_go or free_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_quota only applies to the two quota pricing models (free_quota_overage and free_quota_block). Sent with any other model it configures nothing and is not reported.
  • Under free_quota_block the quota is what gates usage, and prohibited blocks 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. prohibited blocks, and so does free_quota_block with no free_quota or a free_quota of 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 as prohibited.
  • 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_pricing entry 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_id is matched without regard to case, so gpt-4-turbo prices the item stored as GPT-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_pricing may name each item once. Two entries naming the same item, including two spellings of one ID that differ only in case, are refused with 422 naming both, and nothing is written. Two different services may each price the same item.
  • Inline entitlements are 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_quotas keeps 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 a free_quota of 0. That is the opposite of item_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_go or prohibited in item_pricing. free_quota_overage and free_quota_block are refused with 400, with or without a free_quota of 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 as pay_as_you_go at 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_ids and matches_model. A dimension counts toward the allowance only in a plan that prices it, so item_ids lists the covered codes this plan prices and unpriced_item_ids the covered codes it does not. Usage of an unpriced code is charged at a broader price and does not count toward the allowance.
  • matches_model is false once the model has gained or lost a dimension since the allowance was created, and while any code in unpriced_item_ids is 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}/quota shows one row for the shared allowance, carrying model_id and the covered item_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_ids is what the allowance covers today; model_item_ids is what the model's dimensions are now. matches is true only when they agree and every plan prices each dimension.
  • missing lists dimensions the allowance does not cover yet, so their usage is charged from the first unit. extra lists codes still covered that are no longer dimensions.
  • plans names 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, and unpriced, the dimensions it does not price. Their usage is charged at a broader price and does not count toward the allowance. Each entry carries a reason:
    • 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 set matches to false, and applying writes nothing to that plan.
  • priced lists what a POST priced in that plan, with price_per_unit and prohibited. It is empty on a GET.
  • Applying moves what the allowance covers and prices the dimensions it can. A not_priced_yet dimension 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 in unpriced. 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 POST is read back from the billing backend rather than echoed, so anything that could not be applied still reports under missing or in a plan's unpriced.
  • Applying when matches is already true changes 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.

caution

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}
caution

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).

caution

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.