Skip to content

Stripe Payment Playbook

For future Copilot sessions touching guided/functions/guided/api/checkout.ts, Stripe Radar, payment methods, or paid-product checkout flow.

Last full audit: 11 May 2026 — Sush + Claude Opus 4.7 (1M). Baseline ship: 11 May 2026 (commits cd9a155, 5af8cb7, e152949). Weekly driver: ~/.copilot/scripts/stripe-weekly.py (read-only, runs every Sunday session).


TL;DR for the next session

Before doing anything in Stripe land:

  1. READ this whole page first — it captures ~3 hours of audit time across the 11 May 2026 session.
  2. Run ~/.copilot/scripts/stripe-weekly.py before any code change. It surfaces current state in 30 seconds.
  3. Anything that touches checkout.ts is SLA-PROTECTED — follow the guided-repo SLA protocol (guided/.github/copilot-instructions.md).
  4. Pre-test every payload via direct Stripe API call before committing. Stripe's error messages are clear; failing in production is unacceptable for a paid product.
  5. Radar Rules CANNOT be edited via API. Dashboard-only. Don't waste cycles trying.
  6. Region-restricted methods (Cash App Pay, Klarna, Affirm) are NOT enableable for Sush's NZ-domiciled account. Confirmed at API level. Don't suggest them again unless he applies via Stripe support.

What's already shipped (11 May 2026 baseline)

Three sequential commits to functions/guided/api/checkout.ts:

Commit What changed Real effect
cd9a155 Removed payment_method_types, added request_three_d_secure: 'automatic' 3DS automatic landed. Payment methods: ⚠️ accidentally fell back to ['card'] (Stripe doesn't auto-attach a PMC).
5af8cb7 Corrected: explicit payment_method_types: ['card', 'link'] Adds standalone Link (saved bank/ACH option, ~0.8% fee vs ~2.9% card). Wallets (Apple/Google Pay) were ALREADY working under card.
e152949 Added custom_text.submit.message (personalised per cert) Trust copy "Instant access. 1 year of practice questions for {CERT}. No subscription, no auto-renewal." appears above Pay button.

Why all three: Sush asked to investigate a 32% Stripe failure rate. Investigation revealed most failures (~7 of 16) were fraud-testing clusters correctly blocked by Radar, NOT lost revenue. The remaining 9 were real-customer declines that more payment methods + 3DS could help. Wallets were already working — so the real new lift was Link standalone + 3DS liability shift + trust copy.

Verify the changes are still live:

$body = '{"productType":"cert","certCode":"az-900","vendorSlug":"microsoft"}'
$r = curl.exe -sf -X POST https://www.aguidetocloud.com/guided/api/checkout -H "Content-Type: application/json" -d $body
# Extract Stripe session, inspect:
$key = Get-Content "$env:USERPROFILE\.copilot\secrets\stripe-live-secret-key"
$s = Invoke-RestMethod -Uri "https://api.stripe.com/v1/checkout/sessions/<cs_id>" -Headers @{Authorization="Bearer $key"}
# Must show:
#   payment_method_types: card, link
#   payment_method_options.card.request_three_d_secure: automatic
#   custom_text.submit.message: "Instant access. 1 year ..."

If any of those are missing, something regressed. Investigate before doing new work.


What CAN be done programmatically (via API token at ~/.copilot/secrets/stripe-live-secret-key)

Read operations (safe, used by stripe-weekly.py)

  • GET /v1/charges — all charge data, decline reasons, wallet info, 3DS data
  • GET /v1/customers — buyer records
  • GET /v1/checkout/sessions — session details, custom_text, payment_method_types
  • GET /v1/payment_method_configurations — see what's enabled in each PMC
  • GET /v1/radar/value_lists — read fraud blocklists (email, IP, card_fingerprint, card_bin, etc.)
  • GET /v1/reviews — pending Radar reviews
  • GET /v1/balance — account balance, currency
  • GET /v1/account — branding, business profile, statement descriptor

Write operations (deliberate, require explicit Sush approval each time)

  • Add/remove IPs / emails / card fingerprints from Radar value lists (fraud blocking)
  • Create/update Payment Method Configurations
  • Expire test/dummy checkout sessions (cleanup after API testing)
  • Create/refund charges (NEVER do without explicit instruction)

Code-level changes to checkout.ts (always SLA-protected)

  • payment_method_types: [...] — add slug for any dashboard-enabled method
  • payment_method_options.<method>.<option> — fine-tune behaviour per method
  • custom_text.{submit,after_submit,terms_of_service_acceptance,shipping_address}.message — page copy
  • custom_fields: [...] — extra form fields (currently used for marketing_consent)
  • consent_collection.{terms_of_service,promotions} — explicit consent toggles

What CANNOT be done — don't try

Thing Why Confirmed how
Edit Radar Rules via API No public endpoint exists. /v1/radar/rules returns 404. Tested 11 May 2026
Enable Cash App Pay US-only, requires US-domiciled Stripe account. Sush is NZ. Stripe API rejected 'cashapp' in payment_method_types with invalid_request_error
Enable Klarna Not available for Sush's account/region. Plus minimum order amount (~$35) excludes $9 product. Same rejection error
Enable Affirm US-only, BNPL not available for our region. Same
Modify branding via session Branding is account-level (dashboard or /v1/account PATCH), not per-session. Already configured: peach logo, #f36973 primary, #25282f secondary, statement descriptor AGUIDETOCLOUD.COM
3DS-force ALL payments request_three_d_secure: 'any' would force 3DS on every card — would hurt conversion. Stick with 'automatic'. Stripe docs

The wallet discovery (do not over-promise this)

Apple Pay, Google Pay, and Link wallets work automatically under payment_method_types: ['card']. They are wallet OVERLAYS on card, not separate methods. The Stripe Checkout page renders them as buttons when the customer's device/browser supports them.

Evidence from 1 Apr → 11 May 2026 charge data (under old ['card'] config):

Wallet Transactions
Link 17
Apple Pay 5
Google Pay 3

If a future session sees payment_method_types: ['card'] and wonders "are we missing wallets?" — the answer is no, they're already working. The check is: pull recent charges and look at payment_method_details.card.wallet.type.

The thing that's NOT in ['card'] is standalone Link (Link as a separate payment option, customer pays from a saved bank account / ACH). That's why we added 'link' explicitly in commit 5af8cb7.


Payment Method Configurations — the confusing landscape

Sush's Stripe account has 4 PMCs (Payment Method Configurations) per the API:

ID Name is_default parent Methods enabled
pmc_1S6iyWIXZo4phfVPgckLUxW5 Billing Payments False pmc_1S5u8mExmLtWgK8gHSY58492 Apple/Google/card + EU
pmc_1PycdcIXZo4phfVPSIXQuNjr Billing Payments False (none) Apple/Google/card + EU
pmc_1NL4BtIXZo4phfVP6q0FfDUC Default True pmc_1NIva3ExmLtWgK8g5iWIzXCR Apple/Google/Link/card + bancontact, eps, giropay, ideal, p24
pmc_1KinlyIXZo4phfVPXeJRXQIZ Default True (none) Apple/Google/Link/card only

TWO are marked is_default=True — likely one is the Checkout-API default, one is the Payment-Links default. Same display name, confusingly.

The current checkout.ts does NOT reference any PMC (no payment_method_configuration param) — we use explicit payment_method_types: ['card', 'link'] instead. This is intentional because: 1. Explicit is auditable 2. PMC behaviour is opaque (which one Stripe picks isn't documented) 3. Empirical test: passing a specific PMC ID returned only card anyway

If a future session needs to expose EU methods (bancontact, iDEAL, etc.), the cleanest approach is: - Add to the explicit array: payment_method_types: ['card', 'link', 'ideal', 'bancontact', 'eps', 'giropay', 'p24'] - Currency matters: those methods only work in EUR for most. Pre-test with the actual price ID before committing.


Custom text on the Stripe checkout page — what's possible

stripe.checkout.sessions.create({ custom_text: { ... } }) accepts 4 spots:

Field Where it shows Char limit Currently used?
submit.message Above the Pay button 1200 ✅ Yes — trust copy per cert (commit e152949)
after_submit.message On Stripe's success page after they pay 1200 ❌ Not yet — could add "Check your email" / "Refresh /guided/{cert}/ to start"
terms_of_service_acceptance.message Replaces default ToS legal text if consent_collection.terms_of_service = 'required' 1200 ❌ Not used
shipping_address.message Above shipping address form 1200 ❌ N/A — we don't collect shipping

Branding is dashboard-level, not per-session. Already configured for Sush — don't try to override per-session, you'll get errors.


Pre-test pattern — MANDATORY before committing checkout.ts changes

The protocol that prevented breakage on 11 May 2026:

# Step 1: build the EXACT payload you intend to ship — as a Stripe API form-encoded body
$key = Get-Content "$env:USERPROFILE\.copilot\secrets\stripe-live-secret-key"
$priceId = (Invoke-RestMethod -Uri "https://api.stripe.com/v1/prices?limit=1&active=true" -Headers @{Authorization="Bearer $key"}).data[0].id

$body = @{
  mode = 'payment'
  'line_items[0][price]' = $priceId
  'line_items[0][quantity]' = 1
  'payment_method_types[0]' = 'card'
  'payment_method_types[1]' = 'link'
  'payment_method_options[card][request_three_d_secure]' = 'automatic'
  'custom_text[submit][message]' = 'Your exact proposed message here.'
  success_url = 'https://www.aguidetocloud.com/x'
  cancel_url = 'https://www.aguidetocloud.com/y'
}
$bodyStr = ($body.GetEnumerator() | ForEach-Object { "$([uri]::EscapeDataString($_.Key))=$([uri]::EscapeDataString($_.Value))" }) -join '&'

# Step 2: hit the live Stripe API
try {
  $t = Invoke-RestMethod -Uri "https://api.stripe.com/v1/checkout/sessions" -Method Post -Headers @{Authorization="Bearer $key"} -Body $bodyStr -ContentType "application/x-www-form-urlencoded"
  "✅ Stripe accepted. Response field values:"
  $t | Select-Object id, payment_method_types, payment_method_options, custom_text
  # Step 3: clean up (don't leave dummy sessions polluting the dashboard)
  Invoke-RestMethod -Uri "https://api.stripe.com/v1/checkout/sessions/$($t.id)/expire" -Method Post -Headers @{Authorization="Bearer $key"} | Out-Null
  "✅ Test session expired."
} catch {
  "❌ Stripe REJECTED — fix before committing:"
  $_.ErrorDetails.Message
}

If Stripe accepts the payload via this dry-run, then committing the equivalent code is safe. If it rejects, fix the payload first.


Fraud cluster handling — Radar value lists

If stripe-weekly.py flags a fraud-testing cluster (≥3 declines within 5 minutes), the right response:

  1. Read the failed charges to extract the source IP (charge.outcome.network_status, plus look at the request_log_url in Stripe Dashboard for IP)
  2. Don't ban via code — use Stripe Radar value list client_ip_address_blocklist (id rsl_1IgKEcIXZo4phfVPrJ9u3DtP)
  3. API call to add (requires explicit Sush approval each time):
    POST /v1/radar/value_lists/rsl_1IgKEcIXZo4phfVPrJ9u3DtP/items
    value=<ip>
    
  4. Document the addition in incident-log.md with date + reason + IP

Existing fraud blocks (as of 11 May 2026): - 3 emails in email_blocklist (rsl_1IgKEcIXZo4phfVPGorcUwr6) - 3 card fingerprints in card_fingerprint_blocklist (rsl_1IgKEcIXZo4phfVPBsGiTJqw) - 0 IPs blocked

Don't bulk-add IPs without evidence — Stripe Radar already correctly blocks the highest-risk attempts. Manual blocks are for repeat patterns the rules miss.


Webhook handling — knows about

functions/guided/api/webhook.ts processes Stripe webhooks for checkout.session.completed. Never touch the webhook signature validation — it uses STRIPE_WEBHOOK_SECRET (Cloudflare env var, DANGER ZONE re: PATCH-wipe). If a future change needs new webhook events, read the file end-to-end before adding to the switch statement.

Webhook events we handle (as of 11 May 2026): - checkout.session.completed → grants access via KV record + notification

Events we DON'T handle yet (potential future work): - customer.subscription.created — would matter if we ever do subscriptions (we don't) - charge.dispute.created — would be nice to know about chargebacks; not urgent at our volume


Account-level facts (for grounding)

  • Country: NZ-domiciled Stripe account (acct_1IgKEcIXZo4phfVP)
  • Base currency: NZD (but most charges are USD because pricing is USD)
  • Display name on receipts: "A Guide To Cloud"
  • Statement descriptor: AGUIDETOCLOUD.COM
  • Brand colours: #f36973 (peach) primary, #25282f (slate) secondary
  • Logo: Uploaded as Stripe file file_1TPulEIXZo4phfVPd8iFtW8P
  • Ko-fi connection: Ko-fi tip transactions appear as metadata['Creator Stripe Id']='acct_1IgKEcIXZo4phfVP' plus Ko-fi Transaction Id (PageId). The stripe-weekly.py script filters these out as a separate category — they're not practice-exam revenue.

Common pitfalls to avoid

Pitfall What happens Mitigation
Calling Stripe Radar Rules API 404 Don't try; rules are dashboard-only
Forgetting payment_method_types is array of strings TS error SDK auto-completion catches it
Putting URLs in custom_text.message Stripe rejects as "potentially unsafe" Use plain text only; link from your own site copy if needed
Committing without API pre-test Risk of customer-facing breakage on a paid product Run the pre-test snippet above
Skipping the SLA smoke test Drift since the May 30 + May 9 incidents Always run all 3 curl checks pre + post-deploy
Using PATCH on Cloudflare env vars Can silently wipe STRIPE_SECRET_KEY → checkout breaks Read guided/.github/copilot-instructions.md § Cloudflare API DANGER ZONE first
Disabling 3DS automatic to "fix" something Likely breaks fraud liability shift; you'll get more chargebacks If 3DS conversion is a problem, downgrade to 'any_3ds_required' instead, never to off
Bulk-adding emails/IPs to blocklists Stripe Radar treats this as a signal; over-aggressive lists hurt your account's risk score Add 1-2 entries with documented reasons, then trust Radar's automated rules

  • Code: C:\ssClawy\guided\functions\guided\api\checkout.ts — the checkout endpoint
  • Code: C:\ssClawy\guided\functions\guided\api\webhook.ts — payment webhook handler
  • Weekly driver: ~/.copilot/scripts/stripe-weekly.py — read-only health + revenue report
  • Self-reminders: ~/.copilot/copilot-instructions.md § "GSC + Stripe Ownership Rules"
  • Incident history: incident-log.md § Stripe-related (May 1 secret-wipe, May 3 click-flow, May 9 rate-limit storm)
  • Pricing decision (2026-05): learning-docs/docs/playground/guided/pricing-decision-2026-05.md — why $59/$149 tiers were retired, $9 single-cert is the only SKU

How to extend this playbook

When something new is learnt about Stripe (a method gets activated, an incident happens, a new pattern emerges): 1. Add a row to the relevant table above (don't create a new section unless the topic is genuinely new) 2. Tag the section with (updated: YYYY-MM-DD) so future sessions know what's fresh 3. If the learning was driven by an incident, ALSO add an entry to incident-log.md 4. If the learning was driven by automation discovery, ALSO update ~/.copilot/copilot-instructions.md § "GSC + Stripe Ownership Rules" so the trigger fires automatically next session