Skip to main content

works.yml Pricing Configuration

A directory's plans, prices and checkout provider live in the data repository, not in the app: the pricing: block of .works/works.yml. This page is the complete field reference for that block.

The block is optional. A works.yml with no pricing: key keeps the template's built-in plans (apps/web/lib/types.ts, defaultPricingConfig), which read their provider IDs from NEXT_PUBLIC_* environment variables. Adding the block replaces those defaults wholesale.

A ready-to-copy example listing every field lives at docs/configuration/examples/works-pricing.example.yml.

Where it is read

StepCode
.works/works.yml is parsedgetConfig()apps/web/lib/content.ts
pricing: is validatedparseWorksPricingConfig()apps/web/lib/config/schemas/works-pricing.schema.ts
The result becomes config.pricingConfigapps/web/lib/content.ts
Defaults fill in when it is absentConfigProviderapps/web/app/[locale]/config.tsx
Plan cards are rendereduseMemo over config.pricing?.plansapps/web/hooks/use-pricing-section.ts
The provider is resolveddeterminePaymentProvider()apps/web/lib/utils/payment-provider.ts

Top-level fields

pricing:
provider: stripe
currency: USD
lemonCheckoutUrl: https://your-store.lemonsqueezy.com/checkout
plans: { ... }
FieldTypeRequiredDescription
providerenumnoDefault checkout gateway. stripe, lemonsqueezy, polar, solidgate or manual. Case-insensitive.
currencystringnoCurrency symbol or ISO code shown next to prices (USD, $, EUR).
lemonCheckoutUrlstringnoLemonSqueezy hosted-checkout URL shared by all plans; a plan-level value wins.
plansmappingyesFREE, STANDARD and PREMIUM — see below.

provider: manual

manual says "display the prices, take payment somewhere else."

It is not the same as omitting provider. An omitted provider means "nothing was declared", and resolution falls back to the Stripe default. manual means the operator declared that this site has no gateway, so the value is carried through resolution and the plan buttons never start an in-site checkout — clicking one writes this to the visitor's browser console (it is a client-side hook, so it does not appear in server logs)

[PRICING] works.yml sets pricing.provider: manual — no in-site checkout is started for plan "standard". Payment must be collected outside this site.

and nothing else happens. Which plan cards render is unchanged: that is still decided by the LIVE / DEMO logic of Spec 044.

Surfaces that act on a subscription some gateway already created — auto-renewal, the billing portal, the built-in default plans — keep their pre-existing Stripe default, because manual carries no information about a subscription that already exists.

manual is deliberately not a member of the PaymentProvider enum, which only names gateways PaymentProviderFactory can instantiate and the payment_provider column stores. It is a separate constant, MANUAL_PAYMENT_PROVIDER, and the union of the two is PricingProvider. See Payment Configuration for that enum.

Whatever the block says, a signed-in user's own choice under Settings → Checkout provider still takes precedence — that picker only lists gateways the deployment actually configured, so such a user checks out through the gateway they picked even on a manual site. Everything above describes the site's default, which is what an anonymous or never-configured visitor gets.

manual does not yet render its own call to action (a "Contact us" button, a per-plan external URL); that is recorded as Q-046a in Open questions.

Plan fields

Every plan under plans: accepts the same fields — the PricingConfig interface in apps/web/lib/content.ts, field for field.

Only id, name and price are required. description defaults to an empty string and annualDiscount to 0, so blocks written against the older, shorter example keep working.

Identity and display

FieldTypeDescription
idstringRequired. Plan identifier — use free, standard, premium to match PaymentPlan.
namestringRequired. Card title.
descriptionstringCard subtitle. Defaults to ''.
pricenumberRequired. Amount in the configured currency. Must be a YAML number: 19, not "19".
annualDiscountnumberPercentage off the annual price, 0100. Defaults to 0.
featuresstring listBullet list rendered on the card.
intervalenumdaily, weekly, monthly, yearly, one-time or per-submission.
popularbooleanRenders the "most popular" ribbon.
isPremiumbooleanMarks the plan as a paid tier.
isActivebooleanWhether the plan can be subscribed to.
isFeaturedbooleanHighlights the card.
disabledbooleanRenders the card but blocks checkout.
envKeystringKey used to correlate the plan with *_PLAN environment variables.

Trial

FieldTypeDescription
trialPeriodDaysnumberFree-trial length in days.
trialAmountIdstringProvider price ID charged as a trial authorisation.
trialAmountnumberAmount of that authorisation.
isAuthorizedTrialAmountbooleanWhether the trial authorisation is collected.

Provider IDs

FieldTypeProvider
stripeProductIdstringStripe product.
stripePriceIdstringStripe monthly price.
annualPriceIdstringStripe annual price.
lemonProductIdstringLemonSqueezy product.
lemonVariantIdstringLemonSqueezy variant.
lemonCheckoutUrlstringLemonSqueezy hosted checkout for this plan.
polarFreePlanIdstringPolar plan ID for the free tier.
polarStandardPlanIdstringPolar plan ID for the standard tier.
polarPremiumPlanIdstringPolar plan ID for the premium tier.
polarProductIdstringPolar product backing this plan.

Keys the running template does not recognise are preserved rather than rejected, so a works.yml written for a newer template still loads.

Plan keys

The three keys are FREE, STANDARD and PREMIUM, matching PaymentPlan.

PRO is accepted as an alias for STANDARD — configs written against the original ticket wording load unchanged, with a warning naming the alias. If both are present, STANDARD wins and PRO is ignored. Prefer STANDARD.

Minimal example

pricing:
provider: stripe
currency: USD
plans:
FREE:
id: free
name: Free Plan
description: Access basic features and submit content for free.
price: 0
interval: per-submission
features:
- List your product
STANDARD:
id: standard
name: Standard Plan
description: Get more visibility.
price: 19
interval: monthly
annualDiscount: 10
popular: true
stripePriceId: price_standard_monthly_xxx
PREMIUM:
id: premium
name: Premium Plan
description: Maximum exposure.
price: 49
interval: monthly
annualDiscount: 20
isPremium: true
stripePriceId: price_premium_monthly_xxx

Validation and errors

The block is validated on read. It is never fatal: bad pricing metadata must not take a directory offline.

  • Valid — used as-is.
  • Invalid — a [CONTENT] Invalid "pricing" section … line is logged, followed by one pricing.<path>: <message> line per problem, and the block is dropped so the built-in plans render.
  • Accepted with a note — a [CONTENT] warning for the PRO alias or for provider: manual. These describe what the accepted block does, so they are emitted only when the block validates; a rejected block reports errors only.

A typical failure:

[CONTENT] Invalid "pricing" section in .works/works.yml; falling back to the built-in pricing plans:
- pricing.plans.STANDARD.price: Invalid input: expected number, received string
- pricing.plans.PREMIUM.interval: Invalid option: expected one of "daily"|"weekly"|"monthly"|"yearly"|"one-time"|"per-submission"

Quoting a price (price: "19") is the most common mistake — YAML keeps the quotes, and a string price fails validation.

Validate a block before committing it:

pnpm --filter @ever-works/web test:unit

The suite parses docs/configuration/examples/works-pricing.example.yml, so the published example can never drift from the schema.