← all writing
Tech By Jesse Moraga · Oct 11, 2026 · 3 min read

API Versioning Without Breaking Customers: Date-Pinned Versions Explained

Pin a version to the account, let a header override it, and ship everything non-breaking in between.

Every API I've shipped has had the same bad day waiting for it. You rename a field, you tighten a response, and somewhere a customer's integration that nobody has touched in a year quietly stops working. They don't email you a bug report. They just stop trusting the thing.

Stripe solved this a long time ago and honestly the answer is less clever than people expect.

  ACCOUNT DEFAULT                 PER-REQUEST OVERRIDE
  ---------------                 --------------------
  acct_A -> 2023-08-16            Stripe-Version: 2024-11-20
  acct_B -> 2024-11-20                    |
  acct_C -> 2024-11-20                    v
        |                         +-----------------+
        +-----------------------> |  version router |
                                  +-----------------+
                                           |
                     +---------------------+---------------------+
                     v                                           v
            OLD SHAPE (frozen)                          CURRENT SHAPE
            { customer_name: ... }                      { customer: { name } }
                     ^                                           ^
                     |                                           |
            ADDITIVE CHANGES FLOW INTO BOTH: new fields, new endpoints,
            new enum values, new optional params. No version bump needed.

Look at the bottom row. That's the part people miss: most releases never touch the router at all.

The mechanism is three pieces. One, every account carries a version, a date, set the first time they call you. Two, a request header can override it for that one call, which is how a developer tests the new shape without migrating their whole production app on a Tuesday. Three, and this is the actual work, you write down what "breaking" means before you need the definition. Stripe's versioning docs spell out their line: adding a field, adding an endpoint, adding an optional parameter, those are non-breaking and they ship to everybody. Removing or renaming a field is breaking and it gets a date.

That written-down list is the whole trick. Once you have it, versioning is almost boring, because ninety percent of your releases fall on the additive side and the router never sees them.

In my own company's back office, quoting and invoicing and the field update flow all hang off internal endpoints, and I used to just change the shapes whenever I wanted because, hey, I'm the only consumer. Then the phone assistant started reading from those same endpoints, and then a client system at Art3ry did too, and suddenly "I'm the only consumer" was a lie I was telling myself. So I wrote the compatibility policy first. Additive always, renames never without a date, removed fields keep answering with their old value until the old date is drained. The nice side effect is my changelog basically writes itself, because anything that needs a date is the only thing worth announcing.

The common practice I'll push back on: semantic versioning in the URL path. /v1/, /v2/. It sounds tidy and it is terrible, because v2 is a cliff. You end up maintaining two full codebases and begging people to jump, and the ones who don't jump are usually your oldest, best-paying customers. Dates give you a ramp instead. Small steps, each one documented, each one skippable.

And yeah, you carry some ugliness for it. There's a transform layer that knows how old shapes looked, and it grows. I'm fine with that. I'd rather maintain a shim than an apology.

Backward compatibility is a policy you write down first, and a header second.

Jesse

growth-as-a-service

I build with AI so small businesses can take on the giants. Let me build yours.

Visit Art3ry → art3ry.com