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:
- READ this whole page first — it captures ~3 hours of audit time across the 11 May 2026 session.
- Run
~/.copilot/scripts/stripe-weekly.pybefore any code change. It surfaces current state in 30 seconds. - Anything that touches
checkout.tsis SLA-PROTECTED — follow the guided-repo SLA protocol (guided/.github/copilot-instructions.md). - 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.
- Radar Rules CANNOT be edited via API. Dashboard-only. Don't waste cycles trying.
- 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 dataGET /v1/customers— buyer recordsGET /v1/checkout/sessions— session details, custom_text, payment_method_typesGET /v1/payment_method_configurations— see what's enabled in each PMCGET /v1/radar/value_lists— read fraud blocklists (email, IP, card_fingerprint, card_bin, etc.)GET /v1/reviews— pending Radar reviewsGET /v1/balance— account balance, currencyGET /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 methodpayment_method_options.<method>.<option>— fine-tune behaviour per methodcustom_text.{submit,after_submit,terms_of_service_acceptance,shipping_address}.message— page copycustom_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:
- 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) - Don't ban via code — use Stripe Radar value list
client_ip_address_blocklist(idrsl_1IgKEcIXZo4phfVPrJ9u3DtP) - API call to add (requires explicit Sush approval each time):
- Document the addition in
incident-log.mdwith 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'plusKo-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 |
Related files & references¶
- 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