Guide · Stripe

Migrate stripe-python to StripeClient (v8 to v16)

stripe-python has two ways to call the API. The legacy one, stripe.api_key plus stripe.Customer.create, still works, but new endpoints are headed to StripeClient only. Since v15, Stripe objects are no longer dicts either.

Three releases matter here. 8.0.0 (January 25, 2024) introduced StripeClient and a service-based call pattern. 12.5.0 (August 27, 2025) copied every v1 service under client.v1, and 13.0.0 (September 30, 2025) deprecated the old client.customers style accessors. 15.0.0 (March 25, 2026) made StripeObject stop inheriting from dict, which breaks .get() on every response whichever call style you use. The current major is 16.0.0. Stripe's README says the legacy pattern will be marked deprecated, after which new API endpoints will only be reachable through StripeClient. This guide follows Stripe's own migration guides and changelog, linked at the end.

What changed

  • A StripeClient instance replaces the global stripe.api_key and other module settings.
  • Static resource methods become service methods: stripe.Customer.create(...) becomes client.v1.customers.create(...).
  • Request parameters go in a params dict and request options such as stripe_account go in an options dict.
  • modify() is called update() on services, and nested resources get their own services.
  • stripe.Webhook.construct_event becomes client.construct_event.
  • Code already on StripeClient must add .v1: client.customers is deprecated since 13.0.0.
  • From 15.0.0, StripeObject is not a dict: .get(), .keys() and dict(obj) no longer work, and decimal_string fields are Decimal.

Migration steps

  1. Upgrade and read the pinned API version

    pip install --upgrade stripe

    Each stripe-python major pins a Stripe API version: 15.0.0 pins 2026-03-25.dahlia and 16.0.0 pins 2026-09-30.endive. Upgrading the library can therefore change API behavior as well as Python syntax. Read the Stripe API changelog for each version you cross. 15.0.0 also dropped Python 3.7 and 3.8.

  2. Create a client instead of setting globals

    Before

    import stripe
    
    stripe.api_key = os.environ["STRIPE_SECRET_KEY"]
    stripe.max_network_retries = 3

    After

    from stripe import StripeClient
    
    client = StripeClient(
        os.environ["STRIPE_SECRET_KEY"],
        max_network_retries=3,
    )

    Former globals such as stripe_version, max_network_retries and proxy become keyword arguments on the constructor. Because there is no global state, you can run several clients with different keys side by side, and tests can pass a fake client instead of patching module functions.

  3. Move calls to services, with params and options

    Before

    customer = stripe.Customer.create(
        email="jenny.rosen@example.com",
        stripe_account="acct_123",
    )

    After

    customer = client.v1.customers.create(
        params={"email": "jenny.rosen@example.com"},
        options={"stripe_account": "acct_123"},
    )

    Resource methods mixed API parameters and request options in one set of keyword arguments. Services keep them apart: params for what goes to the API, options for api_key, stripe_account, stripe_version and idempotency_key.

  4. Rename modify to update, and move nested calls

    Before

    stripe.Customer.modify("cus_123", description="VIP")
    
    stripe.Customer.list_balance_transactions("cus_123", limit=3)

    After

    client.v1.customers.update("cus_123", params={"description": "VIP"})
    
    client.v1.customers.balance_transactions.list("cus_123", params={"limit": 3})

    Pass IDs positionally, as above. Since 16.0.0, path parameters such as the customer ID must be positional on service methods; passing them by keyword is no longer supported.

  5. Construct webhook events through the client

    Before

    event = stripe.Webhook.construct_event(payload, sig_header, secret)

    After

    event = client.construct_event(payload, sig_header, secret)
  6. Add .v1 to existing StripeClient code

    Before

    customers = client.customers.list()

    After

    customers = client.v1.customers.list()

    Stripe added a v2 namespace for its newer APIs, so the original APIs now live under v1. The top-level accessors still work as of 16.0.0, but they have been deprecated since 13.0.0, Stripe says they will be removed in a future release, and editors flag them.

  7. Stop treating Stripe objects as dicts

    Before

    name = customer.get("name", "unknown")
    data = dict(customer)
    for key in customer.keys():
        ...

    After

    name = getattr(customer, "name", "unknown")
    data = customer.to_dict()
    for key in customer.to_dict().keys():
        ...
    • Attribute access, customer["name"], "name" in customer and str(customer) still work.
    • For JSON, use str(obj) or obj.to_dict(for_json=True). to_dict() returns a copy; changing it does not change the object.
    • .items() no longer means dict.items(): on a subscription, subscription.items is the subscription's line items.
    • to_dict_recursive() is removed (use to_dict()), and stripe_id is removed (use id).
    • decimal_string fields such as unit_amount_decimal are Decimal now, so compare with Decimal("9.99"), not "9.99".

How to find every affected call

ast-grep finds the call styles that need to change, including calls split across lines:

sg run --pattern 'stripe.api_key = $KEY' --lang python
sg run --pattern 'stripe.$RESOURCE.create($$$ARGS)' --lang python
sg run --pattern 'stripe.$RESOURCE.modify($$$ARGS)' --lang python
sg run --pattern 'stripe.$NS.$RESOURCE.create($$$ARGS)' --lang python
sg run --pattern 'stripe.Webhook.construct_event($$$ARGS)' --lang python
sg run --pattern '$CLIENT.customers.$METHOD($$$ARGS)' --lang python

$RESOURCE matches one name, so stripe.checkout.Session.create needs the $NS pattern. Repeat the create and modify patterns for retrieve, list and delete, and the last pattern for each service you use. The dict-style reads from step 7 are harder: .get() on a Stripe object looks exactly like .get() on a dict. Search for .get(, .keys() and dict( on variables that hold Stripe responses, and rely on tests that exercise those code paths.

Testing the change

  • Run mypy or pyright. stripe-python ships type hints, and since 13.0.0 deprecated client.customers style calls are flagged in your editor.
  • Replace tests that patch stripe.Customer.create with a fake or mocked client passed into your code. stripe-mock, Stripe's mock API server, is useful for running real requests in CI.
  • Run your payment flows end to end in test mode with a test key, including one Connect call with stripe_account if you use Connect.
  • Replay webhooks with the Stripe CLI (stripe listen and stripe trigger) to confirm client.construct_event verifies signatures and your handlers read fields correctly.

Automating it

Stripe publishes @stripe/sdk-migrator, which uses ast-grep. For the v1 namespace change, preview with npx @stripe/sdk-migrator --language python --migration v1-namespace -d <path> and add --execute to apply; add --untyped for code without type hints. Stripe calls it best effort, especially in dynamic code. At the time of writing its README documents only the v1-namespace migration, so moving legacy stripe.Customer calls to StripeClient, and the v15 dict change, are manual.

Versyka handles the whole path: it finds call sites deterministically with ast-grep, rewrites them along with the code that reads the results, then runs your repository's own type check and pytest in an isolated virtualenv inside a disposable sandbox. It opens a pull request only if those pass.

Was this guide helpful?

Check your own repository

Paste your package.json, requirements.txt or pyproject.toml into the free upgrade check to see which API SDKs are behind, with no account. Or let Versyka run this migration on one repository and open a pull request that has already passed your tests.

Sources