💳 Spoke 4 of 10 · MCP Server Ops

Wire Stripe Checkout Into Your MCP Server

Replace the fake URL with a real Stripe Checkout Session. Inline pricing, test cards, webhooks, and the official Stripe MCP server — complete runnable code.

stripe==15.1.0 Pinned SDK
2.9% + $0.30 Per Transaction
0 Raw Card Numbers
7 Common Mistakes

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
PCI compliance note: Because the hosted checkout page is served entirely by Stripe, your server never touches raw card data. You operate under SAQ A — the simplest PCI compliance tier. If you handled card data yourself, you'd need SAQ D, a full audit, and quarterly scans.

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:

bash
pip install stripe==15.1.0

Add to requirements.txt:

requirements.txt
stripe==15.1.0 fastmcp==2.3.4 fastapi==0.115.9
stripe-python requires Python ≥ 3.9. Vercel's default Python runtime satisfies this. Version 15.1.0 pins to Stripe API version 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 TypePrefixWhat It DoesWhere to Use
Test secret keysk_test_...Processes fake payments, no real money movesLocal dev + Vercel Preview
Live secret keysk_live_...Real money — never expose in code or logsVercel Production only
Test publishable keypk_test_...Browser-side Stripe Elements onlyFrontend JS (not your MCP server)
Live publishable keypk_live_...Safe for production frontendsFrontend 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

  1. Open your project in the Vercel Dashboard
  2. Go to Settings → Environment Variables
  3. Add STRIPE_SECRET_KEY with value sk_test_... for Preview/Development
  4. Add the same key with value sk_live_... scoped to Production
  5. Redeploy — Vercel does not hot-reload env vars on running instances

Loading the key in your server

server.py
import os import stripe # Square brackets raise KeyError immediately if the variable is missing. # This is correct — fail fast at startup, not mid-request. stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
Use 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.

server.py — complete initiate_checkout tool
import os import time import stripe from fastmcp import FastMCP stripe.api_key = os.environ["STRIPE_SECRET_KEY"] mcp = FastMCP("product-server") @mcp.tool() async def initiate_checkout( product_name: str, price_cents: int, quantity: int = 1, product_id: str = "", success_url: str = "https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}", cancel_url: str = "https://yoursite.com/cancel", ) -> dict: """ Create a Stripe Checkout Session and return the hosted payment URL. Args: product_name: Human-readable name shown on the checkout page. price_cents: Price in smallest currency unit (cents for USD). E.g., 4999 = $49.99. Must be a positive integer. quantity: Number of units. Defaults to 1. product_id: Internal ID stored in metadata for fulfillment. success_url: Where Stripe redirects after payment. {CHECKOUT_SESSION_ID} is replaced by Stripe with the real session ID. cancel_url: Where Stripe redirects if the customer clicks Back/Cancel. Returns: {"checkout_url": str, "session_id": str} on success. {"error": str} on failure. """ try: session = stripe.checkout.Session.create( mode="payment", line_items=[ { "price_data": { "currency": "usd", "unit_amount": price_cents, # integer cents "product_data": { "name": product_name, }, }, "quantity": quantity, } ], success_url=success_url, cancel_url=cancel_url, metadata={ "product_id": product_id, "source": "mcp_server", }, expires_at=int(time.time()) + 1800, # 30-minute session ) return {"checkout_url": session.url, "session_id": session.id} except stripe.CardError as e: return {"error": f"Card declined: {e.user_message}"} except stripe.InvalidRequestError as e: return {"error": f"Invalid request: {e.user_message}"} except stripe.AuthenticationError: return {"error": "Stripe authentication failed — check STRIPE_SECRET_KEY"} except stripe.APIConnectionError: return {"error": "Could not reach Stripe — check network connectivity"} except stripe.RateLimitError: return {"error": "Stripe rate limit hit — retry in a few seconds"} except stripe.StripeError as e: return {"error": f"Stripe error: {str(e)}"}
session.url is the string 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

FieldRequired?Notes
modeYes"payment" for one-time; "subscription" if recurring
line_itemsYesArray; max 100 items in payment mode
price_data.currencyYesISO 4217 lowercase, e.g. "usd"
price_data.unit_amountYesInteger, smallest unit (cents). 4999 = $49.99
price_data.product_data.nameYesDisplayed on checkout page to customer
success_urlYesStripe redirects here after payment; use {CHECKOUT_SESSION_ID} placeholder
cancel_urlRecommendedStripe shows Back button redirecting here on cancel
metadataOptionalKey-value pairs, up to 50 keys; stored on the Session and visible in Dashboard
expires_atOptionalEpoch seconds; min 30 min, max 24 hr; defaults to 24 hours
customer_emailOptionalPre-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.

ApproachHow It WorksWhen to Use
Inline pricing
price_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 ID
price: "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)

server.py — switching to Price ID
# Replace price_data with a stored Price ID: line_items=[ { "price": "price_1ABCDEFGHIJklmn", # from Stripe Dashboard "quantity": 1, } ]

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 NumberBrandWhat It Triggers
4242 4242 4242 4242Visa✓ Successful payment
5555 5555 5555 4444Mastercard✓ Successful payment
3782 8224 6310 005Amex✓ Successful payment (4-digit CVC)
4000 0000 0000 0002Visa✗ Generic decline (card_declined)
4000 0000 0000 9995Visa✗ Decline: insufficient funds
4000 0000 0000 0069Visa✗ Decline: expired card
4000 0000 0000 0127Visa✗ Decline: incorrect CVC
4000 0025 0000 3155Visa⚠ Requires 3D Secure authentication
4100 0000 0000 0019Visa✗ Blocked by Radar (highest fraud risk)

Verifying a completed session in the Dashboard

  1. Open dashboard.stripe.com with Test mode toggled on (top-right toggle)
  2. Go to Payments → All transactions
  3. Find your test charge — status should read Succeeded
  4. Click the charge → scroll to Payment metadata to confirm your product_id and source: mcp_server appear
  5. For the full Session object: Checkout → Sessions in the left nav

Stripe CLI: local webhook forwarding

bash
# Install: https://docs.stripe.com/stripe-cli stripe login # Forward Stripe events to your local server stripe listen --forward-to localhost:8000/webhook/stripe # Output: Your webhook signing secret is whsec_test_51... # In a second terminal — fire a synthetic event to test your handler stripe trigger checkout.session.completed
The CLI-generated 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_url pattern
  • 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

server.py — POST /webhook/stripe
import os import stripe from fastapi import FastAPI, Request, HTTPException app = FastAPI() STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] @app.post("/webhook/stripe") async def stripe_webhook(request: Request): # Must read raw bytes — NEVER use await request.json() here. # Re-parsing JSON changes byte representation and breaks HMAC verification. payload = await request.body() sig_header = request.headers.get("stripe-signature") if not sig_header: raise HTTPException(status_code=400, detail="Missing stripe-signature header") try: event = stripe.Webhook.construct_event( payload=payload, sig_header=sig_header, secret=STRIPE_WEBHOOK_SECRET, ) except ValueError: # Invalid payload bytes raise HTTPException(status_code=400, detail="Invalid payload") except stripe.SignatureVerificationError: # Signature mismatch — possible forgery attempt raise HTTPException(status_code=400, detail="Invalid signature") if event["type"] == "checkout.session.completed": session = event["data"]["object"] product_id = session.get("metadata", {}).get("product_id", "") session_id = session["id"] amount_total = session["amount_total"] # in cents customer_email = session.get("customer_details", {}).get("email", "") # TODO: check idempotency (has this session already been fulfilled?) # TODO: provision access, update database, trigger shipment print( f"✓ Payment confirmed: session={session_id}, " f"product={product_id}, amount={amount_total}¢, email={customer_email}" ) elif event["type"] == "checkout.session.async_payment_succeeded": # ACH debit, SEPA, and other delayed payment methods session = event["data"]["object"] # handle same as completed # Return 200 immediately — Stripe retries any non-2xx response return {"status": "ok"}
Handle both 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

Local Dev

stripe listen generates a temporary secret

stripe listen --forward-to localhost:8000/webhook/stripe # Prints: Your webhook signing secret is whsec_test_51... export STRIPE_WEBHOOK_SECRET=whsec_test_51...

This secret is only valid while stripe listen is running. Do not commit it. Use a .env file for local development.

Production

Register your endpoint in the Stripe Dashboard

  1. Stripe Dashboard → Developers → Webhooks → Add endpoint
  2. Enter your URL: https://your-app.vercel.app/webhook/stripe
  3. Select events: check checkout.session.completed
  4. Also check checkout.session.async_payment_succeeded
  5. Copy the Signing secret (whsec_live_...)
  6. 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 GroupSample Tools
Account / Balanceget_stripe_account_info, retrieve_balance
Customerscreate_customer, list_customers, update_customer
Products / Pricescreate_product, list_products, create_price, create_payment_link
Paymentslist_payment_intents, charges.list
Invoicescreate_invoice, finalize_invoice, list_invoices
Subscriptionslist_subscriptions, cancel_subscription, update_subscription
Refunds / Disputescreate_refund, list_disputes, update_dispute
Knowledgesearch_stripe_documentation, fetch_stripe_resources

Connecting via Claude Desktop or Cursor

claude_desktop_config.json — remote (OAuth, no key in config)
{ "mcpServers": { "stripe": { "url": "https://mcp.stripe.com" } } }
bash — local (npx, API key required)
npx -y @stripe/mcp@latest --api-key=$STRIPE_SECRET_KEY

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
The two approaches are complementary, not competing. Use 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.

# WRONG — breaks signature verification every time payload = await request.json() # CORRECT — raw bytes required payload = await request.body()

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.

# WRONG — Stripe rejects floats "unit_amount": 49.99 # CORRECT — always cents as an integer "unit_amount": 4999 # $49.99 # Safe conversion from float dollars unit_amount = int(round(price_dollars * 100))

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.

# Local development (.env) STRIPE_WEBHOOK_SECRET=whsec_test_51... # from stripe listen output # Production (Vercel env var) STRIPE_WEBHOOK_SECRET=whsec_live_... # from Dashboard → Developers → Webhooks

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).

# WRONG — mode is required stripe.checkout.Session.create(line_items=[...]) # CORRECT stripe.checkout.Session.create(mode="payment", line_items=[...])

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.

# WRONG — unhandled exception returns 500 session = stripe.checkout.Session.create(...) return {"checkout_url": session.url} # CORRECT — catch and return structured error try: session = stripe.checkout.Session.create(...) return {"checkout_url": session.url} except stripe.StripeError as e: return {"error": str(e)}

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.

# WRONG — localhost breaks in production success_url="http://localhost:3000/success" # CORRECT — use environment variable BASE_URL = os.environ.get("APP_BASE_URL", "https://yourapp.vercel.app") success_url=f"{BASE_URL}/success?session_id={{CHECKOUT_SESSION_ID}}"

From Zero to Live Payments in 5 Steps

  1. 1
    Install and configure the SDK

    Run pip install stripe==15.1.0 and add it to requirements.txt. Set STRIPE_SECRET_KEY in Vercel Settings → Environment Variables (Preview: sk_test_..., Production: sk_live_...). In server.py: stripe.api_key = os.environ["STRIPE_SECRET_KEY"]. Verify with python -c "import stripe; print(stripe.__version__)" → should print 15.1.0.

  2. 2
    Replace the stub with stripe.checkout.Session.create

    Update initiate_checkout to call Session.create with mode="payment", a line_items array using price_data (inline pricing), success_url, cancel_url, and metadata. Return {"checkout_url": session.url, "session_id": session.id}. Wrap the entire call in try/except stripe.StripeError and return {"error": ...} on failure.

  3. 3
    Test end-to-end in Stripe sandbox

    Set STRIPE_SECRET_KEY to your sk_test_... key locally. Call initiate_checkout from your MCP client, open the returned URL, and pay using card 4242 4242 4242 4242 (any future expiry, any CVC). Confirm the payment shows Succeeded in Stripe Dashboard under Test mode → Payments.

  4. 4
    Add and test the webhook endpoint

    Add POST /webhook/stripe to your FastAPI app. Read raw bytes with await request.body(), call stripe.Webhook.construct_event, and handle checkout.session.completed. Run stripe listen --forward-to localhost:8000/webhook/stripe in a second terminal, complete a test checkout, and verify your handler logs the event with correct metadata. Also test with stripe trigger checkout.session.completed.

  5. 5
    Deploy and promote to live mode

    Register your production webhook URL in Stripe Dashboard → Developers → Webhooks, selecting checkout.session.completed and checkout.session.async_payment_succeeded. Copy the live signing secret to STRIPE_WEBHOOK_SECRET in Vercel. Update STRIPE_SECRET_KEY to sk_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_amount is an integer in the smallest currency unit. For USD, that is cents. Pass 2500 for $25.00. Never pass 25.0 (float) or 25 assuming dollars — 25 would create a $0.25 charge. Use int(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 — use await request.body() in FastAPI, never await request.json(); (2) you are using the stripe listen local 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_url redirects in the customer's browser — if they close the tab before the redirect fires, your fulfillment code never runs. The authoritative signal is the checkout.session.completed webhook event, sent server-to-server by Stripe directly to your /webhook/stripe endpoint, 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. Using price_data with product_data in your line_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.com is 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 build initiate_checkout yourself with stripe.checkout.Session.create.
  • How do I switch between test mode and live mode on Vercel?
    Set STRIPE_SECRET_KEY to sk_test_... in Vercel's Preview environment and sk_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 key stripe.api_key is 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. Pass success_url="https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}" and Stripe redirects to https://yoursite.com/success?session_id=cs_test_51.... Use it if your success page needs to look up the session to display order details via stripe.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.