subscriptions
npx skills add https://github.com/crystallizeapi/ai --skill subscriptionsCrystallize 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 themCore createProduct / updateProduct variant prices per plan period and price variantCore igniteDiscoApi so the storefront can read the plan cards from DiscoveryCore createCustomer Core contracts need a customer; the Shop endpoint does notCore createSubscriptionContract the agreement: item, phases, dates, metaCore 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 endingCore registerOrder the invoice: plan line + overage line, priced by youCore renewSubscriptionContract moves renewAt one period onStates
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:
activateAtis exclusive ofrenewAtandactiveUntil(ActivateAtMustBeExclusiveError; the Shop endpoint only says “Unexpected error”). Send either a future start, or a running contract’s dates.- Send
renewAtandactiveUntiltogether. A contract created with onlyrenewAtis borncancelled.
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.periodNameis 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 withcreateCustomerfirst. paymenthere 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-datesignedAt(accepted) and the orders (registerOrder { createdAt }), and keep your own history inmeta.
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.
activateAtcannot be combined with the other two (ActivateAtMustBeExclusiveError). A contract that starts later is created withactivateAtalone, and becomescancelledwhen that moment passes unless you have activated it first withupdateSubscriptionContract { status: { renewAt, activeUntil } }, which replaces the whole status and clearsactivateAt.renewAtandactiveUntiltravel 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
initialphase stays on it until the first renewal, so “is this a trial?” isstatus.phaseequal to the initial phase. Leaveinitialout and it starts onrecurringat 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
pausedpastactiveUntil, andresumethen yieldscancelled. Record what was left at the pause (meta.pauseRemainingMs) and on resume setrenewAt/activeUntilto now plus that. Note thatupdateSubscriptionContract { status }also un-pauses, so a resume is:resume(toleratingSubscriptionContractIsNotPausedError), then the date update. - Undo a cancel with
updateSubscriptionContract { status: { renewAt: activeUntil, activeUntil } }, notrenewSubscriptionContract— 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.
UpdateSubscriptionContractInputhas nosubscriptionPlan, so monthly ↔ yearly is cancel plus a new contract. A tier change isupdate { 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 withdeleteOrder.
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:
createneeds no Core customer: it creates one fromcustomer { identifier, email, firstName, lastName }. Ids areUUID!. Prices are{ gross, net }per phase instead of Core’s singleprice, and the currency comes fromcontext.price.currency(EUR if omitted).planis{ identifier, periodId }— noperiodName.- The lifecycle behaves as above (
pause,resume,renew,cancel,deactivate,changeDates), with three differences:resumeafter a cancel is silently ignored,renewon a paused contract makes itactive, and the only way to change what is billed isupdateRecurringPhase(phase: { price, period, unit, productVariants, meteredVariables })— there is no item update.status.phaseis alwaysnull. subscriptionContracts(customerIdentifier:)right aftercreatecan answer[]; the contract shows up seconds later. Don’t list to confirm a write.subscriptionContractTemplatewas unusable on the tenant it was tried on: zero prices, noinitial, andcurrency: "eur"regardless — most likely because it reads the empty Cataloguevariants. 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.subscriptionPlansback on Core answers “Not implemented” — one error per variant, even forsubscriptionPlans { identifier }. The write itself succeeds, so acreateProductthat selects the plans throws after creating the product, and a naive retry duplicates it. Select onlyidon the write, and read the plans back from PIM (product { get(id, language) { variants { subscriptionPlans { … } } } }). - A tier’s own
priceis ignored. It is required by the input, but the stored tier price is the one inpriceVariants.price: 99withdefault: 0.1stores 0.1, and an emptypriceVariantsstoresnull. - Price variants are optional per period. Leave
nokout and onlydefaultis stored; Discovery then answersnokPrice: null. A period omitted from the variant is stored asinitial: null, recurring: nulland Discovery drops it. - Meters are addressed two ways. On a variant’s tiers, by metered-variable
id. On contracts and when tracking usage, byidentifier. Mixing them up givesMeteredVariableNotFoundError.
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 } } } } } } } } }}periodcomes back as a string ("1"),priceVariantsas a hash ({ default: { price, currency }, nok: { … } }), andmeteredVariablesas[]when there are none.- Do not trust
initial. It repeatsrecurring: on a tenant whose Monthly period is “14 days free, then 1 month at 7.99”, Discovery answeredinitial { period: "1", unit: "month", defaultPrice: 7.99 }, and a re-check on 2026-09-28 again returnedinitialbyte-identical torecurring. Take the introductory period from the plan (PIM, server-side) or from your own config, and price the trial yourself. hasSubscriptionPlansis the cheap filter for “is this a plan variant”.- On one tenant the Catalogue API’s
variantscame back empty for every product whiledefaultVariantworked and carried the correct plans, including the trial. If a plan card looks empty, readdefaultVariant, 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 answersMeteredVariableNotFoundError. - The
idempotencyKeyis the retry guard. A reused key answersIdempotencyKeyExistsErrorand 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
pausedandcancelledcontracts too. If that should not be allowed, checkstatus.statein 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 endingprice the tiers yourself graduated: (usage − threshold) × the tier's price, per tierregisterOrder the invoice: plan line + an overage line so the total adds uprenewSubscriptionContract moves renewAt one period onupdate meta.periodStart (and tier) the new period's start, and nextTier → tier if it changedregisterOrder 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.startandendareDate, notDateTime, and here the metered variable is referenced byid— the opposite of tracking.- Put the overage on its own line (
type: service) so the total adds up. The meters insidesubscription.meteredVariablesare 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’smetaand look for it withorders(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