Repository navigation
Adapter Stripe
The stripe adapter keeps your Stripe prices and products honest against the facts they mirror. It audits amounts, currencies, product names and whether a price is still active. With writes enabled it can also create replacement prices (Stripe prices are immutable), archive the old ones, rename products, rewrite the price id in your YAML, and roll all of it back. This page covers credentials, bindings, audit, apply, the YAML rewrite, dry runs, revert, list() for orphans, and how to turn writes on without burning the ship down.
Status: tested against mocked HTTP only. The adapter's test suite (
adapters/stripe.test.ts) runs every call against a fakefetch. It has not yet been exercised against a live Stripe account, test mode included. Start with a test-mode key and--dry-run, and read the journal before trusting it with live prices.
Source: adapters/stripe.ts.
The adapter reads a secret key from an environment variable. The default is STRIPE_SECRET_KEY. Point it elsewhere with secretEnv:
# .starchart/config.yaml
adapters:
stripe:
secretEnv: STRIPE_TEST_KEY # optional; default STRIPE_SECRET_KEYThe key is sent as Authorization: Bearer <key> to https://api.stripe.com/v1. It is never written to journals or output. No Stripe-Version header is sent, so requests use your account's default API version.
With no key, the adapter throws MissingCredentialsError. audit then skips the artifact instead of failing:
skip stripe:price/pro-monthly: Stripe secret key not found: set the STRIPE_SECRET_KEY environment variable
A restricted key works if it can read prices and products (audit, list) and, for apply, write prices and products.
- id: stripe:price/pro-monthly
label: Stripe price — Pro monthly (USD)
binding: { adapter: stripe, price: price_1NebulaPro499 }
mirrors: [addon:pro.price.usd, addon:pro.name]| Field | Meaning |
|---|---|
price |
A Stripe price id (price_…). Audited for existence, activity and amounts. |
product |
A Stripe product id (prod_…). Optional when price is set, because the product is taken from the price. |
At least one of price or product is required (stripe binding needs "price" or "product").
The adapter looks at leaf facts reached through mirrors edges and matches them by the last segment of the fact id:
| Fact id ends in | Value type | Compared with |
|---|---|---|
a currency code (.usd, .eur, .gbp, .jpy, … 45 codes) |
number | that currency's amount: unit_amount if it's the price's currency, else currency_options.<cur>.unit_amount
|
.price or .amount
|
number | the amount in the price's own currency |
.name |
string | the product's name
|
Mirroring a container like addon:pro.price ({usd, eur}) expands to its currency leaves. Facts with other names are ignored by this adapter.
Amounts are converted to minor units: 4.99 USD is 499. Zero-decimal currencies (jpy, krw, vnd, clp and the rest of Stripe's list) are compared as-is.
With a price binding, GET /v1/prices/<id>?expand[]=currency_options, then:
| Check | Diff |
|---|---|
| Price returns 404 |
break: price price_x does not exist (audit stops here) |
Price is archived (active: false) |
break: price price_x is archived
|
| An amount differs |
mismatch on unit_amount or currency_options.eur.unit_amount: Stripe has 4.99 USD, fact says 5.99 USD
|
| A directly mirrored currency leaf the price has no amount for |
mismatch on currency: price price_x has no EUR amount (price currency is USD)
|
The last check only fires for currency leaves you mirror directly. A currency that arrived by expanding a container is skipped quietly when the price doesn't carry it. That way a USD-only price can mirror addon:pro.price without complaining about EUR.
Then the product (GET /v1/products/<id>) is fetched when a .name fact is mirrored, or when the binding has only a product:
| Check | Diff |
|---|---|
| Product returns 404 |
break: product prod_x does not exist
|
| Product is archived |
break: product prod_x is archived
|
| Name differs |
mismatch on name: product name is "Nebula Plus", fact says "Nebula Pro"
|
Non-404 API errors (401, 429, 500) are thrown and land under audit errors.
Stripe audit results are mismatch and break, never stale: the adapter compares structured values, not text.
Audit also checks price ids that code points at: any symbol whose literal value is a price_… id (even without a Stripe artifact for it), and any symbol that anchors a Stripe artifact directly (// @starchart anchors stripe:price/pro-monthly). A symbol that only anchors a fact the price mirrors, like a product-name constant, is not a price reference. See Audit and Break Detection. It uses the same key and reports lines like Stripe price price_1NebulaPro499 is ARCHIVED; referenced by apps/web/app/api/checkout/route.ts:3.
Only runs when the step is auto, which requires adapters.stripe.write: true (see Enabling writes safely).
- Fetch the price, compute amount deltas. If a directly mirrored currency doesn't exist on the price, fail:
addon:pro.price.eur is EUR but price price_x is USD; bind a matching price. - Fetch the product if a
.namefact is mirrored and compare names. - Nothing differs? Return ok, no changes.
Stripe won't let you edit a price's amount. So apply:
-
Creates a replacement price (
POST /v1/prices), copying from the old one:product,currency,nickname,tax_behavior,recurring(interval,interval_count,usage_type), every othercurrency_optionsentry (with itstax_behavior), andmetadata. Changed amounts get the new values. -
Transfers the lookup key. If the old price had a
lookup_key, the new price is created with the samelookup_keyandtransfer_lookup_key=true. Code that resolves prices by lookup key follows automatically. -
Archives the old price (
POST /v1/prices/<old>withactive=false). -
Reports the new id as
bindingUpdate: { price: "price_new" }.
Here is the result from a copy of the demo with write: true, usd bumped to 5.99, and Stripe simulated by an injected fetch (the same technique the tests use). It made exactly four calls: GET /v1/prices/price_1NebulaPro499, GET /v1/products/prod_NebulaPro, POST /v1/prices, POST /v1/prices/price_1NebulaPro499:
{
"artifact": "stripe:price/pro-monthly",
"ok": true,
"changes": [
"created price price_1NebulaPro599x (addon:pro.price.usd: 4.99 USD → 5.99 USD)",
"archived price price_1NebulaPro499",
"newPrice: price_1NebulaPro599x"
],
"undo": {
"adapter": "stripe",
"artifact": "stripe:price/pro-monthly",
"data": { "oldPrice": "price_1NebulaPro499", "newPrice": "price_1NebulaPro599x", "lookupKey": "pro_monthly" }
},
"bindingUpdate": { "price": "price_1NebulaPro599x" }
}Existing subscriptions stay on the old (now archived) price. Stripe doesn't migrate subscribers when a price is archived, and neither does STARCHART. Moving subscribers is a billing decision you make in Stripe.
If the mirrored .name differs, POST /v1/products/<id> with the new name: renamed product prod_pro: "Nebula Plus" → "Nebula Pro". Products are mutable, so there's no replacement.
If a call fails midway (say the price was created but archiving the old one failed), the result is ok: false with the undo record and bindingUpdate for what did happen. The engine journals it and rewrites the binding, so starchart revert can still clean up.
When apply returns bindingUpdate: { price: "price_new" }, the engine opens the YAML file that declared the artifact (meta.file) and replaces the old id with the new one as a whole identifier token. Your money.yaml changes from:
binding: { adapter: stripe, price: price_1NebulaPro499 }to
binding: { adapter: stripe, price: price_1NebulaPro599x }The engine's record of that edit, from the same simulated run:
{
"artifact": "stripe:price/pro-monthly",
"file": ".starchart/artifacts/money.yaml",
"field": "price",
"from": "price_1NebulaPro499",
"to": "price_1NebulaPro599x",
"written": true
}The CLI prints it as ↻ stripe:price/pro-monthly: binding price price_1NebulaPro499 → price_1NebulaPro599x. The edit is recorded in the journal's bindingEdits and reversed on revert. Only the artifact's own YAML file is edited. A hardcoded price_… constant in your code is not rewritten. When the constant anchors the Stripe artifact, it shows up as a code item in the plan with its own reason:
⌘ code symbol:web/lib/stripe#PRICE_PRO_MONTHLY anchors holds this artifact's external id; update it if the id changes
and as a break on the next audit once the old price is archived.
starchart apply --dry-run makes the same reads but no writes, and describes the plan:
would create a replacement price (addon:pro.price.usd: 4.99 USD → 5.99 USD) and archive price_1NebulaPro499
(That line is from the simulated demo run, which made only the two GETs. A name change adds would rename product <id>: "<old>" → "<new>".)
No bindingUpdate is returned in dry-run mode, so no YAML is touched.
The undo record stores what's needed to go back:
{ "adapter": "stripe", "artifact": "stripe:price/pro-monthly",
"data": { "oldPrice": "price_old", "newPrice": "price_new", "lookupKey": "pro_monthly",
"productId": "prod_pro", "oldName": "Nebula Plus", "newName": "Nebula Pro" } }revert:
- Reactivates the old price (
active=true), moving thelookup_keyback withtransfer_lookup_key=trueif there was one. - Archives the new price.
- Renames the product back.
- Returns
bindingUpdate: { price: oldPrice }.
From the simulated run, reverting that journal gave:
reactivated price price_1NebulaPro499
archived price price_1NebulaPro599x
and put price_1NebulaPro499 back in money.yaml. The new price is archived, not deleted (Stripe can't delete prices that were used). The engine restores the YAML binding from the journal's bindingEdits, not from the revert's bindingUpdate.
capabilities.list is true. starchart orphans --external calls list() for every adapter that some artifact binds. The Stripe list pages through GET /v1/prices?limit=100&active=true and returns every active price:
{ externalId: "price_…", label: "Pro monthly 4.99 USD/month",
binding: { adapter: "stripe", price: "price_…" }, active: true }The label is the nickname, lookup key or product id, then the amount and interval. Active prices that no artifact binds are reported as orphans: dead stars still charging money. See Orphans.
Writes are off until you opt in. canWrite treats every non-fs adapter as external and requires exactly write: true:
adapters:
stripe:
secretEnv: STRIPE_TEST_KEY
write: trueA sane rollout:
-
Audit first.
STRIPE_SECRET_KEY=sk_test_… starchart audit --ids stripe:price/pro-monthly. Confirm the adapter sees what you expect. - Test mode. Use a test-mode key and test-mode price ids until you've seen a full apply and revert cycle work.
-
Dry run.
starchart apply --dry-run --only stripe:price/pro-monthly. -
Apply one artifact.
starchart apply --only stripe:price/pro-monthly, then look at the journal in.starchart/journal/and at the Stripe dashboard. -
Know the way back.
starchart revert <journal-id>. -
Keep it off in CI unless you mean it. A CI job with
write: trueand a live key can create and archive real prices.
write: false also makes apply refuse outright (writes to Stripe are disabled (adapters.stripe.write: false)), even if the adapter is called directly through the library.
★ STARCHART
☠ a Space Pirate Zero joint
STARCHART Every dependency. Code to cosmos.
Guides
Concepts
- Three-Layer Model
- Facts and Entities
- Artifacts and Bindings
- Edge Types
- Node IDs
- Impact Analysis
- Lockfile and Drift
- Rollout Ordering
Authoring
Code layer
Commands & API
Adapters & engine
- Adapters Overview
- Adapter: fs
- Adapter: url
- Adapter: Stripe
- Adapter: App Store Connect
- Writing an Adapter
- Audit and Break Detection
- Apply, Revert and Journals
- Future Universe Preview
- Viewer and Serve
- Reality X-Ray
Rules & analysis
Integrations
Project