DEV Community

Cover image for Stripe Made card.last4 Nullable. If Your Receipts Assume It's Always There, They Break on Nobody's Schedule.
DriftSignal
DriftSignal

Posted on Originally published at driftsignal.io

Stripe Made card.last4 Nullable. If Your Receipts Assume It's Always There, They Break on Nobody's Schedule.

In Stripe's 2026-03-25.dahlia API version, a set of card fields you have almost certainly read at some point changed from always present to nullable. card.last4, card.brand, card.exp_month, card.exp_year, and card.funding all went from a guaranteed value to a value that can now come back null.

If you have ever written Visa ending in ${card.last4} on a receipt, or branched on card.funding to treat credit and debit differently, or built expiry logic on card.exp_month and card.exp_year, this change is aimed at your code. And it will not announce itself. Stripe still returns a 200. The charge object is still valid. The field you depend on is just sometimes empty now, and your code that assumed it was always there keeps running as if nothing changed.

This is the nastiest category of API change, worse in a specific way than a field being removed outright. A removed field is at least consistent: it is gone on every response, so you notice fast. A field that becomes nullable is present almost all the time and absent only on certain transactions. It sails through your tests, which use ordinary cards that populate every field, and it fails in production on the one payment method or edge case where the value is missing.

What actually changed

Here are the specific card fields on PaymentAttemptRecord.payment_method_details.card and PaymentRecord.payment_method_details.card that Stripe changed in the 2026-03-25.dahlia version:

  • card.brand: was an enum, now enum or null
  • card.last4: was a string, now string or null
  • card.exp_month: was an integer, now integer or null
  • card.exp_year: was an integer, now integer or null
  • card.funding: was an enum of credit, debit, prepaid, or unknown, now that enum or null
  • card.moto: was a boolean, now boolean or null

None of these is a rename. None of them throws. The type widened to include null, which in a dynamically typed language means absolutely nothing happens at the call site until the value actually is null. Then card.last4.slice(-4) throws a TypeError deep in your rendering code, or worse, card.funding === 'credit' quietly evaluates false for a card that should have matched, and your credit surcharge logic silently stops applying.

Why a nullable field is a silent failure and not a loud one

The whole reason this class of change is dangerous is that the failure is conditional and rare at first. Consider what your test suite looks like. You test with Stripe's standard test cards, which populate last4, brand, exp_month, and the rest on every response. Your tests pass. You ship. Everything works for the overwhelming majority of live traffic, because most real cards populate these fields too.

Then a payment comes in through a method or in a state where Stripe returns the card block with last4 as null, and the code path that has run correctly ten thousand times throws on the ten-thousandth. Or it does not throw at all, and instead writes "Visa ending in null" onto a customer receipt, or files a transaction under the wrong funding type in your analytics. The gap between "this worked in every test and almost all of production" and "this is silently wrong on a slice of real payments" is exactly where nullable field changes live.

It is not just the card fields

The same 2026-03-25.dahlia release carried a second change in the same silent family. Every decimal_string field across the API changed type from a plain string to a vendored Decimal object. That affects fields like unit_amount_decimal and quantity_decimal on invoice items and prices, metric_tons on Climate orders, percent_ownership on accounts, and many more.

If your integration read price.unit_amount_decimal as a string and did string operations on it, or wrote it back as a string, that code now has the wrong type on both the read and the write path. It is a different mechanism from the nullable card fields, but the same outcome: the response still comes back with a 200, the field is still there, and your code that made an assumption about its shape is now quietly wrong.

This is contract drift, and Stripe is the careful case

Stripe is genuinely one of the most disciplined API providers in the world about versioning. Every change above sits behind a dated version, 2026-03-25.dahlia, that you explicitly opt into with the Stripe-Version header. There is a detailed changelog. There is a migration guide. Multiple versions run in parallel so you can move on your own schedule.

And it still comes down to the same thing every other provider's changes come down to: a live response that no longer matches the shape your code was written against, returned with a 200 that tells you nothing is wrong. This is API contract drift. The contract Stripe fulfills after you bump the version is not the contract your code assumed when you wrote it, and the difference is a field that used to always be there and now sometimes is not.

Reading the changelog is necessary and not sufficient. The changelog tells you card.last4 is now nullable. It does not tell you about the four places across your codebase, written by three different people over two years, that read card.last4 without a null check. Finding those is a separate job from reading the release note, and it is the job that actually protects you.

Contract testing helps at build time, but it validates your code against your assumptions about the response, and "this field is always present" is exactly the assumption that just became false. A test that constructs a card object with last4 populated will keep passing, because you wrote the test with the same assumption the code has. The gap between "the field is usually there" and "the field is guaranteed there" is the job of runtime monitoring that checks real responses, not fixtures.

What to actually do

Whether or not you have moved to 2026-03-25.dahlia yet, the defense is the same.

Validate live responses against a schema at the boundary, so a field that comes back null when your code needs a value fails loudly and immediately in your own system instead of flowing downstream as a "null" string on a receipt. Treat every field you read as nullable until proven otherwise, and add explicit handling for the empty case rather than assuming presence. Before you bump the pinned Stripe-Version, diff real responses from the old and new versions on the same objects, because the type and nullability changes are the ones that do not surface in a casual read of the diff. And keep checking on a schedule, because Stripe ships a new version roughly every month, and each one carries a few more of these.

The lesson outlives the version

card.last4 going nullable is one change in one version of one API. If you never read it, this specific change is not your problem. But the pattern is everyone's problem, and Stripe is the disciplined, well documented, version pinned best case. Even here, the change that will actually bite you is not the loud one on the changelog's front page. It is the field that used to be guaranteed and quietly became optional, that passes every test you have, and that fails on a slice of real payments at a time you did not choose. The only reliable way to know your integration still matches what the API actually returns is to check the live responses, not the release notes.

Frequently asked questions

What changed with Stripe's card fields in the 2026-03-25 API version?

In the 2026-03-25.dahlia version, several fields on the card payment method details object changed from always present to nullable. card.brand, card.last4, card.exp_month, card.exp_year, card.funding, and card.moto can now return null where they previously always held a value. These are type and nullability changes, not removals or renames, so requests still succeed with a 200 and the fields are still present on most responses.

Why is a field becoming nullable more dangerous than a field being removed?

A removed field is gone from every response, so integrations that depend on it fail consistently and get noticed quickly. A field that becomes nullable is present on most responses and absent only on certain transactions or payment methods. It passes test suites that use standard cards, ships to production, and works for the majority of traffic, then fails silently on the slice of real payments where the value is null. The rarity and conditionality are exactly what make it hard to catch.

Did Stripe also change decimal fields in this version?

Yes. The same 2026-03-25.dahlia release changed all decimal_string fields from a plain string type to a vendored Decimal object, affecting fields such as unit_amount_decimal and quantity_decimal on invoice items and prices, among many others. Code that read or wrote these fields as strings now has an incorrect type assumption on both paths, even though the response still returns successfully.

How do I protect my integration from nullable field changes?

Validate live responses against a schema at the boundary so a null where you expected a value fails loudly in your own system, treat fields as nullable and handle the empty case explicitly rather than assuming presence, and diff real responses between the old and new API versions before bumping your pinned Stripe-Version. Because Stripe ships a new version roughly monthly, ongoing runtime checking of real responses is more reliable than reading each changelog once.

Top comments (0)