Here's a request to our subscribers endpoint:
Now change the version header to 2026-01-01:
Voila! This is our API versioning system at work. Your integration never breaks because we never change what your version of the API looks like. Every breaking change we ship is invisible to clients that haven't opted in.
Our approach is heavily inspired by Stripe's API versioning. To make this work, we need three things:
- A list of versions — date-stamped snapshots of the API's behavior
- Version resolution — figuring out which version a given request is associated with
- Transformations — defining how to convert requests and responses between versions
Let's walk through each one.
Date-based versions, not semver
We use date-based version strings (e.g. 2025-06-01) instead of semantic versioning. Each version represents a snapshot of the API's behavior on that date. When we introduce a breaking change, we create a new version date and define transformations that let older clients keep working as if nothing changed.
Here's what our version registry looks like (simplified):
Each entry maps a version date to a list of migrations — transformations that bridge the gap between that version and the next.
How your version gets determined
When you make an API request, we figure out which version to use in this order:
- The
X-API-Versionheader — if you pass one explicitly, that takes priority - Your API key's pinned version — each key has a version set when it's created
- The system default — the latest stable version
This means you can test a newer version on a single request without changing your key's default. Useful for incremental migration.
The transformation pipeline
The real magic is in how transformations get applied. Every migration implements three methods:
When an older client makes a request, we apply all migrations from their version forward. The request body gets transformed up to the current format before hitting our route handlers, and the response gets transformed back down to the client's expected format before serialization.
The client never knows the internal schema changed. Our route handlers only deal with the latest format.
Simple migrations: field renames
Most breaking changes are field renames. We have a helper that generates the migration for you:
This produces a migration that:
- On requests to
/v1/subscribers: renamessubscriber_type→typein the request body - On responses from
/v1/subscribers: renamestype→subscriber_typein the response
It handles both single objects and paginated lists (the results array pattern) automatically:
Now let's walk through some examples.
Complex migrations: structural changes
Some changes aren't just renames — they restructure the data. For example, we moved from flat included_tags / excluded_tags fields to a more expressive filter system:
The migration handles this bidirectionally. On the request side, it converts old tag arrays into filter objects:
On the response side, it extracts tag filters back into the old flat format. Old clients never see the new filter structure.
ID format migrations
When we moved from plain UUIDs to TypeIDs (prefixed identifiers like sub_01h455vb4pex8n0mq3swfxkj0r), we needed a migration that could handle the change across every endpoint. The TypeID migration recursively walks the entire response tree:
Any field named id or ending in _id with a recognized TypeID prefix gets converted back to a plain UUID. List fields ending in _ids get batch-converted. This means old clients never encounter TypeIDs — they keep getting the UUIDs they expect.
Moving data between levels of nesting
Our most recent migration moved automation timing from a top-level field into individual actions:
The migration handles both directions: old clients sending top-level timing get it distributed into their actions, and responses strip timing from actions and hoist it back to the top level.
Nested key transformations
Sometimes the field you need to rename is buried inside a nested object. We have helpers for that too:
The dot-separated key path (metadata.parameters.date) gets traversed automatically. This particular migration also demonstrates splitting a single value into multiple fields — turning a date range value into explicit start and end parameters.
How it plugs into Django
(This section assumes you're familiar with Django Ninja's abstractions. If not, poke around the docs there!)
The versioning system hooks into Django Ninja's parser and renderer. The parser transforms incoming requests before they reach route handlers, and the renderer transforms outgoing responses before serialization:
This means versioning is mostly invisible to our route handlers — they work with the latest schema and don't worry about field names or response shapes.
The exception is when a version change alters behavior, not just data format. For example, we changed DELETE /v1/subscribers from "mark as unsubscribed" to "actually delete the record." No amount of request/response transformation can bridge that gap — the operation itself is fundamentally different. In cases like these, we do check the version directly in the route handler:
These cases are rare — the vast majority of version changes are pure data transformations that the middleware handles automatically. But when the semantics of an endpoint change, a version check in the handler is the right call.
Testing versioned behavior
Testing is straightforward — pass the X-API-Version header:
If you pass a version that doesn't exist or is newer than the system default, you'll get a 422 error with a clear message.
Why is this nice
A few things we like about this system:
- Route handlers stay relatively clean. The transformation layer handles version differences before requests reach endpoint logic and after responses leave it.
- Old versions work forever. We never remove transformations. If you pinned your integration to
2024-07-01, it still works exactly as it did on that date. - Migrations are composable. Each migration is a small, focused class. They chain together naturally — a request from a
2024-07-01client passes through every migration from that version to the present. - Testing is easy. You can test any version by passing a header. No special setup, no separate API instances.
But most of all, I'll end this with a boring but useful endorsement of this approach: we introduced it, as you might be able to tell from the timestamps, 18 months ago, and have not needed to change anything about it. We've been able to make fairly large changes to our broader API infrastructure — the TypeID migration, splitting out API access into multiple keys — without this being impacted at all.

