Building Public APIs: Versioning Strategies and Design

A robust approach to API backwards compatibility

Posted by Martín Peveri on January 18, 2026 · 6 mins read

Introduction

Recently, at work, I faced the challenge of exposing internal functionality of our system through a Public API. I really liked the approach we are using, which is why I decided to write this article.

It is nothing new or super innovative; in fact, Stripe's public API works this way.

To truly understand how this works "under the hood", I decided to create an example repository from scratch implementing a versioning system. The result is this repository: mapeveri/public-api-versioning.


Benefits of an Explicit Versioning Strategy

When you expose your system to the world, versioning is not optional. Implementing a solid strategy offers:

  • Frictionless Evolution: You can improve, refactor, and change your internal business logic without fear of breaking old clients.
  • Backwards Compatibility: You guarantee that an integration made today will continue working tomorrow, no matter what happens in your backend.
  • Clarity for the consumer: By using explicit versions, the consumer knows exactly what contract they are signing with your API.
  • Clean Codebase (The Stripe Way): Instead of filling your controllers with if (version < OLD) statements, you encapsulate legacy logic in isolated transformation classes. This keeps your core business logic clean, testable, and focused on the present.

How does it work conceptually?

The core idea is not to keep multiple copies of your code (a v1 controller, a v2 one, etc.); that would be unmanageable. Instead, a better maintainable approach is to have a single version of the code (the most recent one) and apply transformations to adapt that version to what the old client expects.

The Request Flow

  1. Request: The client sends data in the "old" format.
    • We apply upward transformations (from old to new). This involves renaming fields, ensuring mandatory parameters (filling defaults if needed), or restructuring JSON to match what the current controller expects. Ideally, the controller never knows it's dealing with a legacy request.
  2. Processing: The controller executes business logic using the newest version of the code. It is completely agnostic of the client's version.
  3. Response: The system generates a response with the new structure.
    • We apply downward transformations (from new to old). We take the new data structure and remove fields that didn't exist in the old version, combine fields (e.g., first_name + last_name -> name), or rename them back so the client receives exactly what they expect.

A Ruby Example (Pseudocode)

To handle these transformations cleanly, we define each version in its own separate file. This keeps the logic isolated and easy to navigate.

For example, if in version `2025-01-01` we had `name`, but the currently (`2026-01-01`) we split that into `first_name` and `last_name`, we would create a file like app/controllers/api/v1/versions/version20250101.rb:


# Definition of the old version
class Version20250101 < Version
  timestamp "2025-01-01"

  # Input Transformation (Request): 
  # Converts what the user sends ("name") into what my current controller expects ("first_name", "last_name")
  payload do |t|
    t.split_field :name, into: [:first_name, :last_name]
  end

  # Output Transformation (Response):
  # Converts what my controller returns ("first_name", "last_name") into what the old user expects ("name")
  response do |t|
    t.combine_fields :first_name, :last_name, into: :name
  end
end

With this pattern, your business logic is always clean and updated, and the "technical debt" of supporting old versions is encapsulated in these transformation classes.


Hybrid Versioning: The Best of Both Worlds

A common question is: "Can I combine this with URL versioning (v1, v2)?". The answer is yes.

You can use Major Versions (v1, v2) for paradigm shifts (e.g., moving from REST to GraphQL, or a complete rewrite of your resource IDs) and Date Versions for the continuous evolution within that major version.

Stripe, for example, has kept `v1` in their URL for over a decade. They haven't needed a `v2` because their transformation system handles evolution so well. However, having that `v1` namespace acts as a safety valve: if they ever need to break the world, they have `v2` reserved.


Deprecation and Sunsetting

Supporting backward compatibility doesn't mean supporting everything forever. You need a strategy to communicate end-of-life.

This pattern allows for granular deprecation:

  • Soft Deprecation (Headers): You can configure specific versions to attach standard HTTP headers like Warning or Sunset. This alerts consumers monitoring their logs that they are using a dying version without breaking their automation.
  • Hard Deprecation (410 Gone): When an endpoint's functionality is fundamentally removed and cannot be emulated via transformations, you can configure that specific legacy version to return 410 Gone, providing a clear signal to the client.

This observability is key: instead of silently failing, your API actively communicates its lifecycle to your consumers.


Conclusion

Implementing a public API requires a mindset shift: you go from thinking only about functionality to thinking about contracts and stability. Versioning via transformations is a powerful strategy because it decouples your software's evolution from the public interface you've promised to maintain.