Why a Fake URL Is a Dead End
A FastMCP initiate_checkout tool that returns a hardcoded or constructed URL sidesteps everything Stripe does for you: hosted payment UI, card validation, 3D Secure, fraud scoring, receipt email, and PCI scope isolation.
Your server never needs to handle raw card numbers. Stripe Checkout gives the customer a Stripe-hosted page, manages the full payment lifecycle, and signals completion via a signed webhook event. The only code your server runs:
- Create the Checkout Session
- Return the hosted URL to the AI agent
- Handle the webhook when payment clears
Stripe charges 2.9% + $0.30 per successful domestic US card transaction, no monthly fee, no setup cost. Radar fraud protection is bundled. For a $50 sale, Stripe takes $1.75 — the remaining $48.25 settles to your bank on a rolling 2-day basis once your account is verified.
Install, Pin, and Configure Keys
Installation and version pinning
Pin to an exact major version to prevent silent breaking API changes. Stripe releases a new major version for every new API version, and major bumps can change method signatures:
Add to requirements.txt:
2026-04-22.dahlia. Use stripe~=15.0 if you want minor-version flexibility without crossing a major bump.
Test keys vs live keys
| Key Type | Prefix | What It Does | Where to Use |
|---|---|---|---|
| Test secret key | sk_test_... | Processes fake payments, no real money moves | Local dev + Vercel Preview |
| Live secret key | sk_live_... | Real money — never expose in code or logs | Vercel Production only |
| Test publishable key | pk_test_... | Browser-side Stripe Elements only | Frontend JS (not your MCP server) |
| Live publishable key | pk_live_... | Safe for production frontends | Frontend JS only |
Your MCP server only needs the secret key. The publishable key is only needed if you render Stripe Elements in a browser.
Setting STRIPE_SECRET_KEY on Vercel
- Open your project in the Vercel Dashboard
- Go to Settings → Environment Variables
- Add
STRIPE_SECRET_KEYwith valuesk_test_...for Preview/Development - Add the same key with value
sk_live_...scoped to Production - Redeploy — Vercel does not hot-reload env vars on running instances
Loading the key in your server
os.environ["KEY"], not os.environ.get("KEY"). The .get() form silently sets stripe.api_key = None if the variable is missing, causing opaque AuthenticationError on every API call. Square brackets fail immediately with a KeyError at startup — much easier to debug.
The initiate_checkout Replacement
Below is a complete drop-in replacement using FastMCP. It calls stripe.checkout.Session.create with inline pricing (no pre-created Price object required), returns the hosted checkout URL, and wraps every API call in structured error handling.
https://checkout.stripe.com/pay/cs_test_... — return it directly to the AI agent. The agent surfaces it to the user as a clickable link to complete payment.
Checkout Session fields reference
| Field | Required? | Notes |
|---|---|---|
mode | Yes | "payment" for one-time; "subscription" if recurring |
line_items | Yes | Array; max 100 items in payment mode |
price_data.currency | Yes | ISO 4217 lowercase, e.g. "usd" |
price_data.unit_amount | Yes | Integer, smallest unit (cents). 4999 = $49.99 |
price_data.product_data.name | Yes | Displayed on checkout page to customer |
success_url | Yes | Stripe redirects here after payment; use {CHECKOUT_SESSION_ID} placeholder |
cancel_url | Recommended | Stripe shows Back button redirecting here on cancel |
metadata | Optional | Key-value pairs, up to 50 keys; stored on the Session and visible in Dashboard |
expires_at | Optional | Epoch seconds; min 30 min, max 24 hr; defaults to 24 hours |
customer_email | Optional | Pre-fills email field on checkout page |
Price IDs vs Inline Pricing
Stripe supports two ways to specify what the customer pays. Most FastMCP servers doing agentic payments should use inline pricing by default.
| Approach | How It Works | When to Use |
|---|---|---|
Inline pricingprice_data |
Pass currency, amount, and name in the API call. Stripe creates an ephemeral price for that session (archived, not reusable). | Dynamic catalog from your DB, per-seat billing, one-time amounts, tip jars, prices not known at deploy time |
Price IDprice: "price_1ABC..." |
Reference a price pre-created in Stripe Dashboard or via API. Reusable, auditable, appears in your Stripe product catalog. | Fixed SKU catalog, subscriptions (required), stable prices you manage in Stripe, when you want Stripe revenue reporting per-product |
Using a Price ID (when your catalog stabilizes)
Use Inline Pricing When…
- Product name and price come from a database query
- Prices change per-user or per-session
- You're building a one-time payment tool for dynamic products
- The catalog isn't managed in Stripe Dashboard
- You don't need per-product Stripe revenue reporting
Use Price IDs When…
- You have a fixed product catalog managed in Stripe
- You need subscriptions or recurring billing
- You want Stripe to be the source of truth for prices
- You need Stripe's revenue reporting per SKU
- Multiple servers or agents reference the same price
Test Mode: Cards, Dashboard, and the Stripe CLI
Test card numbers
Use any future expiry date (e.g. 12/34), any 3-digit CVC (4 digits for Amex), and any 5-digit billing ZIP. These cards only work when your API key starts with sk_test_.
| Card Number | Brand | What It Triggers |
|---|---|---|
4242 4242 4242 4242 | Visa | ✓ Successful payment |
5555 5555 5555 4444 | Mastercard | ✓ Successful payment |
3782 8224 6310 005 | Amex | ✓ Successful payment (4-digit CVC) |
4000 0000 0000 0002 | Visa | ✗ Generic decline (card_declined) |
4000 0000 0000 9995 | Visa | ✗ Decline: insufficient funds |
4000 0000 0000 0069 | Visa | ✗ Decline: expired card |
4000 0000 0000 0127 | Visa | ✗ Decline: incorrect CVC |
4000 0025 0000 3155 | Visa | ⚠ Requires 3D Secure authentication |
4100 0000 0000 0019 | Visa | ✗ Blocked by Radar (highest fraud risk) |
Verifying a completed session in the Dashboard
- Open dashboard.stripe.com with Test mode toggled on (top-right toggle)
- Go to Payments → All transactions
- Find your test charge — status should read Succeeded
- Click the charge → scroll to Payment metadata to confirm your
product_idandsource: mcp_serverappear - For the full Session object: Checkout → Sessions in the left nav
Stripe CLI: local webhook forwarding
whsec_test_... secret is only valid while stripe listen is running. It is different from your Dashboard webhook signing secret. Use separate environment variables for local and production — see the Webhooks section below.
Webhooks: Reliable Order Fulfillment
Why the checkout URL alone is not enough
success_url redirects in the customer's browser after payment. It is not a reliable payment signal:
- The user may close the tab before the redirect fires
- The redirect URL can be constructed by anyone who knows your
success_urlpattern - Network errors can prevent the redirect entirely
The authoritative signal is the checkout.session.completed webhook event — Stripe sends it server-to-server, signed with a secret only you and Stripe share, independently of the browser redirect.
Full working FastAPI webhook handler
checkout.session.completed AND checkout.session.async_payment_succeeded. ACH debit, SEPA, and other delayed payment methods don't fire completed with payment_status=paid immediately. The session will be open / payment_status=unpaid until the bank settles. If you only listen to completed, you'll miss fulfillment for delayed methods.
Getting your webhook signing secret
stripe listen generates a temporary secret
This secret is only valid while stripe listen is running. Do not commit it. Use a .env file for local development.
Register your endpoint in the Stripe Dashboard
- Stripe Dashboard → Developers → Webhooks → Add endpoint
- Enter your URL:
https://your-app.vercel.app/webhook/stripe - Select events: check checkout.session.completed
- Also check checkout.session.async_payment_succeeded
- Copy the Signing secret (
whsec_live_...) - Add it to Vercel as
STRIPE_WEBHOOK_SECRET
mcp.stripe.com — When to Use It Instead
Stripe operates a remote MCP server at https://mcp.stripe.com. It is a separate product from the payment flow you are building — it is an account-management interface for AI agents and code editors.
What it exposes
| Resource Group | Sample Tools |
|---|---|
| Account / Balance | get_stripe_account_info, retrieve_balance |
| Customers | create_customer, list_customers, update_customer |
| Products / Prices | create_product, list_products, create_price, create_payment_link |
| Payments | list_payment_intents, charges.list |
| Invoices | create_invoice, finalize_invoice, list_invoices |
| Subscriptions | list_subscriptions, cancel_subscription, update_subscription |
| Refunds / Disputes | create_refund, list_disputes, update_dispute |
| Knowledge | search_stripe_documentation, fetch_stripe_resources |
Connecting via Claude Desktop or Cursor
Use mcp.stripe.com When…
- An AI agent needs to manage your Stripe account interactively
- You want to list customers, issue refunds, look up subscriptions in Cursor/Claude
- You need to query Stripe documentation from within an agent conversation
- You're building internal admin tooling with AI assistance
Build Your Own Tool When…
- Real users (or agents acting for them) are paying through your server
- You need custom business logic before creating the session
- You need inventory checks, discount rules, or metadata from your own DB
- mcp.stripe.com has no
initiate_checkout— that's your tool
mcp.stripe.com in your development environment to manage your Stripe account. Use your own initiate_checkout tool in the server you ship to users. You can run both simultaneously.
7 Common Mistakes That Break Stripe Integration
1Reading request.json() in the webhook handler
Any JSON parsing or body transformation invalidates the HMAC signature. stripe.Webhook.construct_event computes an HMAC over the exact bytes Stripe sent — re-parsing normalizes whitespace and produces a different byte string.
2Passing unit_amount as a float instead of integer cents
unit_amount must be a non-negative integer in the smallest currency unit. Passing 9.99 or 999.0 causes stripe.InvalidRequestError immediately.
3Fulfilling the order in the success_url handler
success_url fires in the customer's browser after redirect. If they close the tab or lose connectivity mid-redirect, your fulfillment never runs. Only fulfill inside the checkout.session.completed webhook handler.
4Using the wrong webhook secret (CLI vs Dashboard)
The Dashboard webhook signing secret (whsec_...) is different from the whsec_test_... printed by stripe listen during local development. Using the wrong one causes all events to fail SignatureVerificationError.
5Omitting the mode parameter
mode is required. Omitting it raises stripe.InvalidRequestError immediately. Valid values: "payment" (one-time), "subscription" (recurring), or "setup" (save payment method for later).
6No exception handling — tool crashes mid-conversation
Unhandled StripeError raises an exception that returns a raw 500 to the MCP client. The calling agent needs a parseable error dict it can relay to the user ("payment failed, try a different card") rather than a traceback.
7Hardcoding success_url and cancel_url as localhost
Works locally but silently breaks in production. Stripe validates that success_url is a reachable HTTPS URL in live mode. Use environment variables for both.
From Zero to Live Payments in 5 Steps
-
1Install and configure the SDK
Run
pip install stripe==15.1.0and add it torequirements.txt. SetSTRIPE_SECRET_KEYin Vercel Settings → Environment Variables (Preview:sk_test_..., Production:sk_live_...). Inserver.py:stripe.api_key = os.environ["STRIPE_SECRET_KEY"]. Verify withpython -c "import stripe; print(stripe.__version__)"→ should print15.1.0. -
2Replace the stub with stripe.checkout.Session.create
Update
initiate_checkoutto callSession.createwithmode="payment", aline_itemsarray usingprice_data(inline pricing),success_url,cancel_url, andmetadata. Return{"checkout_url": session.url, "session_id": session.id}. Wrap the entire call intry/except stripe.StripeErrorand return{"error": ...}on failure. -
3Test end-to-end in Stripe sandbox
Set
STRIPE_SECRET_KEYto yoursk_test_...key locally. Callinitiate_checkoutfrom your MCP client, open the returned URL, and pay using card4242 4242 4242 4242(any future expiry, any CVC). Confirm the payment shows Succeeded in Stripe Dashboard under Test mode → Payments. -
4Add and test the webhook endpoint
Add
POST /webhook/stripeto your FastAPI app. Read raw bytes withawait request.body(), callstripe.Webhook.construct_event, and handlecheckout.session.completed. Runstripe listen --forward-to localhost:8000/webhook/stripein a second terminal, complete a test checkout, and verify your handler logs the event with correct metadata. Also test withstripe trigger checkout.session.completed. -
5Deploy and promote to live mode
Register your production webhook URL in Stripe Dashboard → Developers → Webhooks, selecting
checkout.session.completedandcheckout.session.async_payment_succeeded. Copy the live signing secret toSTRIPE_WEBHOOK_SECRETin Vercel. UpdateSTRIPE_SECRET_KEYtosk_live_...in Vercel Production. Redeploy and confirm with one real transaction in the live-mode Dashboard under Payments.
Frequently Asked Questions
-
What does Stripe charge per transaction?
Standard pricing for US card transactions is 2.9% + $0.30 per successful charge, no monthly fee, no setup cost. International cards add 1.5%; currency conversion adds 1%; disputes cost $15 each. Radar fraud protection is bundled. For a $50 sale, Stripe takes $1.75. Re-verify at stripe.com/pricing before launch — pricing can change. -
What is unit_amount and what value do I pass for $25.00?
unit_amountis an integer in the smallest currency unit. For USD, that is cents. Pass2500for $25.00. Never pass25.0(float) or25assuming dollars —25would create a $0.25 charge. Useint(round(price_dollars * 100))to convert safely. -
Why is my webhook signature verification failing?
The most common causes: (1) you are reading a parsed or decoded body — useawait request.body()in FastAPI, neverawait request.json(); (2) you are using thestripe listenlocal secret in production or vice versa; (3) a reverse proxy or middleware is modifying the request body before it reaches your handler. Ensure your webhook endpoint receives the raw payload unmodified. -
Can I use success_url to trigger order fulfillment?
No.success_urlredirects in the customer's browser — if they close the tab before the redirect fires, your fulfillment code never runs. The authoritative signal is thecheckout.session.completedwebhook event, sent server-to-server by Stripe directly to your/webhook/stripeendpoint, independently of the browser redirect. Use the success page only for showing a "thank you" message. -
Do I need to create a Product or Price in the Stripe Dashboard first?
No. Usingprice_datawithproduct_datain yourline_items, Stripe creates an ephemeral product and price automatically for that session. You only need a pre-created Price ID if you want a stable catalog entry reused across multiple sessions, need Stripe revenue reporting per product, or are implementing subscriptions (which require Price IDs with recurring intervals). -
What is mcp.stripe.com and should I use it instead of the Stripe SDK?
mcp.stripe.comis Stripe's official remote MCP server for account management — listing customers, creating invoices, issuing refunds, querying Stripe docs. It is useful in development for managing your Stripe account via Claude or Cursor. It does not replace the SDK-based payment flow. There is no tool in the official Stripe MCP server that creates a Checkout Session and returns a payment URL — you buildinitiate_checkoutyourself withstripe.checkout.Session.create. -
How do I switch between test mode and live mode on Vercel?
SetSTRIPE_SECRET_KEYtosk_test_...in Vercel's Preview environment andsk_live_...in Production. In Vercel Dashboard: Settings → Environment Variables, add the variable twice with different values scoped to each environment. Vercel injects the correct value at runtime based on which deployment is running. No code change required — the SDK uses whatever keystripe.api_keyis set to. -
What is the {CHECKOUT_SESSION_ID} placeholder in success_url?
It is a Stripe template placeholder replaced with the actual session ID at redirect time. Passsuccess_url="https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}"and Stripe redirects tohttps://yoursite.com/success?session_id=cs_test_51.... Use it if your success page needs to look up the session to display order details viastripe.checkout.Session.retrieve(session_id). If your success page just shows a thank-you message and you handle fulfillment in the webhook, you don't need it.
MCP Server Ops Series — All Guides
Get the Full MCP Server Ops Playbook
New guides on auth, monitoring, multi-tool servers, and production ops — delivered when they ship.
✓ You're in — watch your inbox.