Sign in →

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.

Updated 2026-10-01Suggest edits

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[].

FieldOnMeaning
offerings[].entryPriceOfferingFixed subscription price per billing period, as a string. null when the offering is usage-only.
offerings[].billingPeriodOfferingPeriod for entryPrice: MONTHLY, QUARTERLY, ANNUAL, ONE_TIME.
offerings[].trialDaysOfferingTrial length in days. 0 means no trial.
offerings[].ratePlanIdsOfferingIds of the rate plans the offering includes.
ratePlans[].currencyRate planISO 4217 code. Applies to every unit on the plan.
ratePlans[].billingPeriodRate planPeriod that per-period prices (FLAT_RATE, STAIRCASE) and free allowances reset on.
ratePlans[].metricsRate planEvery billable unit on the plan. Render from this.

Per-unit pricing fields

Each entry in ratePlans[].metrics[]:

FieldTypeMeaning
metricIdstringBillable unit id.
metricNamestringBillable unit name as your usage events send it, for example api_calls. Not a display label — see What this doesn't cover.
pricingModelstringOne of PER_UNIT, FLAT_RATE, PERCENTAGE, INCLUDED_QUOTA, GRADUATED, VOLUME_TIERED, STAIRCASE, OUTCOME_BASED.
ratestringDecimal as a string, so 0.0005 survives JSON. Price per unit, flat fee per period (FLAT_RATE), or a percent (PERCENTAGE — "2.9" means 2.9%). "0" for STAIRCASE; its prices are on the tiers.
includedFreenumberUnits free each period. 0 when there is no allowance — never null.
blockSizenumber or nullOverage is billed in whole blocks of this many units. null means per unit.
minFeestring or nullMinimum charge per period for this unit.
tiersarraytierStart, tierEnd (null = open-ended), unitPrice, flatFee. Sorted by tierStart. Empty for models without tiers.
outcomeWeightsobject or nullOUTCOME_BASED only. Execution status → weight between 0 and 1. A status that isn't listed bills at 1.

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.

ModelReadWordingExample
Pay Per Use (PER_UNIT in the API)raterate / unit$0.02 / call
Pay Per Use with a free allowancerate, includedFreerate / unit after N free$0.50 / call after 1,000 free
Flat Rate (FLAT_RATE)rate, plan billingPeriodamount + period suffix$49.00/mo
Percentage (PERCENTAGE)ratepercent of transaction value — never format it as money2.9% of transaction value
Included Quota (INCLUDED_QUOTA)includedFree, rate, blockSizeoverage rate after N free; per block when blockSize is set$0.005 / call after 1,000 free
Graduated (GRADUATED)lowest tiers[].unitPrice"From" + lowest unit price, then the tier tableFrom $0.000005 / token
Volume Tiered (VOLUME_TIERED)lowest tiers[].unitPrice"From" + lowest unit price, then the tier tableFrom $0.005 / call
Staircase (STAIRCASE)lowest tiers[].flatFee, plan billingPeriod"From" + cheapest band + periodFrom $100.00/mo
Outcome-Based (OUTCOME_BASED)rate, outcomeWeightsrate / unit, plus which outcomes are discounted or free$0.02 / run — failed runs free

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.005 prints as $0.01; token-scale rates need more.
  • Zero: a per-period price of 0 reads Free, not $0.00/mo.
  • Nothing priced: when a plan has no metrics and the offering has no entryPrice, show Contact sales. Don't invent a figure.
  • "From" across plans: only compare prices that mean the same thing. A $0.05 per-call rate is not cheaper than a $49/mo plan. Compare per-period prices per month ($480/yr is $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:

ModelWhat a tier row means
GRADUATEDUnits inside this band bill at unitPrice. Earlier bands keep their own price.
VOLUME_TIEREDOnce the total lands in this band, all units bill at unitPrice.
STAIRCASEThe total lands in this band and the charge is flatFee for the period. No per-unit component.

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_search at 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[].ratePlanIds may have no match in ratePlans[]. Handle the miss — render the offering's entryPrice alone or fall back to Contact sales.

Troubleshooting

SymptomCauseFix
The table shows one price for a plan that bills several unitsRendering from plan-level pricingModel / rate / tiers, which repeat the first unit onlyLoop over ratePlans[].metrics[]
$0.01 where the plan says $0.005Per-unit rate formatted with two decimalsAllow at least four fraction digits for per-unit rates; parse rate from the string, don't round first
$2.90 on a revenue-share planPERCENTAGE rate formatted as moneyrate is a percent for this model — print 2.9% of transaction value
$0.00 on a Staircase planReading rate, which is "0" for STAIRCASERead tiers[].flatFee
ratePlans is empty, or an offering's plan is missingPricing data was unavailable when the config was assembled, or the plan could not be readRetry after the 60-second cache window; keep the last good config instead of rendering an empty table
New price doesn't appearServer holds the config for up to 60 seconds; your own cache or static build holds it longerWait out the window, then revalidate or rebuild
304 handling crashes with "cannot read properties of null"getConfig({ etag }) returns null on 304Keep the previous config: next ?? current
Your page shows a different allowance than the invoiceThe offering overrides the rate plan's quota or overage rateRead the override from the offering; the headless rate plan doesn't carry it

What this doesn't cover

  • Display names for units. metricName is 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=true returns unpublished config and requires operator access; a storefront key alone gets the published config.