Headless Pricing
Render your own pricing table from the headless config — every rate plan lists each billable unit with its own pricing model, rate, tiers, and outcome weights.
Headless Pricing
You are building your own pricing page and want it to show exactly what Aforo will bill. One request — GET /api/v1/portal/headless/config — returns your published offerings and every rate plan they reference. Each rate plan carries a metrics[] array: one entry per billable unit, with that unit's pricing model, rate, free allowance, tiers, and outcome weights. That array is what you render from.
A rate plan can price several billable units, each on a different model. The plan-level pricingModel, rate, includedFree, and tiers repeat the first unit only — they exist for clients written before multi-unit plans. A table built from them shows one price and silently drops the rest. Loop over metrics[].
Fetch the config
Authenticate with your storefront key in the X-Storefront-Key header. No customer session is needed — this is catalog data.
curl -s https://storefront.aforo.ai/api/v1/portal/headless/config \
-H "X-Storefront-Key: $AFORO_STOREFRONT_KEY" \
-H "Accept: application/json"
import { createAforoHeadlessClient } from '@aforoai/storefront-headless'
const client = createAforoHeadlessClient({
storefrontKey: process.env.AFORO_STOREFRONT_KEY!,
})
const config = await client.getConfig()
if (config) {
for (const plan of config.ratePlans) {
for (const metric of plan.metrics) {
console.log(plan.name, metric.metricName, metric.pricingModel, metric.rate)
}
}
}
@aforoai/storefront-headless is not on the public npm registry yet (npm view returns 404 as of 2026-10-01). Until it is published, call the endpoint with fetch — the response is plain JSON, and the field tables below are the full contract for pricing.
The same call with fetch:
const res = await fetch('https://storefront.aforo.ai/api/v1/portal/headless/config', {
headers: { 'X-Storefront-Key': process.env.AFORO_STOREFRONT_KEY!, Accept: 'application/json' },
})
if (!res.ok) throw new Error(`headless config: ${res.status}`)
const config = await res.json()
How offerings and rate plans connect
offerings[] is what a buyer subscribes to. Each offering lists ratePlanIds; look each id up in the top-level ratePlans[].
Per-unit pricing fields
Each entry in ratePlans[].metrics[]:
Example response
A plan that prices three units on three models. Fields unrelated to pricing are omitted.
{
"schemaVersion": "v1",
"etag": "sf-3f9a1c0b7d2e4a61",
"offerings": [
{
"id": "off_growth",
"name": "Growth",
"billingMode": "POSTPAID",
"currency": "USD",
"entryPrice": "49",
"billingPeriod": "MONTHLY",
"trialDays": 14,
"ratePlanIds": ["rp_growth"]
}
],
"ratePlans": [
{
"id": "rp_growth",
"name": "Growth usage",
"pricingModel": "OUTCOME_BASED",
"currency": "USD",
"rate": "0.02",
"includedFree": 0,
"billingPeriod": "MONTHLY",
"tiers": [],
"metrics": [
{
"metricId": "met_agent_runs",
"metricName": "agent_runs",
"pricingModel": "OUTCOME_BASED",
"rate": "0.02",
"includedFree": 0,
"blockSize": null,
"minFee": null,
"tiers": [],
"outcomeWeights": { "SUCCESS": 1, "PARTIAL": 0.5, "ERROR": 0, "TIMEOUT": 0 }
},
{
"metricId": "met_tokens",
"metricName": "tokens",
"pricingModel": "GRADUATED",
"rate": "0",
"includedFree": 0,
"blockSize": null,
"minFee": null,
"tiers": [
{ "tierStart": 0, "tierEnd": 1000000, "unitPrice": 0.00001, "flatFee": null },
{ "tierStart": 1000000, "tierEnd": 10000000, "unitPrice": 0.000008, "flatFee": null },
{ "tierStart": 10000000, "tierEnd": null, "unitPrice": 0.000005, "flatFee": null }
],
"outcomeWeights": null
},
{
"metricId": "met_api_calls",
"metricName": "api_calls",
"pricingModel": "INCLUDED_QUOTA",
"rate": "0.005",
"includedFree": 1000,
"blockSize": null,
"minFee": null,
"tiers": [],
"outcomeWeights": null
}
]
}
]
}
The plan-level pricingModel: "OUTCOME_BASED" and rate: "0.02" describe agent_runs only. Nothing at plan level tells you about tokens or api_calls.
Word each model's price
These are the wordings Aforo's hosted storefront and the pricing card widget use. Match them and a buyer sees the same price on every surface.
Formatting rules that go with the table:
- Period suffix:
MONTHLY→/mo,QUARTERLY→/qtr,ANNUAL→/yr,ONE_TIME→one-time. - Decimals: two for per-period amounts. Keep at least four for per-unit rates, or
$0.005prints as$0.01; token-scale rates need more. - Zero: a per-period price of
0readsFree, not$0.00/mo. - Nothing priced: when a plan has no
metricsand the offering has noentryPrice, showContact sales. Don't invent a figure. - "From" across plans: only compare prices that mean the same thing. A
$0.05per-call rate is not cheaper than a$49/moplan. Compare per-period prices per month ($480/yris$40/mo); for usage-only plans, show a "From" figure only when every plan uses the same basis and unit.
A label function covering all eight models:
type Tier = { tierStart: number; tierEnd: number | null; unitPrice: number | null; flatFee: number | null }
type Metric = {
metricName: string | null
pricingModel: string | null
rate: string | null
includedFree: number
blockSize: number | null
minFee: string | null
tiers: Tier[]
outcomeWeights: Record<string, number> | null
}
const PERIOD: Record<string, string> = { MONTHLY: '/mo', QUARTERLY: '/qtr', ANNUAL: '/yr', ONE_TIME: ' one-time' }
const money = (n: number, currency: string, perUnit = false) =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: perUnit ? 6 : 2,
}).format(n)
// `unit` is your own display word for metric.metricName ("call", "token", "run").
export function priceLabel(m: Metric, plan: { currency: string; billingPeriod: string | null }, unit: string): string {
const rate = Number(m.rate ?? 0)
const per = m.blockSize && m.blockSize > 1 ? `${m.blockSize.toLocaleString('en-US')} ${unit}s` : unit
const free = m.includedFree > 0 ? ` after ${m.includedFree.toLocaleString('en-US')} free` : ''
const period = PERIOD[plan.billingPeriod ?? 'MONTHLY'] ?? '/mo'
switch (m.pricingModel) {
case 'FLAT_RATE':
return rate === 0 ? 'Free' : `${money(rate, plan.currency)}${period}`
case 'PERCENTAGE':
return `${rate}% of transaction value`
case 'GRADUATED':
case 'VOLUME_TIERED': {
const prices = m.tiers.map((t) => t.unitPrice).filter((p): p is number => p != null)
return prices.length ? `From ${money(Math.min(...prices), plan.currency, true)} / ${unit}` : 'Contact sales'
}
case 'STAIRCASE': {
const fees = m.tiers.map((t) => t.flatFee).filter((f): f is number => f != null)
return fees.length ? `From ${money(Math.min(...fees), plan.currency)}${period}` : 'Contact sales'
}
case 'PER_UNIT':
case 'INCLUDED_QUOTA':
case 'OUTCOME_BASED':
return `${money(rate, plan.currency, true)} / ${per}${free}`
default:
return 'Contact sales'
}
}
For OUTCOME_BASED, print the weights next to the label. A buyer deciding between plans needs to see that ERROR: 0 means failed runs cost nothing and PARTIAL: 0.5 means half price. Statuses missing from the map bill at full weight, so don't describe them as free.
For tiered models, render the tiers as rows. The three tier models bill differently, and the row copy should say so:
Cache it
The response carries an ETag and Cache-Control: private, max-age=60. The ETag changes when you publish the storefront and when an offering or rate plan changes, so a conditional request never returns a stale price as 304.
curl -s -o /dev/null -w "%{http_code}\n" \
https://storefront.aforo.ai/api/v1/portal/headless/config \
-H "X-Storefront-Key: $AFORO_STOREFRONT_KEY" \
-H 'If-None-Match: "sf-3f9a1c0b7d2e4a61"'
# 304 — nothing changed, keep what you have
const next = await client.getConfig({ etag: current.etag })
const config = next ?? current // getConfig returns null on 304
The response is private. If you put it behind your own CDN or a shared cache, key it on the storefront key — every workspace is served from the same URL, and a cache that ignores Vary: X-Storefront-Key would serve one workspace's prices to another.
A price edit can take up to 60 seconds to appear: the server holds the assembled config for that long between publishes. Build-time rendering (static pricing pages) needs a rebuild or revalidation to pick up a change — a 60-second revalidate window matches the server.
What the response leaves out
- Offering-level overrides are not applied. An offering can override a rate plan's quota or overage rate for that offering only.
ratePlans[].metrics[]shows the rate plan as authored, so a page built from it shows the un-overridden numbers. If you use overrides, check the offering in the API reference (Offerings) before you publish a price. - Per-tool and per-endpoint multipliers are not included. A plan that prices
web_searchat 1.5× the base rate shows only the base rate here. - A rate plan that can't be read is omitted, not errored. An id in
offerings[].ratePlanIdsmay have no match inratePlans[]. Handle the miss — render the offering'sentryPricealone or fall back toContact sales.
Troubleshooting
What this doesn't cover
- Display names for units.
metricNameis the event name (api_calls), not a label. Map it to your own singular word ("call", "token") — the response has no display unit. - Currency conversion and tax. Prices come back in the plan's
currency, before tax. - Checkout, subscriptions, and what a signed-in customer has spent. Those are separate endpoints that need a customer session — see Checkout and Subscriptions in the API reference, or drop in the embed widgets.
- How each model computes a charge. Formulas and worked examples are on Rate Plans.
- Draft previews.
?preview=truereturns unpublished config and requires operator access; a storefront key alone gets the published config.