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

推荐订阅源

Cloudbric
Cloudbric
Schneier on Security
Schneier on Security
V2EX - 技术
V2EX - 技术
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
O
OpenAI News
S
Security @ Cisco Blogs
Scott Helme
Scott Helme
Security Archives - TechRepublic
Security Archives - TechRepublic
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
WordPress大学
WordPress大学
云风的 BLOG
云风的 BLOG
T
Threatpost
Hacker News: Ask HN
Hacker News: Ask HN
Microsoft Azure Blog
Microsoft Azure Blog
Know Your Adversary
Know Your Adversary
博客园 - 三生石上(FineUI控件)
A
About on SuperTechFans
Forbes - Security
Forbes - Security
NISL@THU
NISL@THU
Security Latest
Security Latest
G
Google Developers Blog
D
Docker
T
Threat Research - Cisco Blogs
N
Netflix TechBlog - Medium
C
CERT Recently Published Vulnerability Notes
H
Help Net Security
B
Blog
Martin Fowler
Martin Fowler
N
News and Events Feed by Topic
Simon Willison's Weblog
Simon Willison's Weblog
Hacker News - Newest:
Hacker News - Newest: "LLM"
L
Lohrmann on Cybersecurity
Y
Y Combinator Blog
PCI Perspectives
PCI Perspectives
F
Fortinet All Blogs
MyScale Blog
MyScale Blog
Project Zero
Project Zero
爱范儿
爱范儿
Cisco Talos Blog
Cisco Talos Blog
博客园 - 聂微东
Hugging Face - Blog
Hugging Face - Blog
人人都是产品经理
人人都是产品经理
V
Vulnerabilities – Threatpost
P
Proofpoint News Feed
Cyberwarzone
Cyberwarzone
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
TaoSecurity Blog
TaoSecurity Blog
N
News | PayPal Newsroom
Recorded Future
Recorded Future

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 we migrated to TypeIDs without breaking clients
Tanvir Raj · 2026-03-01 · via Buttondown's blog

From the very beginning, Buttondown has used UUIDs as primary keys. Every subscriber, every email, they all get an ID like 550e8400-e29b-41d4-a716-446655440000.

UUIDs work fine as database keys, but they're opaque. If you're building on our API and you get back 550e8400-e29b-41d4-a716-446655440000, there's nothing telling you whether that's a subscriber, an email, a tag, or a newsletter. You have to track that context yourself — in your code, your logs, your error messages. Multiply that across every ID in every response, and it gets noisy fast. With sub_01h455vb4pex5vsknk084sn02q, the prefix carries the context for you: it's a subscriber. Your integration code can validate IDs by type, your logs become self-documenting, and when something breaks at 2 AM, the ID in the stack trace tells you exactly where to look.

Stripe popularized this idea with prefixed IDs, and the TypeID spec gave us a clean way to do it. A TypeID is a UUID encoded in base32 with a human-readable prefix. Same UUID underneath, just dressed up for the outside world.

flowchart LR
    A["REQUEST
sub_01h455vb4pex
or
550e8400-e29b-..."] --> B["NORMALIZE_ID()
Always yields
a UUID"] B --> C["DATABASE
Stores UUID only"] C -->|">= 2026-01-01"| D["sub_01h455vb4pex"] C -->|"< 2026-01-01"| E["550e8400-e29b-..."]

To make this work consistently across our models, we added a single convention each model declares a short type_id_prefix. When Python loads the model class, our BaseModel.__init_subclass__ hook automatically registers that prefix in a global TypeIDRegistry, which we later use to recognize legitimate prefixes during validation and response migrations.

class BaseModel(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    objects = TypeIDAwareManager()

    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        if hasattr(cls, "type_id_prefix"):
            TypeIDRegistry.register(cls.type_id_prefix)

Adding TypeID support to any model is a one-liner:

class Subscriber(BaseModel):
    type_id_prefix = "sub"

class Email(BaseModel):
    type_id_prefix = "em"

BaseModel then has a type_id property that encodes the UUID into a TypeID using the typeid package and the model's prefix:

@property
def type_id(self) -> str:
    prefix = getattr(self.__class__, "type_id_prefix", self.__class__.__name__.lower())
    id_value = UUID(self.id) if isinstance(self.id, str) else self.id
    return str(from_uuid(prefix=prefix, suffix=id_value))

Here's what this looks like in practice:

newsletter = Newsletter.objects.create(name="My Newsletter")
newsletter.id       # → "550e8400-e29b-41d4-a716-446655440000"
newsletter.type_id  # → "news_01h455vb4pex5vsknk084sn02q"

Once models can produce a TypeID, wiring it into the API is mostly a serialization change. Just like BaseModel, we have a BaseSchema with a resolve_id method that returns obj.type_id instead of the raw UUID, so every schema that inherits from it gets TypeIDs for free. By default, any object's ID in a response is a TypeID. More on how we return UUIDs for older clients with API versioning later.

The more interesting question is: what happens when a request comes in with a TypeID? The database only has UUIDs, so we need to decode it before querying.

Under the hood, we have a decode_type_id function that parses the base32 suffix back into a UUID. On top of that, normalize_id is the function we actually call. It passes UUIDs through untouched and only decodes TypeIDs:

def decode_type_id(type_id: str) -> UUID | None:
    if "_" not in type_id:
        return None
    suffix = TypeID.from_string(type_id).suffix  # type: ignore
    decoded_bytes = bytes(base32.decode(suffix))
    return UUID(bytes=decoded_bytes)


def normalize_id(identifier: str) -> str:
    if is_valid_uuid(identifier):
        return identifier
    decoded = decode_type_id(identifier)
    return str(decoded) if decoded else identifier

Calling normalize_id in every route view would be tedious and error-prone. The trick is applying the conversion consistently, including __in lookups and foreign-key filters like subscriber_id. Rather than sprinkling it across views, we pushed it down into the Django ORM.

We wrote a munge_kwargs_for_type_id function that scans every kwarg: if the key is id or ends with _id (including lookups like subscriber_id__in), it converts any TypeID values to UUIDs. Then we wrapped it in a custom QuerySet that intercepts the core ORM calls:

class TypeIDAwareQuerySet(models.QuerySet):
    def get(self, *args, **kwargs):
        return super().get(*args, **munge_kwargs_for_type_id(self.model, kwargs))

    def filter(self, *args, **kwargs):
        return super().filter(*args, **munge_kwargs_for_type_id(self.model, kwargs))

    def exclude(self, *args, **kwargs):
        return super().exclude(*args, **munge_kwargs_for_type_id(self.model, kwargs))

Because BaseModel sets objects = TypeIDAwareManager(), every model gets this for free:

# All of these work:
Subscriber.objects.get(id="sub_01h455vb4pex5vsknk084sn02q")
Subscriber.objects.get(id="550e8400-e29b-41d4-a716-446655440000")
Subscriber.objects.filter(id__in=["sub_abc123", "sub_def456"])
Email.objects.filter(subscriber_id="sub_01h455vb4pex5vsknk084sn02q")

This is where most of the heavy lifting lived. Once TypeIDAwareManager() was in place, we could migrate the API incrementally without breaking anyone. Every Django query could accept either a UUID or a TypeID, and everything still resolved to the same rows.

The last piece is API versioning. Every API request carries an X-API-Version header, and we apply response migrations based on that version. We added one migration function that applied convert_typeid_to_uuid. Clients on 2026-01-01 or later get TypeIDs. Older clients get UUIDs, automatically. On the input side, both formats always work. Zero breaking changes.

{
      "id": "sub_01h455vb4pex5vsknk084sn02q",
      "email_id": "em_7x9kq3m2abc"
}

If your version is older, the migration converts TypeIDs back to plain UUIDs:

{
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "email_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

The core concept is straightforward, but the migration touched almost every API endpoint, and the edge cases added up. One example was automation metadata. Automations store action data as JSON, with tag IDs buried inside nested dictionaries. We had to walk the metadata tree and normalize any TypeID we found without breaking the structure. Foreign keys were the other big surface area. It's not just the id field, but every _id field like referring_subscriber_id and tag_ids in bulk actions, each needing to accept TypeIDs on input and return them on output.

We didn't flip a switch. We migrated 29 routes one at a time, starting with low risk endpoints like images and attachments, building confidence, and working up to high-traffic ones like subscribers and emails. Every endpoint got tests that sent both a UUID and a TypeID and asserted the response matched the requested API version.

With TypeIDs rolled out across the entire API, every ID in Buttondown is now self-describing. Support tickets are easier to debug, API integrations are safer, and log lines actually tell you what you're looking at.

If you're using the Buttondown API, you don't need to do anything. Your existing integrations keep working. But if you'd like to start using TypeIDs, just set your X-API-Version header to 2026-01-01 or later, and every ID in the response will tell you exactly what it is.