Anthropic's terms of service require that API-backed products only serve users in regions where commercial API access is offered. The list lives at anthropic.com/supported-countries. Now I Get It! already screens cards through Stripe for ordinary fraud, but nothing in the stack actually enforced that the buyer's country was on Anthropic's list. Issue #241 fixed that.
The plan I almost shipped
The initial plan was much bigger than what we shipped. I'd assumed enforcement had to live at signup: a Cognito PreSignUp trigger, a new signup proxy Lambda to capture the client IP (PreSignUp can't see the source address), a rewire of the frontend to route signup through our API, a custom CloudFront origin request policy to forward the viewer country header, a shared geo-allowlist module that every blocking handler would import, and a country-select modal as a pre-check before Stripe redirect. Eleven tasks, about a week of work.
Review killed it in one sentence: "I'm not restricting where people can use the app -- only where they can buy credits. Delegate to Stripe." That was right, and it collapsed the entire plan.
What we actually built
Stripe Radar has a Value Lists API and a rule grammar that can reference those lists. The correct shape is: scrape Anthropic's page into a committed JSON of ISO alpha-2 codes, sync those codes into a Stripe Radar Value List on every deploy, and add a Dashboard rule that references the list. When a blocked card hits checkout, Stripe halts the payment before it reaches the card network -- no charge, nothing to refund, no partial-state webhook to reason about. Our only job is observation: log failed payments whose underlying charge has outcome.type == "blocked" into an audit table. Six tasks, no Cognito changes, no CloudFront changes, no new frontend.
Parsing Anthropic's page
The scraper is standard-library-only on purpose -- urllib for fetching, html.parser for the DOM walk -- to keep the deploy host dependency-free. The page has two sections labeled API and Claude.ai; we care about the API list because it governs the calls we make on behalf of the user. The parser tracks state across headings, collects list items only while inside the API section, and hard-fails if fewer than fifty countries come back (a layout-change tripwire) or if any extracted name fails to resolve against a vendored offline ISO mapping. The mapping has 291 entries covering every exact string Anthropic uses plus common aliases: "Brunei" and "Brunei Darussalam", "Czechia" and "Czech Republic", "Türkiye (Turkey)" with the parenthetical, "Ukraine (except Crimea, Donetsk, Kherson, Luhansk, and Zaporizhzhia regions)" stripped of the qualifier before lookup. 175 countries resolved on the first scrape.
Two subtle bugs
I wrote metadata.get("user_id", "") to extract the user from the payment intent metadata, deployed, fired a synthetic failure event, and watched the audit row land with an empty user. I read the handler three times and convinced myself it was fine. Then I pulled the Stripe SDK apart locally and found that the current StripeObject no longer implements dict.get(). It implements subscript access via __getitem__, and __getattr__ falls through with a KeyError-to-AttributeError dance. My hasattr(metadata, "get") check returned false, silently routing into the else: user_id = "" branch. The fix was to use the _get(obj, key, default) helper I'd already written elsewhere in the same file.
The second bug: Stripe's webhook payload for payment_intent.payment_failed doesn't include last_payment_error.outcome. The outcome object -- with type: blocked, reason: rule, rule: ssr_... -- lives on the Charge, not the PaymentIntent, and the webhook delivers the PaymentIntent. The handler has to fetch the Charge from Stripe's API via last_payment_error.charge before it can distinguish a Radar block from a generic issuer decline. One extra round-trip per failed payment, but it's the only way to filter out declines, 3DS failures, and processing errors from the audit trail.
Testing what you can't buy a card for
Stripe's built-in pm_card_riskLevelHighest test card exercises the webhook path but trips Stripe's default fraud rule, not our country rule -- the audit row records highest_risk_level, not rule. To prove the country rule specifically I needed a card from a non-allowlisted country, and Stripe doesn't publish test cards for sanctioned regions like RU, CN, or KP. The workaround was to test in reverse: delete us from the Stripe Value List via the API, create a normal US payment intent, watch Stripe reject it, then re-run the sync script to restore us (the committed JSON already has it, so the script is self-healing). The blocked intent came back with outcome.type=blocked, outcome.reason=rule, outcome.rule=ssr_... -- the exact rule id we just created. After the restore, a follow-up US payment intent succeeded immediately. Every code path now exercised against the live rule, not a mocked handler.
Lesson
Two runbook rewrites were my fault for writing from memory. I documented Dashboard menu names and a "description" field that didn't match the current UI, and wrote a rule expression using a NOT IN operator that Stripe's grammar doesn't support (you have to wrap IN in NOT (...)). When I can't verify UI copy or grammar from a doc I've already read, the right move is to say so and go verify, not write from memory.