How we migrated to TypeIDs without breaking clients

Making every ID in our API self-describing, one endpoint at a time

March 1, 2026
How we migrated to TypeIDs without breaking clients

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 are great for databases, but when someone pastes 550e8400-e29b-41d4-a716-446655440000 in a support ticket, we have no idea what kind of thing it is. With sub_01h455vb4pex5vsknk084sn02q, the prefix tells us instantly: it's a subscriber. Debugging gets faster, support gets easier.

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.

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.

Buttondown is the last email platform you’ll switch to.
How we migrated to TypeIDs without breaking clients