The Recording Club · JMI statusBack to the status page

Internal Stripe cutover runbook, written Sep 16, 2026. Copied as written.

Those three commits shipped on Sep 16 in trc-beta-apps v242 per memory; live jmi-server equals HEAD (inference).

JMI Stripe cutover — Greg's sandbox to Darryl's live account

Internal. Written 2026-09-16 against the code as it stands. Every claim below was checked against the source or the live sandbox API, and the checks are named.

No secret values appear in this file. Keys live in ~/.env and on Fly.


0. What is true today

Thing Value How it was verified
Stripe account in use acct_1UBSC0BTFbgjVgCq, "Jones Musical Instruments sandbox", test mode, charges_enabled: false GET /v1/account with JMI_STRIPE_SECRET_KEY
Account email (who claimed it) info@therecording.club same call
Server Fly app trc-beta-apps, JMI runs as a child process on port 3344 behind the multiplexer fly.toml line 1; DEPLOY-SHOP.md header
API host https://jmi.gregspero.com DEPLOY-SHOP.md; site/shop.js:14
Site Vercel project jones-instruments, currently aliased to daryljones.gregspero.com DEPLOY-SHOP.md
Webhook endpoint we_1UBTftBTFbgjVgCqkpcG8LeF → https://jmi.gregspero.com/api/shop/stripe/webhook, enabled, 6 events GET /v1/webhook_endpoints
Stripe Tax status: active, head office 3131 Cahuenga Blvd W, Los Angeles CA 90068, default tax code txcd_99999999, default behavior exclusive GET /v1/tax/settings
Tax registration taxreg_1UBjHbBTFbgjVgCqcrHTiXhF, US / CA state sales tax, active GET /v1/tax/registrations
Branding icon file_1UBn3CBTFbgjVgCqhMBEtDXT, logo file_1UBn45BTFbgjVgCqUkH8CDp9, primary #0c0b09, secondary #b0813f GET /v1/account
Statement descriptor JONESMUSICALINSTRUMENTS.COM same call
Stripe Products/Prices in the account zero GET /v1/products?limit=3 returned 0, has_more: false

1. Nothing to migrate

Checkout builds every line item inline with price_data + product_data. There is no Stripe Product or Price object anywhere in the flow.

Confirmed against the live account: zero products exist. So the cutover moves credentials and settings only. No catalog export, no price IDs, no customer migration (the server never creates a Customer; stripe.js exposes only checkout.sessions and refunds).

Orders, payments and instrument registry all live in SQLite at /data/jmi.db on Fly. They are untouched by the account swap. Old test-mode cs_test_… ids stay in the rows; their dashboard deep links will 404 once the key is live, which is cosmetic and expected.


2. Every Stripe API call this server makes

The whole surface, from stripe.js and its callers:

Call Where Purpose
checkout.sessions.create orders.js:452, orders.js:460 (tax-off retry), builds.js:555 opens checkout
checkout.sessions.retrieve stripe.js:100 wrapper reading a session back
checkout.sessions.expire orders.js:508, orders.js:1126 releasing a hold
refunds.create stripe.js:124 wrapper never called by any route today

Signature verification is done locally with HMAC-SHA256 in stripe.js (constructEvent), not by the Stripe SDK, so no API call is made on a webhook. Refunds are issued by hand in the Stripe dashboard; the code records them when charge.refunded arrives (stripe.js comment at dashboardUrl, and orders.js:598).

Restricted key vs secret key

Use a restricted key. The server needs a strict subset of one.

Minimum that the code actually exercises:

Resource Permission Why
Checkout Sessions write create, retrieve, expire

That is genuinely all. Add these only if you want headroom without another rotation:

Resource Permission Why
Refunds write stripe.js can refund; no route calls it yet
Payment Intents read debugging a stuck order from the server
Charges read same

DEPLOY-SHOP.md used to ask for write on Checkout Sessions, Payment Intents, Charges, Refunds and Customers plus read on Events. That is wider than the code needs: Customers is dead weight (no Customer is ever created) and Events read is unnecessary (events are verified locally, never fetched). Narrowed to the table above in commit df717fc.

A restricted key starts rk_live_…. The code's live flag used to test /^sk_live_/, so a restricted key made the admin console label the mode "test" and build dashboard.stripe.com/test/… deep links while taking real money. Fixed 2026-09-16 in commit df717fc: the flag is now /^(sk|rk)_live_/, covered by tests in test-orders.mjs. Committed on main, not yet deployed. Two options:

  1. Ship the restricted key. The regex already handles it; deploy the commit.
  2. Ship the live secret key sk_live_… and change nothing.

Take option 1. The change affects only the mode label and the dashboard link base, never the money path.


3. Everything that changes

Fly secrets on trc-beta-apps

Secret From To
JMI_STRIPE_SECRET_KEY sandbox sk_test_… Darryl's live restricted key rk_live_…
JMI_STRIPE_WEBHOOK_SECRET sandbox whsec_… the whsec_… from the new live endpoint
SITE_ORIGIN unset, so it defaults to https://daryljones.gregspero.com (shop.js:45) https://jonesmusicalinstruments.com if and when the domain cuts over

The JMI_ prefix is mandatory and the original draft of this runbook got it wrong. trc-beta-apps runs ~26 children and spawns each with { ...process.env, ...env } (server.js:99), so money keys are namespaced per child: server.js:422-423 maps JMI_STRIPE_SECRET_KEY and JMI_STRIPE_WEBHOOK_SECRET onto the STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET this app reads. Setting the bare names sets variables nothing reads and leaves the shop on the sandbox key with no visible error. Confirmed against fly secrets list -a trc-beta-apps: the deployed names are JMI_-prefixed.

SITE_ORIGIN is the exception and is read bare (shop.js:45), because the jmi child env block does not list it. No other child reads that name today, so a bare Fly secret reaches the shop. Cleaner, and worth one line of server.js during the cutover: add SITE_ORIGIN: process.env.JMI_SITE_ORIGIN || 'https://daryljones.gregspero.com' to the defineChild('jmi', …) env block and set JMI_SITE_ORIGIN instead.

SITE_ORIGIN is not cosmetic. It builds success_url and cancel_url (orders.js:445-446) and the certificate/asset URLs (shop.js:62). If the domain moves and this stays, buyers land back on the old host after paying.

Set all of it in one command. Each fly secrets set restarts the machine.

fly secrets set -a trc-beta-apps \
  JMI_STRIPE_SECRET_KEY=rk_live_... \
  JMI_STRIPE_WEBHOOK_SECRET=whsec_... \
  SITE_ORIGIN=https://jonesmusicalinstruments.com

Confirm STRIPE_MOCK is not set on the machine (it is bare, not namespaced, and would silence every child that reads it). Any value of 1 forces the stub and the shop silently stops taking money (stripe.js, const mock = ...).

Local ~/.env

Replace JMI_STRIPE_SECRET_KEY / JMI_STRIPE_PUBLISHABLE_KEY / JMI_STRIPE_CLAIMABLE_KEY with the live restricted key, under a comment naming Darryl's account id. The publishable and claimable keys can go: see below.

Code files

None left. apps/jmi-server/stripe.js was fixed on 2026-09-16 (commit df717fc) and the full JMI suite passes: 1,303 checks, 0 failures.

The site — nothing changes

There is no publishable key anywhere in the site or the server. Verified by grep across ~/jones-instruments-site/site/ and apps/jmi-server/*.js: zero hits for pk_, publishable, or stripe outside comments. Checkout is created server-side and the browser follows session.url to Stripe's hosted page (site/shop.js, safeHttpUrl). So JMI_STRIPE_PUBLISHABLE_KEY in ~/.env is unused by anything shipped.

The CSP in site/vercel.json needs no edit either. It allows connect-src 'self' https://jmi.gregspero.com, which covers the fetch that creates the session, and the hop to checkout.stripe.com is a top-level navigation, not a fetch or a form post. form-action 'self' does not bind it.

CORS already allows the real domain: index.js:309 matches daryljones.gregspero.com, jones-instruments.vercel.app, and (www.)?jonesmusicalinstruments.com. No edit.


4. The webhook on Darryl's account

Endpoint URL, unchanged by the account move:

https://jmi.gregspero.com/api/shop/stripe/webhook

Subscribe to exactly these six. They are the cases in the switch (event.type) at orders.js:590-600:

A wider subscription is harmless: anything else gets a 200 and a row marked unhandled. A narrower one is not. Dropping checkout.session.expired leaves instruments reserved until the sweeper catches them. Dropping checkout.session.async_payment_failed leaves an order stuck at "Payment clearing" while holding an instrument nobody paid for.

DEPLOY-SHOP.md said "the same five events" in its handoff section. There are six, listed above and confirmed live on the sandbox endpoint. Corrected in commit df717fc.

Copy the new signing secret once. Stripe shows it once.


5. Stripe Tax — yes, it must be done again

Tax settings and registrations belong to the account, so none of the sandbox configuration carries over. On Darryl's live account, all of this is new:

  1. Head office address at https://dashboard.stripe.com/settings/tax. The address goods ship from. Checkout will not create a session with automatic_tax enabled until this exists.
  2. Preset product tax code. Sandbox uses txcd_99999999 (general tangible goods). Match it, or pick a musical-instrument code if Darryl's accountant prefers one.
  3. Default tax behavior: exclusive. The code sends tax_behavior: 'exclusive' on every line and on the shipping rate (orders.js:425, orders.js:439), so the listed price is the instrument and tax is added at the till. Correct for California.
  4. California registration at https://dashboard.stripe.com/tax/locations, plus any other state where Darryl has nexus. Without a registration in the buyer's state, Stripe calculates zero tax and the sale goes out untaxed.

The registration is Darryl's legal filing with the CA CDTFA, not something Stripe grants. If he already holds a seller's permit for the business, adding the registration in Stripe is a two-minute form. If he does not, that is the long pole in the whole cutover and it is his accountant's call.

There is a safety net, not a substitute for it: if tax is unconfigured, the first sessions.create throws, isTaxConfigError catches it, and checkout reopens with automatic_tax: { enabled: false }, flagged on the order (orders.js:452-463). The shop keeps selling and the note tells you tax was not collected. Treat any order carrying that note as a problem to fix same day.


6. Branding — re-apply on the live account

Branding is per-account. Settings page: https://dashboard.stripe.com/settings/branding

Re-upload and set:

Setting Value
Icon ~/claude-grid/public/responses/jmi-stripe-icon.png
Logo ~/claude-grid/public/responses/jmi-stripe-logo.png
Brand colour #0c0b09
Accent colour #b0813f
Prefer logo over icon on Checkout on

Also set, under https://dashboard.stripe.com/settings/public:

Setting Value
Business name Jones Musical Instruments
Website https://jonesmusicalinstruments.com
Support email info@jonesmusicalinstruments.com (used in shop.js:1010)
Statement descriptor JONESMUSICALINSTRUMENTS.COM (matches the sandbox)

The statement descriptor is what a buyer sees on a card statement. A descriptor they do not recognise is the most common cause of a chargeback on a first sale.


7. Access model — the recommendation

Darryl owns the account. Greg is a team member with the Developer role.

Darryl registers at https://dashboard.stripe.com/register with his own email, completes KYC himself (EIN or SSN, bank account, business address), and is the Account Owner. The bank account and the money are his, which is the point.

Greg is invited at https://dashboard.stripe.com/settings/team with the Developer role. Verified against Stripe's role documentation, a Developer can:

and cannot:

That last group is exactly the right fence. Greg can run the integration and refund a buyer, and cannot move where the money lands.

Webhook endpoint registration lives under the same settings permission, so Greg can register and re-point the endpoint himself.

Invite info@therecording.club. That is the email that claimed the sandbox (confirmed on the live account object), so it keeps one Stripe identity across both accounts rather than splitting history across two of Greg's addresses.

Turn on two-factor for both accounts at https://dashboard.stripe.com/settings/user. A passkey, not SMS.


8. Cutover order

Do it in this order. Steps 1 to 4 are safe to do days ahead; the shop keeps running on the sandbox until step 6.

  1. Darryl registers, activates, and passes KYC. Nothing else can start.
  2. Darryl invites info@therecording.club as Developer.
  3. Greg, in live mode on Darryl's account: - Tax: head office, tax code, exclusive behavior, CA registration - Branding: icon, logo, #0c0b09, #b0813f, prefer logo - Public details: name, website, support email, statement descriptor
  4. Greg creates the restricted key jmi-server (Checkout Sessions: write) and the webhook endpoint with the six events. Copy the key and the whsec_ once.
  5. Deploy the live-regex fix. Done in code already — commit df717fc widened it to /^(sk|rk)_live_/ and added tests — but it is committed, not deployed. Run cd ~/trc-beta-apps-deploy && ./bin/deploy (never a bare fly deploy) on a clean tree, before the secrets, so the machine restarts once on the right code.
  6. One fly secrets set with JMI_STRIPE_SECRET_KEY, JMI_STRIPE_WEBHOOK_SECRET, and SITE_ORIGIN if the domain has moved. The machine restarts.
  7. Run the test plan in section 9.
  8. Delete the sandbox key from the old account and clear the sandbox values out of ~/.env. Rotating means deleting the old key, not only adding a new one.
  9. DEPLOY-SHOP.md is already corrected in commit df717fc: six events not five, the restricted-key grant narrowed to Checkout Sessions write, the JMI_-prefixed secret names, and the live-account smoke test no longer telling you to use a test card.

9. Test plan

A live account means real money. Do this once, deliberately, with a real card.

  1. curl -s https://jmi.gregspero.com/api/shop/products returns 200. The server came back up.
  2. In the console at https://jmi.gregspero.com/admin, the order list should report mode live. If it says test while the key is live, the stripe.js regex edit did not ship.
  3. Buy the cheapest real accessory on the site with a real card, Darryl's or Greg's. Watch for: - Stripe's hosted page shows the JMI logo on the #0c0b09 ground - sales tax appears on the total for a California shipping address - the browser returns to /order?session_id=… on the right domain
  4. In the console: the order is Paid, carries the payment intent, shows the shipping address, and carries no "tax was not configured" note.
  5. In Stripe, Developers → Webhooks → the endpoint: checkout.session.completed delivered with a 200. A 400 here means the webhook secret is wrong.
  6. Refund it in the Stripe dashboard, full amount.
  7. Back in the console, within a minute, the order shows the refund. That is the charge.refunded webhook landing. If it does not, the event is not subscribed on the new endpoint.
  8. Open one instrument, start a checkout, and abandon it. After the hold window the checkout.session.expired event should release the instrument back to the site.

Step 7 and step 8 are the two that actually prove the new webhook secret and the full event list. Do not skip them because the purchase worked.

Do not use 4242 4242 4242 4242. Test cards are declined by a live account.


10. Rollback

The old sandbox key and webhook keep working until you delete them, so rollback is one command and a restart:

fly secrets set -a trc-beta-apps \
  JMI_STRIPE_SECRET_KEY=<the sandbox sk_test_...> \
  JMI_STRIPE_WEBHOOK_SECRET=<the sandbox whsec_...>

Both values are in ~/.env and in the sandbox dashboard until step 8 of the cutover. Which is the reason step 8 is last and separate: do not delete the old key on the same day you install the new one.

If you roll back, disable the live webhook endpoint on Darryl's account first. Otherwise it keeps posting events the server can no longer verify, every delivery fails, and Stripe eventually disables the endpoint on its own and emails Darryl about it.

If a live order came in before the rollback, it is in /data/jmi.db with a live session id and a real charge on Darryl's account. Refund it from his dashboard by hand; the sandbox key cannot touch it.


11. Unverified, flagged