How Buttondown's API versioning works

A deep dive into our date-based API versioning system, inspired by Stripe, and how it lets us ship breaking changes without breaking your integrations.

Justin Duke
Justin Duke
March 8, 2026
How Buttondown's API versioning works

Here's a request to our subscribers endpoint:

$ curl https://api.buttondown.com/v1/subscribers \
  -H "Authorization: Token your-api-key" \
  -H "X-API-Version: 2024-07-01"

{
    "results": [
        {
            "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08"
            "email": "jane@example.com",
            "subscriber_type": "regular",
        }
    ]
}

Now change the version header to 2026-01-01:

$ curl https://api.buttondown.com/v1/subscribers \
  -H "Authorization: Token your-api-key" \
  -H "X-API-Version: 2024-07-01"
  -H "X-API-Version: 2026-01-01"

{
    "results": [
        {
            "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08"
            "email": "jane@example.com",
            "subscriber_type": "regular",
            "id": "sub_7y2PGGvLXB3MU7oSQkRsCP"
            "email_address": "jane@example.com",
            "type": "regular",
            "creation_date": "2025-12-10T12:00:00",
            "metadata": {}
        }
    ]
}

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:

  1. A list of versions — date-stamped snapshots of the API's behavior
  2. Version resolution — figuring out which version a given request is associated with
  3. 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):

API_VERSION_TO_TRANSFORMATIONS = {
    "2024-07-01": [
        generate_migration("/v1/subscribers", "subscriber_type", "type"),
        generate_migration("/v1/subscribers", "email", "email_address"),
    ],
    "2024-08-01": [
        unship_included_and_excluded_tags.Migration(),
    ],
    "2024-12-30": [
        unship_is_comments_disabled.Migration(),
    ],
    "2025-01-02": [
        convert_tag_to_tag_id.Migration(),
    ],
    "2025-06-01": [
        convert_typeid_to_uuid.Migration(),
    ],
    "2026-04-01": [
        merge_automation_timing_into_actions.Migration(),
    ],
}

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:

  1. The X-API-Version header — if you pass one explicitly, that takes priority
  2. Your API key's pinned version — each key has a version set when it's created
  3. The system default — the latest stable version
# Pin a specific request to an older version
curl https://api.buttondown.com/v1/subscribers \
  -H "Authorization: Token your-api-key" \
  -H "X-API-Version: 2024-07-01"

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:

class Migration:
    def is_eligible(self, request):
        """Does this migration apply to this request?"""
        return request.path.startswith("/v1/emails")

    def transform_request(self, data):
        """Convert old client format → current internal format."""
        return data

    def transform_response(self, data):
        """Convert current internal format → old client format."""
        return data

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.

sequenceDiagram
    participant Client as Client (v2024-07-01)
    participant Ninja as Django Ninja
    participant Handler as Route Handler

    Client->>Ninja: {"subscriber_type": "regular"}
    Ninja->>Handler: {"type": "regular"}
    Handler->>Ninja: {"email_address": "hi@example.com", "type": "regular"}
    Ninja->>Client: {"email": "hi@example.com", "subscriber_type": "regular"}

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:

generate_migration("/v1/subscribers", "subscriber_type", "type")

This produces a migration that:

  • On requests to /v1/subscribers: renames subscriber_typetype in the request body
  • On responses from /v1/subscribers: renames typesubscriber_type in the response

It handles both single objects and paginated lists (the results array pattern) automatically:

def transform_response(self, data):
    if "results" in data:
        for result in data["results"]:
            if new_key in result:
                result[old_key] = result.pop(new_key)
    else:
        if new_key in data:
            data[old_key] = data.pop(new_key)
    return data

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:

# Old format (pre-2024-08-01)
{
    "subject": "My Newsletter",
    "included_tags": ["python", "django"],
    "excluded_tags": ["spam"]
}

# New format (2024-08-01+)
{
    "subject": "My Newsletter",
    "filters": {
        "predicate": "and",
        "groups": [],
        "filters": [
            {"field": "subscriber.tags", "operator": "contains", "value": "python"},
            {"field": "subscriber.tags", "operator": "contains", "value": "django"},
            {"field": "subscriber.tags", "operator": "not_contains", "value": "spam"}
        ]
    }
}

The migration handles this bidirectionally. On the request side, it converts old tag arrays into filter objects:

def transform_request(self, data):
    included_tags = data.get("included_tags", [])
    excluded_tags = data.get("excluded_tags", [])
    nouveau_filters = []
    for tag in included_tags:
        nouveau_filters.append(
            {"field": "subscriber.tags", "operator": "contains", "value": tag}
        )
    for tag in excluded_tags:
        nouveau_filters.append(
            {"field": "subscriber.tags", "operator": "not_contains", "value": tag}
        )
    if len(nouveau_filters) > 0:
        data["filters"] = {
            "filters": nouveau_filters,
            "predicate": "and",
            "groups": [],
        }
    del data["included_tags"]
    del data["excluded_tags"]
    return data

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:

class Migration:
    def is_eligible(self, request):
        return request.path.startswith("/v1")

    def transform_response(self, data):
        return self._convert_typeids_to_uuids(data)

    def _convert_typeids_to_uuids(self, data):
        if isinstance(data, dict):
            for key, value in data.items():
                if self._should_convert_field(key, value):
                    if converted_uuid := decode_type_id(value):
                        data[key] = str(converted_uuid)
                elif isinstance(value, list) and key.endswith("_ids"):
                    data[key] = normalize_ids(value)
                elif isinstance(value, dict | list):
                    data[key] = self._convert_typeids_to_uuids(value)
        elif isinstance(data, list):
            return [self._convert_typeids_to_uuids(item) for item in data]
        return data

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:

# Old format: timing lives at the top level
{
    "timing": {"time": "immediate"},
    "actions": [
        {"type": "send_email", "metadata": {...}}
    ]
}

# New format: timing lives inside each action
{
    "actions": [
        {"type": "send_email", "metadata": {...}, "timing": {"time": "immediate"}}
    ]
}

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:

generate_migration(
    "/v1/bulk_actions",
    "metadata.parameters.date",
    ["metadata.parameters.date__start", "metadata.parameters.date__end"],
    ignore_response=True,
)

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:

api = ButtondownAPI(
    parser=VersioningAwareParser(),
    renderer=VersioningAwareRenderer(),
)

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:

def delete_subscriber(request, id_or_email):
    api_version = determine_api_version(request)
    if api_version < "2024-09-30":
        # Old behavior: mark as unsubscribed
        subscriber = retrieve(request, id_or_email)
        unsubscribe.call(subscriber, unsubscribe.Source.API)
        return 204, None
    # New behavior: hard delete
    subscriber = retrieve(request, id_or_email)
    return delete(request, subscriber.id)

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:

response = client.get(
    "/v1/subscribers",
    headers={"HTTP_X_API_VERSION": "2024-07-01"},
)
# Response uses old field names
assert "subscriber_type" in response.json()["results"][0]
assert "email" in response.json()["results"][0]

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-01 client 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.

Buttondown is the last email platform you’ll switch to.
How Buttondown's API versioning works