Skip to content

subscriptions

Terminal window
npx skills add https://github.com/crystallizeapi/ai --skill subscriptions

Crystallize Subscriptions

A subscription plan describes what can be sold repeatedly — its periods (monthly, yearly), an optional introductory period, and any metered variables. A variant carries the plan’s prices. A subscription contract is one customer’s agreement: the plan and period they chose, the prices they agreed, the dates it runs on, and the meter readings.

The contract is the whole model. It is not a state machine that bills for you.

Verified on 2026-09-28 against the live Core API (screen-universe) and the public Discovery API, and against two builds that ran the whole lifecycle: Screen Universe (memberships with a 14-day trial and metered downloads) and Lab Universe (B2B standing orders). Claims that come from those builds rather than from the schema are marked.

Two things to know before you write any code

1. Nothing happens by itself. There is no billing engine behind a contract. At renewAt nothing is charged, no order appears, and no state changes. A contract whose activeUntil has passed simply reads cancelled. Renewal is your job: a scheduled task that finds contracts due, prices the period, writes an order, and calls renewSubscriptionContract. Plan for that before you model anything.

2. Contracts live in two stores that barely sync. Core and the Shop API /subscription-contract endpoint both hold contracts, and they are not one store with two doors:

Core API Shop API /subscription-contract
Meant for back office, seeding, batch, renewals a storefront acting for the signed-in customer
Ids 24-hex ObjectId UUID!
Usage tracking trackSubscriptionContractUsage, usage(startDate, endDate) not available — Core only
Needs a customer first yes (CustomerNotFoundError) no, it creates one from customer { … }
Status labelled stable labelled EXPERIMENTAL

A Shop contract is copied to Core about 7 seconds later with a new Core id and the Shop UUID in meta._shopApiId. After that copy, nothing flows from the Shop to Core: pause, resume, renew, cancel and phase changes on the Shop side leave the Core copy at its creation state. In the other direction a Core contract is copied into the Shop store with its Core ObjectId as id — which is not a UUID, so subscriptionContracts(customerIdentifier:) then fails on that element and the contract cannot be addressed through the Shop API at all.

So pick one store per tenant and stay in it. Both builds landed on that rule the hard way:

  • Core for everything when the lifecycle is driven server-side — seeding, renewal jobs, usage, back-dated history, and account pages rendered by your own server. This is what Screen Universe does. Core is rate limited and is not meant for storefront traffic, so put it behind your own server actions or API routes — never in a browser call, and never once per page view.
  • Shop API for everything when the storefront itself creates and changes contracts for the customer in front of it, as Lab Universe does for standing orders. Then never create contracts for those customers in Core.

The pipeline

PIM subscriptionPlan.create periods and metered variables — the ids are generated, keep them
Core createProduct / updateProduct variant prices per plan period and price variant
Core igniteDiscoApi so the storefront can read the plan cards from Discovery
Core createCustomer Core contracts need a customer; the Shop endpoint does not
Core createSubscriptionContract the agreement: item, phases, dates, meta
Core registerOrder the receipt for the first period (0 for a trial)
Core trackSubscriptionContractUsage every metered event, with an idempotency key
… then, on a schedule, per contract due ………………………………………………………
Core usage(startDate, endDate) the meter for the period that is ending
Core registerOrder the invoice: plan line + overage line, priced by you
Core renewSubscriptionContract moves renewAt one period on

States

SubscriptionContractState is active, paused, pendingActivation, pendingDeactivation or cancelled, and it is derived from the dates, not set directly:

Dates State
activateAt in the future pendingActivation
now before activeUntil, renewAt set active
activeUntil in the future, no renewAt pendingDeactivation
no activeUntil, or it has passed cancelled
paused explicitly paused

Two rules follow, and both bite:

  • activateAt is exclusive of renewAt and activeUntil (ActivateAtMustBeExclusiveError; the Shop endpoint only says “Unexpected error”). Send either a future start, or a running contract’s dates.
  • Send renewAt and activeUntil together. A contract created with only renewAt is born cancelled.

Failure modes

Symptom Cause Fix
Nothing is charged and no order appears at renewAt There is no billing engine Run your own renewal job — see usage-and-renewals
A brand-new contract reads cancelled activeUntil missing, or only renewAt was sent Send both; for a trial set both to the trial’s end
subscriptionContracts errors on one element, the rest is data A Core-created contract was copied into the Shop store with a Core id Use one store; read with partial results in the meantime
A Shop pause/renew never reaches Core The Shop → Core copy happens once, at creation Use one store
Usage tracked but the invoice has no overage Nothing prices usage for you Read usage(…), price the tiers yourself, add an overage line
A trial shows the full price on the plan card Discovery’s initial repeats recurring Read the trial from the plan (PIM, server-side) or your own config
MeteredVariableNotFoundError when tracking The metered variable’s id was sent Track by identifier
Variant prices or contracts stop resolving after a plan edit subscriptionPlan.update without ids mints new period ids Always pass the existing ids back
A paused contract goes cancelled on resume Pause freezes nothing; the dates kept running On resume, push renewAt/activeUntil by the time that was paused
InvalidActiveUntilDateError while seeding history Core refuses dates in the past Back-date signedAt and the orders instead

References

  • references/plans-and-pricing.md — plans and periods in the PIM API, variant subscription prices, metered variables, and what Discovery and Catalogue serve.
  • references/contracts.md — creating contracts, the date rules, the whole lifecycle with what each call actually changes, and the Shop endpoint’s differences.
  • references/usage-and-renewals.md — metered usage, period windows, renewal orders, orderIntent, and what to do about the missing billing engine.

Related: [[pricing]] for price variants and markets, [[mutation]] and [[query]] for the APIs themselves, [[bookable-resources]] for the other “sold over time” model — booking holds a calendar, a subscription repeats a charge.


Reference Details

Contracts: creating them, and what each lifecycle call really does

A contract holds the customer, the plan and period they chose, the phases (an optional initial, a required recurring), the dates, and your meta. Everything below is the Core API unless it says otherwise; the Shop endpoint’s differences are at the end.

Create

mutation Create($input: CreateSubscriptionContractInput!) {
createSubscriptionContract(input: $input) {
__typename
... on SubscriptionContractAggregate {
id
status {
state
renewAt
activeUntil
}
}
... on BasicError {
errorName
message
}
}
}
{
"input": {
"customerIdentifier": "ada@example.com",
"subscriptionPlan": { "identifier": "membership", "periodId": "<period id>", "periodName": "Monthly" },
"item": { "sku": "plan-premium", "name": "Membership Premium", "quantity": 1 },
"initial": { "currency": "EUR", "price": 0, "period": 14, "unit": "day" },
"recurring": {
"currency": "EUR",
"price": 19.99,
"period": 1,
"unit": "month",
"meteredVariables": [
{
"identifier": "downloads",
"tierType": "graduated",
"tiers": [
{ "threshold": 0, "price": 0, "currency": "EUR" },
{ "threshold": 10, "price": 0.61, "currency": "EUR" }
]
}
]
},
"status": { "renewAt": "2026-10-12T00:00:00Z", "activeUntil": "2026-10-12T00:00:00Z" },
"payment": { "provider": "custom", "custom": { "properties": [{ "property": "method", "value": "card" }] } },
"meta": [
{ "key": "tier", "value": "premium" },
{ "key": "periodStart", "value": "2026-09-28T00:00:00Z" }
]
}
}
  • subscriptionPlan.periodName is required on Core, so carry the period’s name alongside its id.
  • A Core contract needs an existing customer (CustomerNotFoundError) and an existing SKU (SkuNotFoundError). Upsert the customer with createCustomer first.
  • payment here records how it will be paid; it does not take money.
  • Core refuses dates in the past (InvalidActiveUntilDateError). To seed a subscriber “since 2025”, back-date signedAt (accepted) and the orders (registerOrder { createdAt }), and keep your own history in meta.

The date rules, which decide the state

SubscriptionContractStatusInput is only activateAt, renewAt and activeUntil — there is no state field. See the table in SKILL.md.

  • activateAt cannot be combined with the other two (ActivateAtMustBeExclusiveError). A contract that starts later is created with activateAt alone, and becomes cancelled when that moment passes unless you have activated it first with updateSubscriptionContract { status: { renewAt, activeUntil } }, which replaces the whole status and clears activateAt.
  • renewAt and activeUntil travel together. For a trial, both are the trial’s end. For a running period, both are the end of the paid period.
  • A contract with an initial phase stays on it until the first renewal, so “is this a trial?” is status.phase equal to the initial phase. Leave initial out and it starts on recurring at once — which is what you want for an add-on, or for seeding an existing subscriber.

One contract, several products

item is the single headline product, but recurring.productVariants takes a list of { sku, name, quantity, imageUrl, meta }, and the phase’s price is what the whole basket costs per period. A standing order of six consumables is therefore one contract with six lines, not six contracts — pause, resume and renewal then act on all of them at once. (Lab Universe.)

The lifecycle

Recorded step by step on a trial contract (renewAt = activeUntil = T+14d):

Call State after What moved
createSubscriptionContract active phase = the 14-day trial
pauseSubscriptionContract paused nothing else — the dates keep their values
pause again error SubscriptionContractIsAlreadyPausedError
resumeSubscriptionContract active dates unchanged
updateSubscriptionContract { item, recurring } active item and recurring phase now; status.phase only at the next renew
renewSubscriptionContract active renewAt = old renewAt + one period; activeUntil follows
cancelSubscriptionContract { deactivate: false } pendingDeactivation renewAt → null, activeUntil kept (runs to the paid end)
renewSubscriptionContract after that active back from the dead, renewAt = activeUntil + one period
cancelSubscriptionContract { deactivate: true } cancelled renewAt and activeUntil → null

Consequences worth designing around:

  • Renew adds a period to the previous renewAt, not to now. Renewing a long-expired contract revives it on its old dates. If you renew late, decide whether the customer gets the lost time.
  • Pause freezes nothing. A contract paused over the summer keeps running down, stays paused past activeUntil, and resume then yields cancelled. Record what was left at the pause (meta.pauseRemainingMs) and on resume set renewAt/activeUntil to now plus that. Note that updateSubscriptionContract { status } also un-pauses, so a resume is: resume (tolerating SubscriptionContractIsNotPausedError), then the date update.
  • Undo a cancel with updateSubscriptionContract { status: { renewAt: activeUntil, activeUntil } }, not renewSubscriptionContract — renew reactivates too, but silently moves the customer a whole period on without an invoice.
  • updateSubscriptionContract { meta } replaces the whole list. Keys you leave out are deleted; always send all of it.
  • Plan and period cannot change. UpdateSubscriptionContractInput has no subscriptionPlan, so monthly ↔ yearly is cancel plus a new contract. A tier change is update { item, recurring } and applies from the next renewal — there is no proration. Keep the tier in force and the coming one apart (meta.tier, meta.nextTier) and swap them when you renew.
  • deleteCustomer(identifier, deleteSubscriptionContracts: true) removes a customer and their contracts in one call; orders go separately with deleteOrder.

Reading contracts

subscriptionContracts(filter: { customerIdentifier, state, sku, subscriptionPlanIdentifier }) is a connection (edges { node { … } }), sortable by createdAt. One contract is subscriptionContract(id:). Useful fields: status { state renewAt activeUntil phase }, item, initial, recurring { price currency period unit meteredVariables { tiers } productVariants }, subscriptionPlan { identifier periodId periodName }, meta, and usage(startDate:, endDate:).

status.phase is the phase in force — the trial before the first renewal, the recurring phase after it.

The Shop API endpoint

/subscription-contract (scopes subscription-contract, subscription-contract:admin) is the storefront-facing store, and it is labelled EXPERIMENTAL. What differs:

  • create needs no Core customer: it creates one from customer { identifier, email, firstName, lastName }. Ids are UUID!. Prices are { gross, net } per phase instead of Core’s single price, and the currency comes from context.price.currency (EUR if omitted). plan is { identifier, periodId } — no periodName.
  • The lifecycle behaves as above (pause, resume, renew, cancel, deactivate, changeDates), with three differences: resume after a cancel is silently ignored, renew on a paused contract makes it active, and the only way to change what is billed is updateRecurringPhase(phase: { price, period, unit, productVariants, meteredVariables }) — there is no item update. status.phase is always null.
  • subscriptionContracts(customerIdentifier:) right after create can answer []; the contract shows up seconds later. Don’t list to confirm a write.
  • subscriptionContractTemplate was unusable on the tenant it was tried on: zero prices, no initial, and currency: "eur" regardless — most likely because it reads the empty Catalogue variants. Build the create input yourself from Discovery plus the plan.
  • There is no delete. Cancelled test contracts stay in the list.
  • Usage is Core-only, and a Core id is required for it, so a Shop-store tenant has no metered billing path today. Plan flat periods there, or keep the whole thing in Core.

Plans, periods and subscription pricing

A plan is the template. It says which periods can be bought (Monthly, Yearly), whether there is an introductory period, and which meters exist. Prices are not on the plan — they are on the variant, per plan period and per price variant.

Plans live in the legacy PIM API

There is no plan surface on the Core API. Create and edit plans on https://pim.crystallize.com/graphql, which takes the tenant id, not the identifier:

mutation CreatePlan($input: CreateSubscriptionPlanInput!) {
subscriptionPlan {
create(input: $input) {
identifier
periods {
id
name
initial {
period
unit
}
recurring {
period
unit
}
}
meteredVariables {
id
identifier
}
}
}
}
{
"input": {
"tenantId": "<tenant id>",
"identifier": "membership",
"name": "Membership",
"periods": [
{
"name": "Monthly",
"initial": { "period": 14, "unit": "day" },
"recurring": { "period": 1, "unit": "month" }
},
{
"name": "Yearly",
"initial": { "period": 14, "unit": "day" },
"recurring": { "period": 1, "unit": "year" }
}
],
"meteredVariables": [{ "identifier": "downloads", "name": "Offline downloads", "unit": "download" }]
}
}

The ids are generated, and everything downstream points at them. Period ids and metered-variable ids come back from create as 24-hex ObjectIds. Variant prices reference a period id; contracts reference a period id and a metered-variable identifier. Store them in your seed or config.

update mints new ids for anything you send without one. update(identifier, tenantId, input: { periods: [{ id, … }], meteredVariables: [{ id, … }] }) keeps them; the same lists without ids replace them, and every variant price and contract pointing at the old ids is orphaned. A name-only update (input: { name }) leaves periods and meters alone. delete(identifier, tenantId) succeeds even while variants still reference the plan.

Prices go on the variant (write on Core, read on PIM)

createProduct, updateProduct and updateProductVariant take subscriptionPlans per variant:

{
"identifier": "membership",
"periods": [
{
"id": "<period id>",
"initial": { "priceVariants": [{ "identifier": "default", "price": 0 }] },
"recurring": {
"priceVariants": [
{ "identifier": "default", "price": 7.99 },
{ "identifier": "nok", "price": 89 }
],
"meteredVariables": [
{
"id": "<metered variable id>",
"tierType": "graduated",
"tiers": [
{ "threshold": 0, "price": 0, "priceVariants": [{ "identifier": "default", "price": 0 }] },
{
"threshold": 10,
"price": 0,
"priceVariants": [{ "identifier": "default", "price": 0.5 }]
}
]
}
]
}
}
]
}

Four things worth knowing, all from the Screen Universe spike:

  • Reading ProductVariant.subscriptionPlans back on Core answers “Not implemented” — one error per variant, even for subscriptionPlans { identifier }. The write itself succeeds, so a createProduct that selects the plans throws after creating the product, and a naive retry duplicates it. Select only id on the write, and read the plans back from PIM (product { get(id, language) { variants { subscriptionPlans { … } } } }).
  • A tier’s own price is ignored. It is required by the input, but the stored tier price is the one in priceVariants. price: 99 with default: 0.1 stores 0.1, and an empty priceVariants stores null.
  • Price variants are optional per period. Leave nok out and only default is stored; Discovery then answers nokPrice: null. A period omitted from the variant is stored as initial: null, recurring: null and Discovery drops it.
  • Meters are addressed two ways. On a variant’s tiers, by metered-variable id. On contracts and when tracking usage, by identifier. Mixing them up gives MeteredVariableNotFoundError.

What the storefront can read

Discovery serves the plan cards, and this is the query that works (verified on a live tenant):

{
browse {
plan(language: en) {
hits {
variants {
sku
hasSubscriptionPlans
subscriptionPlans {
identifier
name
periods {
id
name
recurring {
period
unit
defaultPrice
priceVariants
meteredVariables {
identifier
tierType
tiers {
threshold
defaultPrice
}
}
}
}
}
}
}
}
}
}
  • period comes back as a string ("1"), priceVariants as a hash ({ default: { price, currency }, nok: { … } }), and meteredVariables as [] when there are none.
  • Do not trust initial. It repeats recurring: on a tenant whose Monthly period is “14 days free, then 1 month at 7.99”, Discovery answered initial { period: "1", unit: "month", defaultPrice: 7.99 }, and a re-check on 2026-09-28 again returned initial byte-identical to recurring. Take the introductory period from the plan (PIM, server-side) or from your own config, and price the trial yourself.
  • hasSubscriptionPlans is the cheap filter for “is this a plan variant”.
  • On one tenant the Catalogue API’s variants came back empty for every product while defaultVariant worked and carried the correct plans, including the trial. If a plan card looks empty, read defaultVariant, or use Discovery.

Remember that a plan variant is an ordinary variant: markets, price variants, VAT and volume tiers behave as they do everywhere else — see [[pricing]].

Metered usage and renewals

Two jobs Crystallize does not do for you: counting what a customer used, and charging them for the next period. The API gives you a meter and an order writer; the schedule and the arithmetic are yours.

Tracking usage

mutation Track($id: ID!, $input: TrackSubscriptionContractUsageInput!) {
trackSubscriptionContractUsage(subscriptionContractId: $id, input: $input) {
__typename
... on SubscriptionContractUsage {
id
createdAt
}
... on BasicError {
errorName
message
}
}
}
{
"meteredVariableIdentifier": "downloads",
"quantity": 1,
"idempotencyKey": "<contract>:<title>:<profile>:2026-09-28",
"description": "Offline download"
}
  • By identifier, never by id. The metered variable’s id answers MeteredVariableNotFoundError.
  • The idempotencyKey is the retry guard. A reused key answers IdempotencyKeyExistsError and records nothing, which makes it safe to call from a route that may run twice. Build the key from what makes the event unique — contract, thing, day.
  • Fractional quantities are accepted (0.5).
  • Usage is Core-only and needs the Core contract id. A Shop UUID gives InvalidIdError, and usage tracked on the Core copy of a Shop contract is invisible to the Shop API.
  • Usage can be tracked against paused and cancelled contracts too. If that should not be allowed, check status.state in your own route first.

Reading the meter

usage(startDate:, endDate:) on the contract returns the sum per metered variable in that window ([{ meteredVariableIdentifier: "downloads", quantity: 12.5 }]), and [] when there is nothing. It is a window over the tracking time — there is no notion of “the current period”, and usage cannot be back-dated.

So keep the period’s start yourself, in meta.periodStart: set it at sign-up and again at every renewal, and read the meter as usage(periodStart, now). If you renew early — a demo button, a manual run — the next period starts at the moment of renewal, otherwise events between the real renewAt and your early run fall outside every window.

Renewals

Nothing renews itself. Run a scheduled task (a cron, a queue worker, or a button in a demo) that takes every contract with state: active and renewAt <= now and does this:

usage(periodStart, renewAt) the meter for the period that is ending
price the tiers yourself graduated: (usage − threshold) × the tier's price, per tier
registerOrder the invoice: plan line + an overage line so the total adds up
renewSubscriptionContract moves renewAt one period on
update meta.periodStart (and tier) the new period's start, and nextTier → tier if it changed

registerOrder writes the invoice. The shape that worked:

{
"customer": { "identifier": "ada@example.com" },
"cart": [
{
"name": "Membership Premium",
"sku": "plan-premium",
"quantity": 1,
"type": "subscription",
"subscriptionContractId": "<core contract id>",
"price": { "currency": "NOK", "gross": 211.25, "net": 169 },
"subscription": {
"name": "Monthly",
"period": 1,
"unit": "month",
"start": "2026-10-10",
"end": "2026-11-10",
"meteredVariables": [{ "id": "<metered variable id>", "usage": 13.5, "price": 21.88 }]
}
}
],
"total": { "currency": "NOK", "gross": 238.6, "net": 190.88 },
"type": "recurring"
}
  • subscription.start and end are Date, not DateTime, and here the metered variable is referenced by id — the opposite of tracking.
  • Put the overage on its own line (type: service) so the total adds up. The meters inside subscription.meteredVariables are a record, not a charge: nothing in Crystallize prices them.
  • pipelines: [{ pipelineId, stageId }] drops the order straight into a stage (Trial, Active). The ids come from the PIM API, so resolve them in your seed rather than at runtime.
  • Make it idempotent: put renewalOf = <contract>:<renewAt> in the order’s meta and look for it with orders(filter: { customer: { identifier }, meta: [{ key, value }] }) before writing.

What orderIntent gives you

orderIntent(id, format: shop | core | coreWithOrderV2 | legacy) on the Shop API returns the next period’s order — type: recurring, the recurring price, and subscription { start: renewAt, end: renewAt + period, meteredVariables: [{ identifier, price, usage }] }. It is a preview, not a charge, and on the tenant it was tried on it reported usage: 0 even with usage tracked in Core, and answered the same shape during a trial. Treat it as a template to render, not as a source of truth for money.

createFromSubscriptionContract on the Shop /order endpoint failed on every attempt in the Screen Universe spike — trial, renewed, cancelled and flat contracts, before and after re-igniting — always “Unexpected error”. A Shop /order create with a type: subscription line does work, but the metered price is not added to the order total. For renewal invoices, Core registerOrder is the path that held up.

Webhooks

createWebhook accepts any concern/event string — nope/nope is accepted — so the API cannot tell you which contract events exist. Do not design a renewal pipeline around webhook events you have not seen arrive at a receiver.


Crystallize AI