# Ephemera — Accept payments with Stripe (Checkout + Billing)

> Self-executing Markdown. First binding of the *payments* intent. The cloud is the source of truth;
> this file is intent + write-back ledger.

> **Status: DOGFOODED 2026-07-02** — subscription branch proven live in a Dashboard **sandbox**
> (test mode, ~$0): §1 gate (refused a real `rk_live_` mistake) → catalog + idempotent re-apply →
> real 4242 purchase with `/v1/events` acceptance → portal session → 💥 teardown verified inert.
> Unrun branches: `BILLING=one-time`, `WEBHOOK=endpoint` (+ `stripe listen` delivery proof), `TAX=on`, §7 live.

> **Provides** `price(lookup_key)` for each catalog tier, `publishable-key(pk_…)` (dashboard-copied,
> public by design — the string a frontend embeds), and `webhook-secret(pointer)` (a secret-store
> name, never the value). **Requires** *(only when `WEBHOOK=endpoint`)* `https-receiver(RECEIVER_URL)`
> — a deployed HTTPS endpoint from e.g. [`service.cloudflare.md`](./service.cloudflare.md) /
> [`service.aws.md`](./service.aws.md) that will consume the events.

---

## 🤖 Director prompt

Observe before acting; verify each step; stop at 🔴/💥 for human go; write realized values back into
Live State. **Test mode is the built-in ephemeral sandbox** — a `sk_test_`/`rk_test_` key gives the
full create/verify/teardown loop at $0 with zero blast radius; live mode exists only behind the §7
gate, and §1 **hard-fails** on a non-test key. `STRIPE_API_KEY` is a secret (env / secret store),
never inline; the webhook signing secret (`whsec_…`) is **returned only at creation** — capture it to
the secret store immediately, ledger the pointer. Never `set -x` in a block that touches `$WH`.

```
Legend  🟢 create · 🟡 config · 🔴 GATE (human go) · 💥 destructive (human go) · ⏳ wait · ✔ verify
```

## Intent

Accept payments for a small product catalog — recurring subscription tiers **or** one-time purchases
(`BILLING` knob) — via Stripe-hosted Checkout, with events delivered to a webhook and (subscription
branch) customer self-service via the hosted Billing Portal. No card data ever touches your
infrastructure: Stripe hosts the payment page; your side is a catalog, a webhook consumer, and two
redirect URLs. Cost shape: **$0 idle, $0 in test mode**; live mode is per-transaction
(≈2.9% + 30¢) — there is no denial-of-wallet surface because there is no infrastructure to run up.

**Shared acceptance contract** (every *payments* binding must pass):
1. the catalog is **discoverable by a deterministic key** (here: price `lookup_key`s) — re-apply finds, never duplicates
2. a Checkout/hosted-payment session **mints** and its hosted URL is reachable
3. a **test-card purchase completes** and the provider delivers a **signed completion event** (`checkout.session.completed`) for that exact session — asserted from the provider's event log, not by eyeball
4. *(subscription branch)* a customer **self-service portal session mints** for the purchasing customer

## Provisioning Inputs

| # | Question | Options (closed enum) | Default | Sets | Gates |
|---|----------|-----------------------|---------|------|-------|
| 1 | Billing model? | `subscription` / `one-time` | `subscription` | `BILLING` | §2 price shape, §3 event list, §4 session mode, §5 runs at all |
| 2 | Webhook delivery? | `cli-listen` / `endpoint` | `cli-listen` | `WEBHOOK` | §3 branch; `endpoint` Requires `RECEIVER_URL` |
| 3 | Stripe Tax? | `off` / `on` | `off` | `TAX` | §6 runs; §4 adds `automatic_tax` |
| 4 | Mode? | `test` / `live` | `test` | `MODE` | `live` is 🔴 — §7 only, never silently |
| 5 | Receiver URL *(if WEBHOOK=endpoint)* | free-text HTTPS URL | — | `RECEIVER_URL` | §3 |
| 6 | Head-office country/state *(if TAX=on)* | free-text ISO codes | `US` / `CA` | `TAX_COUNTRY`,`TAX_STATE` | §6 |
| 7 | Currency / tier names / amounts | free-text, **single-token** (names become lookup_keys — no spaces/`&`/`=`) | `usd`; `basic`@$5, `pro`@$20 /mo; one-time `item`@$10 | `CURRENCY`,`TIER*`,`ITEM*` | §2 |

```yaml
resolved_inputs:            # → written into Live State once resolved
  billing:  subscription
  webhook:  cli-listen
  tax:      off
  mode:     test
```

## Metadata & provenance (binding asymmetry)

**Stripe has no resource-tag API — but every object this plan creates (products, prices, webhook
endpoints, portal configurations) accepts `metadata`.** The TAGS movement binds to metadata here:
`managed_by=ephemera`, `source`, `plan_version`, `environment` on every create (values single-token —
the renderer word-splits like the AWS `tags_kv` shorthand). Deterministic identity rides on price
**`lookup_key`s** (`${ENV}-${tier}`) — Stripe's first-class answer to the ACM
deterministic-identity problem: observe by lookup key, reuse if found, create only if absent.
The metadata is also teardown's **enforced guard**: every destructive step first reads the object's
metadata and acts only on `managed_by=ephemera` + `source=payments.stripe.md`.

## Live State

```yaml
status:        not-created      # published template - run it to realize state
                             # gone-inert (named terminal state): nothing purchasable or delivering;
                             # archived objects still exist — Stripe keeps financial history immutable
last_action:   2026-07-02 dogfood — full loop §1→§5 then 💥 teardown (human go), subscription branch, sandbox test mode
last_verified: 2026-07-02 — acceptance contract 1–4 ALL PASS; teardown verified inert (archived catalog; sub canceled; customer deleted:true; default portal config exempt-active per Stripe rule)
mode:          test          # live only ever via §7 🔴 — dogfood ran in a Dashboard SANDBOX (own acct_…, own isolated dataset)
resolved_inputs: { billing: subscription, webhook: cli-listen, tax: off, mode: test }
```

| key | value (filled on apply) |
|-----|-------------------------|
| PRODUCT_ID(s) | `<STRIPE_PRODUCT_ID>` (basic-dev), `<STRIPE_PRODUCT_ID>` (pro-dev) |
| PRICE_ID(s) + lookup_key(s) | `dev-basic` → `<STRIPE_PRICE_ID>`; `dev-pro` → `<STRIPE_PRICE_ID>` |
| PUBLISHABLE_KEY (`pk_…`) | not copied this run (frontend consumer out of dogfood scope) |
| WEBHOOK_ENDPOINT_ID (`we_…`) | — (WEBHOOK=cli-listen: no cloud object; endpoint branch UNRUN) |
| WEBHOOK_SECRET pointer | — (n/a this run) |
| PORTAL_CONFIG_ID (`bpc_…`) | `<STRIPE_PORTAL_CONFIG_ID>` |
| verify CUSTOMER_ID (`cus_…`) / SESSION_ID / SUBID | `<STRIPE_CUSTOMER_ID>` / `<STRIPE_CHECKOUT_SESSION_ID>…` / `<STRIPE_SUBSCRIPTION_ID>` |

| ✔ check | expected | observed | result |
|---------|----------|----------|--------|
| key is test-mode | prefix `sk_test_`/`rk_test_` AND `/v1/balance` → `livemode: false` (**negative**: not live; hard exit otherwise) | rk_test_ + `livemode: False` — **and the gate earned it: first key offered was rk_live_, REFUSED** | **PASS** |
| catalog by lookup_key | each `lookup_key` resolves to **exactly one** active price (asserted, exit 1) | dev-basic + dev-pro → exactly one each; re-apply short-circuited (`exists`) | **PASS** |
| checkout session mints | `url` present, HTTP 200 on GET | 200 | **PASS** |
| webhook endpoint enabled | `WE_ID` present with `status=enabled` (asserted; WEBHOOK=endpoint) | — UNRUN (cli-listen run) | unrun |
| completion event delivered | `/v1/events` contains `checkout.session.completed` for **this** `SESSION_ID` (polled, exit 1) | real 4242 purchase by human; event matched SID on first poll | **PASS** |
| portal session mints | `url` present (subscription branch) | `billing.stripe.com/p/session/test_…` minted for `<STRIPE_CUSTOMER_ID>…` | **PASS** |
| tax settings | observed; set only if unset (TAX=on) | — UNRUN (TAX=off) | unrun |
| teardown: catalog inert | lookup_keys → **zero active** prices; no ephemera-owned endpoints; no active ephemera-owned **non-default** portal configs; ledgered customer `deleted:true` (asserted, exit 1) | 0/0/0 active; `<STRIPE_CUSTOMER_ID>…` deleted:true; `bpc_…` left active (account default — Stripe refuses) | **PASS** |

## 0. Variables

```bash
set -f   # metadata[…]/lookup_keys[] args are glob patterns — globbing off for plan blocks
export ENV="dev" MODE="test" BILLING="subscription" WEBHOOK="cli-listen" TAX="off"
export CURRENCY="usd"
export TIER1_NAME="basic" TIER1_AMOUNT=500  TIER2_NAME="pro" TIER2_AMOUNT=2000 INTERVAL="month"
export ITEM_NAME="item"  ITEM_AMOUNT=1000          # one-time branch
export SUCCESS_URL="https://example.com/thanks" CANCEL_URL="https://example.com/cancel"
export TAX_COUNTRY="US" TAX_STATE="CA"             # §6 (TAX=on)
export PLAN_SOURCE="payments.stripe.md" PLAN_VERSION="2026-07-02"

# secret: export STRIPE_API_KEY=…  (sk_test_/rk_test_; env or secret store, never inline)
#   macOS Keychain (presence probe is exit-code only — never echo the value):
#   security find-generic-password -s "stripe-${MODE}-key" -w >/dev/null 2>&1 && echo HIT
#   STRIPE_API_KEY="$(security find-generic-password -s "stripe-${MODE}-key" -w)"
S() {  # authed Stripe call — curl reads the key from a private fd, not argv (still in this shell's env)
  curl -fsS --config <(printf 'user = "%s:"\n' "$STRIPE_API_KEY") "$@"; }

meta_args() {  # provenance on every create (single-token values; intentional word-split)
  printf -- "-d metadata[managed_by]=ephemera -d metadata[source]=%s -d metadata[plan_version]=%s -d metadata[environment]=%s " \
    "$PLAN_SOURCE" "$PLAN_VERSION" "$ENV"; }
meta_args_pd() {  # same provenance for inline product_data (atomic product+price create, §2)
  printf -- "-d product_data[metadata][managed_by]=ephemera -d product_data[metadata][source]=%s -d product_data[metadata][plan_version]=%s -d product_data[metadata][environment]=%s " \
    "$PLAN_SOURCE" "$PLAN_VERSION" "$ENV"; }

price_by_key() {  # active price id for a lookup_key; "" if absent; NONZERO EXIT on API failure
  local out; out="$(S -G https://api.stripe.com/v1/prices -d active=true -d "lookup_keys[]=$1")" || return 2
  printf '%s' "$out" | python3 -c 'import sys,json; d=json.load(sys.stdin)["data"]; print(d[0]["id"] if d else "")'; }

owned() {  # teardown guard: is this API object ephemera-owned? echoes yes/no; nonzero on API failure
  local out; out="$(S "https://api.stripe.com/v1/$1")" || return 2
  printf '%s' "$out" | PLAN_SOURCE="$PLAN_SOURCE" python3 -c 'import sys,json,os; m=json.load(sys.stdin).get("metadata") or {}; print("yes" if m.get("managed_by")=="ephemera" and m.get("source")==os.environ["PLAN_SOURCE"] else "no")'; }
```

## Dependency frontier

```
key (test) ─> ✔ preflight ─> 🟢 catalog (price w/ inline product) ─┬─> 🟡/🟢 webhook ─> ✔ checkout+event (acceptance)
                                                                    ├─> 🟡 portal config (subscription)
                                                                    └─> 🟡 tax settings (TAX=on)
live mode: everything again behind 🔴 §7 — test objects DO NOT exist in live; it is a re-apply, not a flip
```
Non-negotiable edges: Checkout needs a price; the portal session (§5 verify) needs the **customer
that §4's completed purchase created**; the webhook consumer must be listening *before* the §4
purchase or the acceptance event is unobserved (the §4 event assertion still catches it in
`/v1/events` — Stripe's log is the truth, the listener is the delivery proof).

## 1. Preflight  ✔

```bash
case "${STRIPE_API_KEY:-}" in
  "") echo "key: MISSING"; exit 1 ;;
  sk_test_*|rk_test_*) echo "key: test-mode prefix ok" ;;
  *) [ "$MODE" = "live" ] || { echo "key: NOT a test key and MODE=test — refusing"; exit 1; } ;;
esac
brew list stripe >/dev/null 2>&1 || brew install stripe/stripe-cli/stripe   # CLI needed only for `stripe listen` (§3/§4); all mutations here are plain curl
S https://api.stripe.com/v1/balance | MODE="$MODE" python3 -c '
import sys,json,os; live=json.load(sys.stdin)["livemode"]; print("livemode:", live)
sys.exit(1 if (live and os.environ["MODE"]=="test") or (not live and os.environ["MODE"]=="live") else 0)'
# hard exit on mode mismatch — the $0-blast-radius story rests on this being fail-closed
```
> ⚠️ **Console gate, stated up front:** Stripe API keys — including restricted keys — are minted in
> the **Dashboard only**; there is no API to create them. This plan *consumes* a key; it cannot issue
> one. **Exact click-path (proven live 2026-07-02; the UI is a maze — budget 10 minutes):** open your
> **Sandbox** (top-left account switcher; a sandbox is an isolated dataset with its own `acct_…` —
> ideal, the plan gets an empty room) → **Developers** (lower-left) → **API keys** → **Create
> restricted key** (the three "what's this for?" entry doors just pre-shape the same permissions
> checklist — pick "build your own integration" for manual control). The permissions list is huge —
> **use its search box** and set, one by one: **write** = Products (covers prices), Checkout Sessions,
> Customers, Subscriptions, Customer portal (under Billing), Webhook Endpoints; **read** = Balance
> (§1's ping) + **Events** (§4's acceptance poll — easy to miss, without it §4 403s). Everything
> else None. ⚠️ **Create the key with the sandbox banner showing** — the same flow on the live
> Dashboard mints an `rk_live_` that this §'s gate will refuse (proven: it did). The publishable
> `pk_…` for the frontend is copied from the same page → ledger it (public by design).
> → Live State: mode + livemode check row + PUBLISHABLE_KEY.

## 2. Catalog — prices with inline products  🟢

```bash
# observe first: lookup_keys are the deterministic identity — found ⇒ reuse, absent ⇒ create.
# product_data[…] creates the product atomically WITH the price — no orphan product on partial failure.
mk_price() {  # $1=name $2=amount $3=recurring|onetime
  KEY="${ENV}-$1"
  PID="$(price_by_key "$KEY")" || { echo "observe failed (API error) — not creating"; exit 1; }
  [ -n "$PID" ] && { echo "exists $KEY -> $PID"; return 0; }
  S https://api.stripe.com/v1/prices -d "product_data[name]=$1-${ENV}" $(meta_args_pd) \
    -d unit_amount="$2" -d currency="$CURRENCY" -d lookup_key="$KEY" \
    $( [ "$3" = recurring ] && printf -- '-d recurring[interval]=%s' "$INTERVAL" ) $(meta_args) \
    | python3 -c 'import sys,json; d=json.load(sys.stdin); print("created", d["lookup_key"], d["id"], "product:", d["product"])'
}
if [ "$BILLING" = "subscription" ]; then
  mk_price "$TIER1_NAME" "$TIER1_AMOUNT" recurring || exit 1
  mk_price "$TIER2_NAME" "$TIER2_AMOUNT" recurring || exit 1
else
  mk_price "$ITEM_NAME" "$ITEM_AMOUNT" onetime || exit 1
fi
```
```bash
# ✔ each lookup_key resolves to EXACTLY ONE active price — asserted, not eyeballed
for K in $( [ "$BILLING" = subscription ] && echo "${ENV}-${TIER1_NAME} ${ENV}-${TIER2_NAME}" || echo "${ENV}-${ITEM_NAME}" ); do
  S -G https://api.stripe.com/v1/prices -d active=true -d "lookup_keys[]=$K" | K="$K" python3 -c '
import sys,json,os; d=json.load(sys.stdin)["data"]
print(os.environ["K"], "->", [p["id"] for p in d]); sys.exit(0 if len(d)==1 else 1)' || exit 1
done
```
> ⚠️ **Prices are immutable** — `unit_amount` can never change on an existing price. A price change is
> a *rotation*: create the new price with `-d transfer_lookup_key=true` (moves the lookup_key), then
> archive the old one (§ Update). Re-running this step is safe: observe-by-lookup_key short-circuits,
> and an API failure during observe **stops** (exit) rather than falling through to a duplicate create.
> → Live State: PRICE_ID + PRODUCT_ID per lookup_key (from the `created …` line).

## 3. Webhook — how completion events reach you

```bash
EVENTS="checkout.session.completed invoice.paid invoice.payment_failed customer.subscription.updated customer.subscription.deleted"
[ "$BILLING" = "one-time" ] && EVENTS="checkout.session.completed payment_intent.succeeded payment_intent.payment_failed"
```

**`WEBHOOK=cli-listen`** 🟡 *(no cloud object; ideal for dogfood/dev)* — run in a second terminal
**before** §4 and leave it running. ⚠️ `stripe listen` needs a one-time `stripe login` first — a
**browser/console gate** on a fresh machine (same class as the Firebase first-activation gate):

```bash
stripe listen --events "$(printf '%s' "$EVENTS" | tr ' ' ',')" --print-json
# prints its own whsec_… + every delivered event; --forward-to <url> to exercise a local consumer
```

**`WEBHOOK=endpoint`** 🟢 *(Requires `https-receiver(RECEIVER_URL)`)* — observe first; Stripe happily
duplicates endpoints at the same URL, so found ⇒ reuse:

```bash
WE_ID="$(S -G https://api.stripe.com/v1/webhook_endpoints -d limit=100 | RECEIVER_URL="$RECEIVER_URL" PLAN_SOURCE="$PLAN_SOURCE" ENV="$ENV" python3 -c '
import sys,json,os
j=json.load(sys.stdin)
if j["has_more"]: sys.exit(2)   # accounts cap at 16 endpoints, but fail loud, never silent-miss
m=lambda e:(e.get("metadata") or {})
hits=[e["id"] for e in j["data"]
      if e["url"]==os.environ["RECEIVER_URL"] and m(e).get("source")==os.environ["PLAN_SOURCE"]
      and m(e).get("environment")==os.environ["ENV"]]
print(hits[0] if hits else "")')" || exit 1
if [ -z "$WE_ID" ]; then
  WH="$(S https://api.stripe.com/v1/webhook_endpoints -d url="$RECEIVER_URL" \
    $(printf -- "-d enabled_events[]=%s " $EVENTS) $(meta_args))"
  WE_ID="$(printf '%s' "$WH" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
  # the response's "secret" (whsec_…) is shown ONLY NOW — straight to Keychain (one argv exposure,
  # unavoidable: `security` has no stdin mode). NEVER echo $WH; never set -x here.
  security add-generic-password -U -s "stripe-whsec-${ENV}" -a ephemera \
    -w "$(printf '%s' "$WH" | python3 -c 'import sys,json; print(json.load(sys.stdin)["secret"])')"
  unset WH
  echo "created ${WE_ID}; whsec -> Keychain stripe-whsec-${ENV}"
else
  security find-generic-password -s "stripe-whsec-${ENV}" -w >/dev/null 2>&1 \
    && echo "exists ${WE_ID} — whsec in Keychain (stripe-whsec-${ENV})" \
    || echo "WARN: ${WE_ID} exists but Keychain pointer missing — rotate: delete endpoint + re-run §3"
fi
```
```bash
# ✔ THE PLAN'S endpoint exists + enabled — asserted, exit 1 (secret NOT in this file — pointer only)
S -G https://api.stripe.com/v1/webhook_endpoints -d limit=100 | WE_ID="$WE_ID" python3 -c '
import sys,json,os
hit=[e for e in json.load(sys.stdin)["data"] if e["id"]==os.environ["WE_ID"]]
print(hit[0]["id"], hit[0]["status"], hit[0]["url"]) if hit else print("MISSING", os.environ["WE_ID"])
sys.exit(0 if hit and hit[0]["status"]=="enabled" else 1)' || exit 1
```
> → Live State: WEBHOOK_ENDPOINT_ID; WEBHOOK_SECRET pointer = `stripe-whsec-${ENV}` (Keychain).

## 4. Checkout — mint a session, complete a test purchase  🟡✔  *(the acceptance step)*

```bash
SMODE="$( [ "$BILLING" = subscription ] && echo subscription || echo payment )"
PRICE="$(price_by_key "${ENV}-$( [ "$BILLING" = subscription ] && echo "$TIER1_NAME" || echo "$ITEM_NAME" )")" || exit 1
SESS="$(S https://api.stripe.com/v1/checkout/sessions -d mode="$SMODE" \
  -d "line_items[0][price]=$PRICE" -d "line_items[0][quantity]=1" \
  -d success_url="$SUCCESS_URL" -d cancel_url="$CANCEL_URL" \
  $( [ "$TAX" = on ] && printf -- '-d automatic_tax[enabled]=true' ) $(meta_args))"
SID="$(printf '%s' "$SESS" | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
URL="$(printf '%s' "$SESS" | python3 -c 'import sys,json; print(json.load(sys.stdin)["url"])')"
curl -fsS -o /dev/null -w 'checkout page: %{http_code}\n' "$URL" && echo "$URL"
```
> **Human step:** open `$URL` in a browser, pay with test card `4242 4242 4242 4242` (any future
> date/CVC). Then the machine-checkable acceptance — Stripe's event log is the truth:

```bash
# ✔ poll /v1/events for checkout.session.completed carrying THIS session id — fail-closed ⏳
HIT=no
for i in $(seq 1 18); do
  HIT="$(S -G https://api.stripe.com/v1/events -d type=checkout.session.completed -d limit=20 \
    | SID="$SID" python3 -c 'import sys,json,os; es=json.load(sys.stdin)["data"]; print("yes" if any(e["data"]["object"]["id"]==os.environ["SID"] for e in es) else "no")')" || exit 1
  [ "$HIT" = yes ] && break; sleep 10
done
[ "$HIT" = yes ] || { echo "no completion event for $SID"; exit 1; }
# capture what the purchase created (feeds §5 + teardown) → ledger these. Fail-closed: the session
# must be status=complete, and an API failure stops rather than silently ledgering "none".
OUT="$(S "https://api.stripe.com/v1/checkout/sessions/${SID}" | python3 -c '
import sys,json; d=json.load(sys.stdin)
sys.exit(1) if d["status"]!="complete" else print("CUS=%s SUBID=%s" % (d["customer"] or "", d.get("subscription") or ""))')" \
  || { echo "session ${SID} not complete / API error"; exit 1; }
eval "$OUT"
echo "customer: ${CUS:-none}  subscription: ${SUBID:-none}"
```
> Sessions are ephemeral by design (expire ~24 h) — the session is the *proof*, the catalog is the
> *infrastructure*. `stripe trigger checkout.session.completed` also exists as a plumbing smoke test,
> but ⚠️ its fixtures **create their own throwaway product/price** (teardown noise) — the real
> test-card purchase above is the honest acceptance. If §3 runs `cli-listen`, the listener printing
> the same event is the *delivery* proof on top of this *occurrence* proof.
> → Live State: SESSION_ID, CUSTOMER_ID (`cus_…`), SUBID; fill event row.

## 5. Billing Portal — customer self-service  🟡  *(BILLING=subscription only)*

```bash
# observe first: portal configurations can NEVER be deleted (only deactivated) — a re-run that
# creates blindly accretes one forever. Found (active + ephemera-owned) ⇒ reuse.
BPC="$(S -G https://api.stripe.com/v1/billing_portal/configurations -d active=true -d limit=100 \
  | PLAN_SOURCE="$PLAN_SOURCE" ENV="$ENV" python3 -c '
import sys,json,os
j=json.load(sys.stdin)
if j["has_more"]: sys.exit(2)   # configs are undeletable and accrete — fail loud past 100, never silent-miss
hits=[c["id"] for c in j["data"]
      if (c.get("metadata") or {}).get("source")==os.environ["PLAN_SOURCE"]
      and (c.get("metadata") or {}).get("environment")==os.environ["ENV"]]
print(hits[0] if hits else "")')" || exit 1
[ -z "$BPC" ] && BPC="$(S https://api.stripe.com/v1/billing_portal/configurations \
  -d "features[invoice_history][enabled]=true" \
  -d "features[customer_update][enabled]=true" -d "features[customer_update][allowed_updates][]=email" \
  -d "features[subscription_cancel][enabled]=true" \
  -d "business_profile[headline]=Manage your subscription" $(meta_args) \
  | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')"
echo "BPC: $BPC"
```
```bash
# ✔ a portal session mints for §4's customer ($CUS from §4 / Live State)
S https://api.stripe.com/v1/billing_portal/sessions -d customer="$CUS" -d configuration="$BPC" \
  -d return_url="$SUCCESS_URL" | python3 -c 'import sys,json; print(json.load(sys.stdin)["url"])'
```
> ⚠️ Proven live: **the first API-created configuration becomes the account default
> (`is_default: true`), and Stripe refuses `active:false` on the default** — it can be neither
> deleted nor deactivated. Teardown's sweep and ✔ exempt it. → Live State: PORTAL_CONFIG_ID.

## 6. Stripe Tax  🟡  *(TAX=on only — observe-first on a BORROWED account-level singleton)*

```bash
# tax settings are account-level: this plan does NOT own them. Observe; set ONLY if unset.
CUR="$(S https://api.stripe.com/v1/tax/settings | python3 -c 'import sys,json; d=json.load(sys.stdin); print(d["status"], (d.get("head_office") or {}).get("address",{}).get("country") or "unset")')" || exit 1
echo "tax settings: $CUR"
case "$CUR" in
  *unset) S https://api.stripe.com/v1/tax/settings -d "head_office[address][country]=$TAX_COUNTRY" \
            -d "head_office[address][state]=$TAX_STATE" -d "defaults[tax_behavior]=exclusive" >/dev/null \
            && echo "set head office ${TAX_COUNTRY}/${TAX_STATE}" || { echo "tax settings set FAILED"; exit 1; } ;;
  *)      echo "already configured — leaving the borrowed singleton alone (changing it is a human decision, not drift)" ;;
esac
S https://api.stripe.com/v1/tax/settings | python3 -c 'import sys,json; print("tax status:", json.load(sys.stdin)["status"])'
```
> If status stays `pending`, the remainder is **console-gated** (registrations / activation in
> Dashboard → Tax) — the `manual` member of this fork: finish there, then re-run the ✔. Teardown
> never touches tax settings (borrowed, not created).
> → Live State: tax row (status + whether this plan set it or found it).

## 7. Live-mode promotion  🔴

Everything above ran in test mode. Going live is a **re-apply, not a flip**: test-mode objects do not
exist in live mode. With explicit human go: swap `STRIPE_API_KEY` for the live key (secret store),
set `MODE=live ENV=prod`, re-run §1 (its mode check now *requires* `livemode: true`), §2, §3
(`WEBHOOK=endpoint` with the production receiver), §5, §6. §4's card is a **real charge** — verify
live with a real card + immediate refund, or trust the test-mode acceptance. Record approver +
timestamp in Live State.

## Update (idempotent reconcile)  🟡

- **Price change** = rotation: create new price `-d transfer_lookup_key=true` (lookup_key moves),
  **repoint the product** (`POST /v1/products/{prod_…} -d default_price={new price}` — archiving a
  price that is an active product's `default_price` 400s, proven live), then archive the old
  (`active=false`). Existing subscriptions keep the old price until migrated — deliberate, not drift.
- **Event list / receiver change**: `POST /v1/webhook_endpoints/{we_…}` with new `enabled_events[]` /
  `url`. Secret is unchanged; rotation = delete + recreate (new `whsec_` → same Keychain name, `-U`).
- **Key rotation / revocation**: mint or revoke in Dashboard (console-gated), update secret store; no
  plan step changes. ⚠️ Keys are **per-context**: a sandbox only shows its own keys — to revoke a
  live key you must first **exit the sandbox** (account dropdown, top-left) before Developers →
  API keys shows the live set (proven live: a stray `rk_live_` was revoked exactly this way).
- Re-running §2/§3/§5 is always safe — each observes first (lookup_key / url+metadata / metadata) and
  **stops on API failure** instead of treating it as "absent".

## Teardown (observe-first, resumable)  💥

> Test mode only unless a human explicitly gates live. **The metadata guard is enforced, not
> claimed**: every object is fetched and checked (`owned()` — `managed_by=ephemera` +
> `source=payments.stripe.md`) before anything touches it; the only subscription ever canceled is the
> **ledgered `SUBID`** from §4's verify purchase — never a list-by-price sweep (in live mode that
> would be mass-canceling real customers). Stripe's asymmetry, **pinned live 2026-07-02**: **prices
> can never be deleted** (archive only); **products with user-created prices can NEVER be deleted
> either** — even with every price archived the API answers *"cannot be deleted because it has one or
> more user-created prices"* — archive is the terminal state for the whole catalog; **archive order
> matters**: archiving a price that is the `default_price` of an **active** product 400s, so archive
> the **product first, then its price** (inline `product_data` creates set exactly that default);
> **portal configs deactivate — except the account default**: the first API-created configuration
> becomes `is_default: true` and Stripe refuses `active:false` on it (it stays active, costs nothing,
> delivers nothing by itself — the sweep and the ✔ exempt it); only webhook endpoints truly delete.
> Account-level things (tax settings, keys) are borrowed — never touched. Fresh-shell safe: §0
> defines `S`/`price_by_key`/`owned`; `CUS`/`SUBID` come from Live State.

```bash
# 💥 the ledgered verify-subscription + verify-customer (from Live State §4) — and ONLY those.
# Identity IS the guard here: §4 wrote these exact ids; there is deliberately no list-by-price sweep.
[ -n "${SUBID:-}" ] && S -X DELETE "https://api.stripe.com/v1/subscriptions/${SUBID}" >/dev/null && echo "canceled $SUBID"
[ -n "${CUS:-}" ] && S -X DELETE "https://api.stripe.com/v1/customers/${CUS}" >/dev/null && echo "deleted $CUS"

# 💥 catalog: archive price, then delete-else-archive its product — guard enforced per object
for K in "${ENV}-${TIER1_NAME}" "${ENV}-${TIER2_NAME}" "${ENV}-${ITEM_NAME}"; do
  P="$(price_by_key "$K")" || { echo "observe failed (rc=$?) — stopping (resume by re-running)"; exit 1; }
  [ -z "$P" ] && { echo "$K already inert"; continue; }
  R="$(owned "prices/${P}")" || { echo "observe failed (rc=$?) — stopping (resume by re-running)"; exit 1; }
  [ "$R" = yes ] || { echo "SKIP $P — not ephemera-owned"; continue; }
  PROD="$(S "https://api.stripe.com/v1/prices/${P}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["product"])')"
  R="$(owned "products/${PROD}")" || { echo "observe failed (rc=$?) — stopping (resume by re-running)"; exit 1; }
  # ⚠ ORDER PROVEN LIVE: product FIRST, then price — archiving a price that is the default_price
  # of an active product 400s. No delete attempt: products with user-created prices never delete.
  [ "$R" = yes ] && S "https://api.stripe.com/v1/products/${PROD}" -d active=false >/dev/null && echo "archived product $PROD"
  S "https://api.stripe.com/v1/prices/${P}" -d active=false >/dev/null && echo "archived price $P"
done

# 💥 webhook endpoints + portal configs — observed by metadata source AND environment (this env's
# objects only — dev teardown must never touch stg's). Lists are captured first so an API failure
# stops loudly; a `for X in $(failing-cmd)` would just silently skip the sweep.
WE_LIST="$(S -G https://api.stripe.com/v1/webhook_endpoints -d limit=100 | PLAN_SOURCE="$PLAN_SOURCE" ENV="$ENV" python3 -c '
import sys,json,os
j=json.load(sys.stdin)
if j["has_more"]: sys.exit(2)
m=lambda e:(e.get("metadata") or {})
[print(e["id"]) for e in j["data"] if m(e).get("source")==os.environ["PLAN_SOURCE"] and m(e).get("environment")==os.environ["ENV"]]')" \
  || { echo "observe failed (rc=$?) — stopping (resume by re-running)"; exit 1; }
for WE in $WE_LIST; do
  S -X DELETE "https://api.stripe.com/v1/webhook_endpoints/${WE}" >/dev/null && echo "deleted $WE"; done
BPC_LIST="$(S -G https://api.stripe.com/v1/billing_portal/configurations -d active=true -d limit=100 | PLAN_SOURCE="$PLAN_SOURCE" ENV="$ENV" python3 -c '
import sys,json,os
j=json.load(sys.stdin)
if j["has_more"]: sys.exit(2)
m=lambda c:(c.get("metadata") or {})
mine=[c for c in j["data"] if m(c).get("source")==os.environ["PLAN_SOURCE"] and m(c).get("environment")==os.environ["ENV"]]
[print(c["id"]) for c in mine if not c.get("is_default")]
[print("leaving account-default active (Stripe refuses):", c["id"], file=sys.stderr) for c in mine if c.get("is_default")]')" \
  || { echo "observe failed (rc=$?) — stopping (resume by re-running)"; exit 1; }
for B in $BPC_LIST; do
  S "https://api.stripe.com/v1/billing_portal/configurations/${B}" -d active=false >/dev/null && echo "deactivated $B"; done
security delete-generic-password -s "stripe-whsec-${ENV}" >/dev/null 2>&1 || true
```
```bash
# ✔ teardown verify — asserted, fail-closed: zero active prices per key, no owned endpoints,
# no active owned portal configs
for K in "${ENV}-${TIER1_NAME}" "${ENV}-${TIER2_NAME}" "${ENV}-${ITEM_NAME}"; do
  S -G https://api.stripe.com/v1/prices -d active=true -d "lookup_keys[]=$K" | K="$K" python3 -c '
import sys,json,os; d=json.load(sys.stdin)["data"]; print(os.environ["K"], "->", len(d), "active"); sys.exit(1 if d else 0)' || exit 1
done
S -G https://api.stripe.com/v1/webhook_endpoints -d limit=100 | PLAN_SOURCE="$PLAN_SOURCE" ENV="$ENV" python3 -c '
import sys,json,os
j=json.load(sys.stdin)
if j["has_more"]: sys.exit(2)
m=lambda e:(e.get("metadata") or {})
hits=[e["id"] for e in j["data"] if m(e).get("source")==os.environ["PLAN_SOURCE"] and m(e).get("environment")==os.environ["ENV"]]
print("owned endpoints (this env):", hits); sys.exit(1 if hits else 0)' || exit 1
S -G https://api.stripe.com/v1/billing_portal/configurations -d active=true -d limit=100 | PLAN_SOURCE="$PLAN_SOURCE" ENV="$ENV" python3 -c '
import sys,json,os
j=json.load(sys.stdin)
if j["has_more"]: sys.exit(2)
m=lambda c:(c.get("metadata") or {})
hits=[(c["id"], bool(c.get("is_default"))) for c in j["data"] if m(c).get("source")==os.environ["PLAN_SOURCE"] and m(c).get("environment")==os.environ["ENV"]]
nondefault=[i for i,dflt in hits if not dflt]
print("active owned portal configs:", hits, "— non-default (must be zero):", nondefault)
sys.exit(1 if nondefault else 0)' || exit 1   # account-default is exempt: Stripe refuses active:false on it (proven)
# verify-customer really gone (if Live State ledgered one). ⚠ ORDER MATTERS: the final echo below
# must stay LAST — with CUS unset this test leaves status 1, which the echo absorbs.
[ -n "${CUS:-}" ] && { S "https://api.stripe.com/v1/customers/${CUS}" | python3 -c '
import sys,json; d=json.load(sys.stdin); print("customer:", d["id"], "deleted:", d.get("deleted", False))
sys.exit(0 if d.get("deleted") else 1)' || exit 1; }
echo "teardown verified: inert"
```
> Archived objects still *exist* (Stripe keeps financial history immutable — that's compliance, not
> failed teardown); `status: gone-inert` means **inert**: nothing purchasable, nothing delivering.
> Dashboard "delete all test data" is the console-only full wipe — noted, not scripted.
> → Live State: `status: gone-inert`, clear realized ids, keep the archive list for audit.

## Deliberately not included

- **The storefront / webhook consumer app** — the consumer of this capability (`service.*` plans).
- **Refunds, disputes, payouts** — operations on money in flight, not infrastructure; separate intent.
- **Stripe Connect / marketplaces, metered & usage-based billing, coupons/promotions** — real
  features, different plans.
- **Payment-method configuration** — Stripe's dashboard defaults (cards + wallets) are correct here.
- **Key issuance** — Dashboard-only (stated in §1); the plan consumes, never mints, credentials.
- **Live-mode operations beyond the §7 gate** — this plan's home is the test-mode sandbox.
