Products and prices
A product is what you sell, a price is how much and how often, and a checkout always names a price.
Product or price
A product carries the name and description a customer sees on the payment page, the invoice and the portal. It has no amount of its own.
A price pins an amount, a currency and an interval to one product, along with the trial, the
payment methods, the refund policy and the tax treatment. It is what a customer buys: checkout
sessions, subscriptions and plan changes all name a price_…, never a product.
Use one product per offering and several prices under it: monthly and yearly, or one per currency.
Create a product
curl https://api.billkit.eu/v1/products \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro plan",
"description": "For teams that ship every week.",
"marketing_features": ["Unlimited projects", "Priority support"]
}'{
"id": "prod_kQ2v8Zr4nT1yLpXaBc",
"object": "product",
"created": 1789344000,
"livemode": false,
"name": "Pro plan",
"description": "For teams that ship every week.",
"prices": null,
"stats": null,
"marketing_features": ["Unlimited projects", "Priority support"],
"metadata": {},
"active": true,
"allow_promotion_codes": false,
"default_price_id": null,
"default_price": null,
"archived_at": null
}| Field | Notes |
|---|---|
| namerequired | Up to 255 characters. Shown to the customer. |
| description | Up to 2,048 characters. |
| marketing_features | Up to 24 strings, listed on the portal’s plan switcher. |
| metadata | Up to 50 keys of 40 characters, values up to 500. Never read by BillKit. |
| allow_promotion_codes | Lets a buyer type a coupon code in the embedded checkout, and a customer type one in the billing portal when its coupons capability is on. Defaults to false. See Discounts with coupons. |
prices, stats and default_price are null unless you ask for them with
?expand=prices,stats,default_price. See Expanding responses.
default_price_id stays null until the product has a price.
Create a price
curl https://api.billkit.eu/v1/prices \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_id": "prod_kQ2v8Zr4nT1yLpXaBc",
"amount_cents": 29000,
"currency": "EUR",
"interval": "year",
"trial_days": 14,
"tax_behavior": "exclusive",
"payment_methods": ["creditcard", "ideal"],
"metadata": {"plan": "pro-yearly"}
}'{
"id": "price_7Hd3Wm9pQx2vRt5Kna",
"object": "price",
"created": 1789344060,
"livemode": false,
"product_id": "prod_kQ2v8Zr4nT1yLpXaBc",
"product_name": "Pro plan",
"amount_cents": 29000,
"unit_amount_decimal": "29000",
"currency": "EUR",
"interval": "year",
"active": true,
"metadata": {"plan": "pro-yearly"},
"trial_days": 14,
"trial_verification_cents": 100,
"payment_methods": ["creditcard", "ideal"],
"refund_on_cancel": "none",
"refund_window_initial_days": null,
"refund_window_renewal_days": null,
"tax_behavior": "exclusive",
"usage_type": "licensed",
"billing_scheme": "per_unit",
"tiers_mode": null,
"tiers": null
}The product must exist and be active. Every field below is optional unless marked.
| Field | Notes |
|---|---|
| product_idrequired | The prod_… this price sells. |
| amount_cents | What is charged each period, as a positive integer in the currency’s minor unit: 29000 is €290.00. Required on a flat price. |
| currency | ISO 4217, defaults to EUR. It must be enabled on your account; GET /v1/tenant/capabilities lists yours in allowed_currencies, and anything else is a 400. |
| intervalrequired | month or year. There is no interval count, so quarterly or weekly billing cannot be expressed. |
| trial_days | 0 to 90, default 0. See Free trials. |
| trial_verification_cents | What a trial or metered checkout charges to create the mandate. Default 100. |
| payment_methods | Offered at checkout. Any of creditcard, ideal, eps, applepay, paypal, directdebit; default ["creditcard"]. At least one must be able to take a first payment, so directdebit alone is a 400. |
| tax_behavior | inclusive (VAT is inside amount_cents), exclusive (VAT is added on top) or unspecified, the default, which defers to the tax rate for the buyer’s country. See Charge and record EU VAT. |
| refund_on_cancel | none (default), full or prorated. See Refunds. |
| refund_window_initial_days, refund_window_renewal_days | 0 to 365. null uses the default window, 0 disables refunds for that kind of charge. |
| usage_type | licensed (default) charges amount_cents every period. metered makes it a per-unit rate billed in arrears from usage records. |
| unit_amount_decimal, billing_scheme, tiers_mode, tiers | Sub-cent and tiered rates, metered prices only. See Metered billing. |
| metadata | Same limits as on a product. |
There are no nicknames or lookup keys. Keep your own mapping from plan names to price_… ids, or
put a key in metadata and filter on it after listing.
unit_amount_decimal is returned on every price as a string, even when you only sent
amount_cents, so a client can read that one field for the rate.
The default price
A product’s default_price_id names the price the billing portal offers on that
price’s interval. A price created while its product has no default becomes the default, and
creating it also sends product.updated. That is the first price under a product, and also the
next price you create after clearing the default or archiving the default price. A price created
while a default is set does not change it.
To raise a price, create the new price and point the default at it with
POST /v1/products/{id}:
curl https://api.billkit.eu/v1/products/prod_kQ2v8Zr4nT1yLpXaBc \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"default_price_id": "price_Nt8Ql3wZ7bR2vMxKye"}'The default must be an active price of the same product. Anything else, including another
product’s price or an archived one, is a 400 on default_price_id. Send null to clear it; the
portal then offers the newest active price on each interval, until the next price you create
becomes the default. Customers already on the old price
keep renewing at it, and the portal still shows it to them as their current plan.
What can change after creation
The fields that decide what a past charge was are fixed: amount_cents, unit_amount_decimal,
currency, interval, usage_type, billing_scheme, the tier table, trial_days and
trial_verification_cents. Subscriptions renew against a price by id, so editing any of these
would re-price customers already on it. To charge something different, create a new price.
POST /v1/prices/{id} accepts the forward-looking fields only:
| Field | Notes |
|---|---|
| active | false archives the price, true puts it back on sale. |
| payment_methods | Read when a checkout opens. |
| metadata | Replaced as a whole. Send {} to empty it. |
| refund_on_cancel, refund_window_initial_days, refund_window_renewal_days | Read when a cancellation or refund happens, so a change covers customers already on the price. Send a window as null to go back to the default. |
| tax_behavior | inclusive or exclusive, and only while the price is still unspecified. Once set it cannot change; trying is a 400. |
curl https://api.billkit.eu/v1/prices/price_7Hd3Wm9pQx2vRt5Kna \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"refund_on_cancel": "prorated", "refund_window_renewal_days": 14}'Omitted fields are left alone. Only the two refund windows accept null; on any other field it is
refused with a 400 naming the field, because none of them can be emptied. A field not listed
here, amount_cents included, is refused with 400 parameter_unknown naming it:
curl https://api.billkit.eu/v1/prices/price_7Hd3Wm9pQx2vRt5Kna \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount_cents": 3900}'POST /v1/products/{id} is less strict, because a product holds no money: name, description,
marketing_features, metadata, allow_promotion_codes, default_price_id and active can all
change.
marketing_features and metadata are replaced, not merged. null clears description,
marketing_features and default_price_id. null on name, metadata,
allow_promotion_codes or active is a 400 naming the field; send metadata: {} to remove all
keys.
Archive, never delete
There is no DELETE for products or prices. Subscriptions and invoices point at them by id, so
they stay readable for good. To stop selling one, send active: false.
curl https://api.billkit.eu/v1/prices/price_7Hd3Wm9pQx2vRt5Kna \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"active": false}'An archived price is refused for anything new. A checkout session against it returns 404 resource_missing, as if the price did not exist, and it is no longer offered as a plan change:
curl https://api.billkit.eu/v1/checkout/sessions \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"customer_email": "grace@example.com",
"price_id": "price_7Hd3Wm9pQx2vRt5Kna",
"success_url": "https://example.com/welcome",
"cancel_url": "https://example.com/pricing"
}'Archiving a product’s default price also clears its default_price_id and sends
product.updated. The portal then offers the newest active price on that interval, until you set
a new default or create another price, which becomes it.
Customers already subscribed keep renewing at the archived price until they cancel or change
plan. GET /v1/prices/{id} and listings still return it, with active: false. Send
active: true to put it back on sale.
Archiving a product works the same way one level up. It stamps archived_at, and new checkouts
against any of its prices fail with a 400 on price_id. No new prices can be created under it.
Its prices keep their own active: true, so archive them too if your pricing page reads that
field.
| Change | Events |
|---|---|
| Created | product.created, price.created. A price created while the product has no default, such as the first one, also sends product.updated, for its new default_price_id. |
| Archived | product.archived, price.archived. Archiving a product’s default price also sends product.updated, for its cleared default_price_id. |
| Anything else | product.updated, price.updated. Un-archiving is an update. A price update that changes nothing emits no event. |
Listing
Both lists are newest first and paginated with limit (1 to 100, default 10) and
starting_after. Archived rows are included, so filter on active before rendering a pricing
page.
curl "https://api.billkit.eu/v1/prices?product_id=prod_kQ2v8Zr4nT1yLpXaBc&limit=100" \
-H "Authorization: Bearer $BILLKIT_API_KEY"product_id is the only filter on prices. Use it rather than filtering one page yourself, which
misses everything past the limit. Products have no filters, but GET /v1/products?expand=prices
returns each product with all its prices, active ones first, in one call.
Using a price
Pass the price id as price_id when you create a checkout session. That session’s first charge,
its trial and the payment methods on offer all come from the price. See the
Quickstart for the full flow, and Subscriptions for
moving a customer to a different price.
A plan change can only move between prices in the same currency, and never to or from a metered price. Keep a monthly and a yearly price per currency if you want customers to switch between them.
BillKit