惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

AWS News Blog
AWS News Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - Franky
大猫的无限游戏
大猫的无限游戏
Engineering at Meta
Engineering at Meta
T
Tailwind CSS Blog
T
The Blog of Author Tim Ferriss
L
LangChain Blog
Vercel News
Vercel News
N
Netflix TechBlog - Medium
Hacker News - Newest:
Hacker News - Newest: "LLM"
Spread Privacy
Spread Privacy
小众软件
小众软件
H
Help Net Security
The Last Watchdog
The Last Watchdog
Forbes - Security
Forbes - Security
WordPress大学
WordPress大学
Know Your Adversary
Know Your Adversary
Recent Commits to openclaw:main
Recent Commits to openclaw:main
H
Heimdal Security Blog
GbyAI
GbyAI
P
Privacy International News Feed
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
The Cloudflare Blog
爱范儿
爱范儿
V
V2EX
The Register - Security
The Register - Security
B
Blog RSS Feed
Apple Machine Learning Research
Apple Machine Learning Research
O
OpenAI News
Cisco Talos Blog
Cisco Talos Blog
Cloudbric
Cloudbric
Security Archives - TechRepublic
Security Archives - TechRepublic
S
Secure Thoughts
L
LINUX DO - 最新话题
Recorded Future
Recorded Future
P
Proofpoint News Feed
PCI Perspectives
PCI Perspectives
Hugging Face - Blog
Hugging Face - Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
T
Tor Project blog
Latest news
Latest news
Project Zero
Project Zero
月光博客
月光博客
F
Fortinet All Blogs
A
Arctic Wolf
博客园 - 【当耐特】
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Y
Y Combinator Blog
N
News and Events Feed by Topic

Buttondown's blog

Email could have been X.400 times better The physicists who convinced Fermilab to send Brazil's emails Better in-app previews Analytics 3.0 Subscriber ID variables Comments! Send latest premium action Automation filtering Free API subscribers Surveys in automations Reply to replies Labels for RSS feeds How Jeremy Singer-Vine curates curious datasets for readers 2023 (and what's next) Email vs web content Sort by engagement Better gift subscriptions How Andy Dehnart built a career reviewing television New email template Email-based automations Opt-in reply tracking Automatic alt text More social network integrations Sort by metadata Overlarge image warnings Automation tag actions Pause emails mid-flight Search tags and automations Gift via automations Subscriber-driving emails Programmatic webhooks Email page views Tag statistics Discord webhook formatting Automatic subscriber cleanup RSS subscriber count Weekly subscriber reports More list columns Customizable list views How Max Voltar turned a side gig into a trusted keyboard resource How Nick Disabato runs two newsletters from one design consultancy Made-for-you share images Automation improvements End-of-email surveys Filter by date Survey-triggered automations More automation functionality New webhooks How France Insider built a news service with paid subscribers Email as primary key How John Willshire unites two businesses in one newsletter Confirmation reminders Email churned subscribers Email-to-draft Subscriber metadata columns ChatGPT integration Faster web archives Referral program Better search results TikTok embeds Subscriber timeline Spotify embeds Improved RSS-to-email Subscribe page OG image New analytics page Google Tag Manager Even more subscriber types Integrating Duda with Buttondown Linktree integration guide Advanced and enterprise plans Framer integration guide API requests page Team collaboration In-email surveys Better CSS settings Better RSS automation fetching! Editor toolbar improvements Smart filters Faster emails page RSS automations Faster email analytics Zapier error codes Image accessibility checks Tags vs newsletters OG image picker Image editor improvements API bulk actions Improved OpenAPI spec Mastodon support Better subscriber filtering Better subscriber validation Hotkey support! Programmatic access to analytics Stronger bulk actions Faster archive page Custom canonical URLs Email slug and metadata Improved writing interface Generating a Typescript router in Django Filter emails by source
How Buttondown's API versioning works
Justin Duke · 2026-03-08 · via Buttondown's blog

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.