On April 22, 2026, Stripe shipped the Dahlia API version. Among the changes was one that doesn't sound like much in the changelog and absolutely is in production: every field formatted as decimal_string — unit_amount_decimal, quantity_decimal, fx_rate, and friends — changed type in the SDKs.
In stripe-python, those fields used to be str. They are now decimal.Decimal.
In stripe-node, they used to be string. They are now Stripe.Decimal — a class Stripe ships inside the SDK package.
Same for stripe-dotnet, stripe-go, stripe-java, stripe-php, stripe-ruby. Every official SDK got the same reshape.
The wire format did not change. Stripe's HTTP responses still serialize these fields as JSON strings. The SDKs now parse them into a typed Decimal on the way in, and serialize them back to strings on the way out. From the docs:
The SDK handles conversion to and from the string wire format transparently.
That sentence does a lot of heavy lifting. It is true on the request and response side. It is not true in any code path between those two boundaries.
What This Quietly Breaks
If your code does any of the following with a decimal_string field, it broke when you bumped the SDK to a Dahlia-compatible version. None of these will throw at SDK install time, none will fail unit tests that mock Stripe with the old shape, and none will produce a clean stack trace pointing back at the change.
Equality checks against strings
# Before
if line_item.unit_amount_decimal == "1500":
apply_discount()
# After: line_item.unit_amount_decimal is Decimal('1500'), never == "1500"
Decimal("1500") == "1500" is False. Always. No coercion, no warning. The if branch stops firing. Whatever logic depended on it goes silently dead.
String operations
// Node — was: "1500.00".startsWith("1") → true
// After: price.unitAmountDecimal is Stripe.Decimal — no .startsWith()
if (price.unitAmountDecimal.startsWith("1")) { /* ... */ }
// TypeError: price.unitAmountDecimal.startsWith is not a function
# Python — was: "1500.00"[:2] → "15"
# After: Decimal('1500.00')[:2] → TypeError: 'decimal.Decimal' object is not subscriptable
prefix = line_item.unit_amount_decimal[:2]
Less-obvious variants: len(), .split('.'), regex matching. Anything that treated the field as a string.
JSON serialization
import json
json.dumps(stripe_response)
# TypeError: Object of type Decimal is not JSON serializable
This is the one that bites the hardest in practice. Backends that json.dumps() a Stripe object — to log it, to enqueue it on a job queue, to forward it to an analytics pipeline — start raising at the serialization boundary, not at the API call. The traceback points at the dumps line, not at Stripe.
In Node, JSON.stringify(price) doesn't throw, but it serializes the Decimal class via its toJSON (if defined) or its default object representation. Either way the output shape changes from "1500" to either "1500" (if toJSON returns a string), or {} / something object-shaped (if not). Downstream consumers that parse and re-validate may reject it.
Mixed-type arithmetic
# Python: was string, you'd cast first
total = sum(float(li.unit_amount_decimal) * li.quantity for li in items)
# Now li.unit_amount_decimal is Decimal, li.quantity is int — mixing Decimal*int is fine
# But: total + 0.01 (a float) → TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'
Decimal + float raises in Python. So the moment your accumulator picks up a float anywhere in the chain — a hardcoded 0.01 for rounding, a return value from another library — addition explodes. The location of the explosion can be far from the SDK boundary.
Database driver type expectations
Most drivers handle Decimal cleanly (psycopg2, asyncpg, the .NET SQL drivers, JDBC). Some don't, especially older or thin drivers, NoSQL clients, and ORMs that do their own coercion. If you've been passing unit_amount_decimal straight into a query parameter, you may now hit a different code path in the driver — one that's slower, or that converts to a different SQL type, or that silently truncates.
Logging and observability tools
Loggers that string-coerce values write "Decimal('1500.00')" to the log line, not "1500.00". Search queries against your log index for the price value miss. Sentry breadcrumbs change shape. Datadog APM spans tagged with the price string lose dashboarding continuity. Nothing breaks loudly; the searches just stop matching.
The Wire-Format Trap
Here is the silent-fail vector that has the highest blast radius.
Stripe webhooks deliver raw JSON. If your handler parses the request body itself and accesses fields directly — body["data"]["object"]["unit_amount_decimal"] — those fields are still strings. They came off the wire as strings, and you never went through the SDK's deserialization path.
Stripe SDK retrievals — stripe.Price.retrieve(...) or stripe.checkout.Session.retrieve(...) — return Decimal-typed fields. Same field name, same logical value, different runtime type.
So: in one part of your codebase, unit_amount_decimal is a string. In another, it's a Decimal. Both came from "Stripe." Both are correct. Whether the equality check, the comparison, the arithmetic, or the serialization works depends on which codepath produced the value.
The classic version of the bug is: webhook handler stores the raw payload's unit_amount_decimal in a database column, scheduled job retrieves the price via SDK and compares it to the stored value, comparison is always False, the job concludes the price changed, fires a re-pricing flow, customer gets re-charged.
The fix is not "always go through the SDK" or "always parse the raw body" — both have legitimate use cases. The fix is to normalize at the edge: if you're touching this field, decide whether it lives as a string or a Decimal in your domain model, convert immediately, and never let the unconverted form leak past the boundary.
Why The Migration Slipped
Same answers as every other silent type change.
The SDK's own changelog is short on it. The line in the release notes is "all decimal_string fields changed type from string to Decimal." Reading that fast, you assume it's a developer-experience win — typed values, better arithmetic. The downstream consequences for code that already treated those fields as strings aren't called out.
Types didn't catch it where you'd expect. In TypeScript/stripe-node, the type does change — string → Stripe.Decimal. If your code depends on string operations and you ran tsc, you'd see errors. But: a lot of code uses any at the boundary, especially in legacy projects. A lot of code marshals through JSON and back and loses the type entirely. And a lot of code is JavaScript. None of those cases get a compile error.
In Python, there are no compile-time types unless you've fully type-annotated your Stripe-touching code, which most teams haven't.
In Go, the SDK uses concrete struct types and the change is visible in the type signature — Go users get the cleanest signal of any language.
Tests against fixtures. If you have unit tests that mock Stripe responses with hand-rolled JSON — strings on unit_amount_decimal — those tests pass against your old SDK code (parses to string) and your new SDK code (parses string to Decimal, both still match the assertion if you also coerced), or they don't. Either way, they don't reflect what production does, because production actually goes through SDK deserialization.
Account-level API version pinning. Stripe lets you pin your account's default API version. If you pinned, the wire format won't move on you. But the SDK reshape happens regardless of the account default — it's a client-side behavior. Bumping stripe-python from 11.x to 12.x with the same account API version still flips your local types.
Dependabot. A Dependabot PR bumps your stripe dependency, your fixture-based tests pass, you merge, deploy, and ship the type flip into production. Same Dependabot story we wrote about for the Basil migration — different field reshape, same vector.
A Migration That Actually Holds
-
Pin the SDK in
package.json/requirements.txt/ equivalent. Don't let the major version float. The Dahlia-aware SDK majors are Python 12.x, Node 22.x, .NET 49.x — pin explicitly. -
Grep the codebase for the affected field names:
unit_amount_decimal,quantity_decimal,fx_rate, plus any custom field on a Stripe object that ends in_decimal. Note every reference. (grep -r "unit_amount_decimal" .is fine to start.) - Classify each reference: is it string-style (equality, slicing, length, JSON serialize) or numeric-style (arithmetic, comparison, sorting)? String-style is what breaks; numeric-style is what gets better.
-
Convert at the edge. Wherever a Stripe object enters your domain (after SDK call, after webhook parse), coerce all
_decimalfields to a single canonical type — eitherstrif your domain is string-typed, orDecimalif it's numeric-typed. Don't mix. -
Update test fixtures. If you mock Stripe with hand-rolled JSON, your fixtures need to round-trip through
stripe.util.convert_to_stripe_object(Python) or the equivalent SDK helper, so your tests see the post-deserialization shape, not the wire shape. -
Audit JSON serialization paths. Any place you
json.dumps()a Stripe object needs a Decimal-aware encoder (json.dumps(obj, default=str)is the quick fix; a customJSONEncoderis the right fix). - Stage behind a flag. Roll the SDK bump out incrementally — one service at a time, ideally one with low write volume first. Compare logs and outputs against the prior version for a few days before promoting.
How To See The Next One Coming
The reshape pattern — "we kept the field name, we kept the API version contract, we just changed the runtime type the SDK hands you" — is going to keep happening. Stripe is not the only vendor doing it. Every SDK that ships a vendored numeric type, datetime type, or money type can do this in a minor SDK release without bumping the API version, and it's almost always a real improvement.
The defense isn't "block SDK upgrades" — that runs out of road on security patches. The defense is to know which fields you depend on, what shape they have today, and to find out the moment that shape changes — before the deploy.
That's the gap FlareCanary plugs. Point it at the endpoints you depend on (Stripe price retrieve, subscription retrieve, customer retrieve) and it polls them, learns the response structure, and alerts when a field's type or shape moves — including SDK-side reshapes if you point it at SDK-deserialized output, and wire-format changes if you point it at raw HTTP. Removed fields and type flips are alerts; new optional fields are informational.
You don't strictly need a tool. You need a habit. Watch the runtime shape of every external response you depend on. The Dahlia decimal reshape is a textbook case of an upgrade that the type system in some languages catches, the type system in others doesn't, and the test suite in almost all of them misses entirely — because the test suite is checking the JSON shape, and the JSON shape didn't move.
The runtime shape did. That's the only thing that actually matters.
If you've been bit by the Dahlia type reshape — or by any silent SDK type flip on another API — drop a note. The "the wire didn't change but the SDK did" failures are the exact ones we hear about most.
Top comments (0)