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).
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.
| 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 |
Checkout builds every line item inline with price_data + product_data.
There is no Stripe Product or Price object anywhere in the flow.
apps/jmi-server/orders.js:418-432 — instrument and accessory linesapps/jmi-server/builds.js:531-541 — custom-build deposit lineConfirmed 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.
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).
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:
sk_live_… and change nothing.Take option 1. The change affects only the mode label and the dashboard link base, never the money path.
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 = ...).
~/.envReplace 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.
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.
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.
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:
checkout.session.completedcheckout.session.async_payment_succeededcheckout.session.async_payment_failedcheckout.session.expiredcharge.refundedpayment_intent.payment_failedA 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.
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:
automatic_tax enabled until this exists.txcd_99999999 (general tangible
goods). Match it, or pick a musical-instrument code if Darryl's accountant
prefers one.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.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.
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.
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.
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.
info@therecording.club as Developer.#0c0b09, #b0813f, prefer logo
- Public details: name, website, support email, statement descriptorjmi-server (Checkout Sessions: write) and
the webhook endpoint with the six events. Copy the key and the whsec_ once.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.fly secrets set with JMI_STRIPE_SECRET_KEY, JMI_STRIPE_WEBHOOK_SECRET,
and SITE_ORIGIN if the domain has moved. The machine restarts.~/.env. Rotating means deleting the old key, not only adding a new one.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.A live account means real money. Do this once, deliberately, with a real card.
curl -s https://jmi.gregspero.com/api/shop/products returns 200. The server
came back up.stripe.js
regex edit did not ship.#0c0b09 ground
- sales tax appears on the total for a California shipping address
- the browser returns to /order?session_id=… on the right domaincheckout.session.completed
delivered with a 200. A 400 here means the webhook secret is wrong.charge.refunded webhook landing. If it does not, the event is not
subscribed on the new endpoint.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.
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.
jonesmusicalinstruments.com still resolves to the old
WordPress host (199.16.172.169, 199.16.173.146), but the domain is already
attached to the jones-instruments Vercel project and verified (checked
2026-09-16 via the Vercel API). The cutover is two Cloudflare DNS records in
Darryl's Cloudflare account, not a Vercel project move. Greg's Cloudflare
token sees only therecording.club and tinyroom.com, so he cannot make the
change himself today. The SITE_ORIGIN change in section 3
applies only once the domain moves to the jones-instruments Vercel project.
The Stripe cutover does not depend on it and can happen first.GETs against the sandbox with the
existing test key.