April 9, 2026 10 min read

API Versioning for Payment Platforms — How We Migrated 200 Merchants Without Breaking a Single Integration

When your API handles real money, "move fast and break things" isn't an option. Here's how we designed a versioning strategy that let us ship breaking changes to 200 merchants over 14 months — with zero integration failures.

Last year, our payment platform hit a wall. The original API — designed three years prior when we had 12 merchants — was buckling under the weight of features it was never meant to support. Multi-currency settlements, split payments, 3DS2 authentication flows — all bolted on through increasingly creative parameter overloading. We needed a clean v2, but 200 merchants were actively processing transactions on v1. Downtime or broken integrations meant real revenue loss for real businesses.

This is the story of how we pulled it off.

200
merchants migrated
0
broken integrations
14
month deprecation window

Choosing a Versioning Strategy

Before writing any code, we spent two weeks evaluating versioning approaches. Every strategy has trade-offs, and in payments, the wrong choice compounds over years. Here's what we considered:

Strategy Example Pros Cons
URL Path /v1/charges Explicit, easy to route, cache-friendly URL proliferation, hard to do partial upgrades
Header Api-Version: 2 Clean URLs, per-request flexibility Easy to forget, harder to debug, invisible in logs
Query Param ?version=2 Simple to test in browser Pollutes query string, caching headaches
Date-based ✓ Stripe-Version: 2025-06-15 Granular, self-documenting, incremental changes More versions to maintain, needs strong tooling

We went with a hybrid: URL path versioning for major structural changes (/v1/ vs /v2/) combined with date-based versioning for minor breaking changes within a major version. This is essentially what Stripe does, and for good reason — it lets you ship incremental improvements without forcing merchants onto an entirely new API surface.

Why we studied Stripe's approach: Stripe pins each API key to the version that was current when the key was created. Merchants opt into newer versions explicitly. This means a merchant who integrated two years ago still gets the response shapes they coded against, even if the API has evolved 30 times since. That's the gold standard for payment APIs.

The Backward Compatibility Contract

Before building anything, we wrote down our compatibility contract. This sounds bureaucratic, but it saved us from at least a dozen arguments later. The rules were simple:

We encoded these rules into a CI check that compared OpenAPI specs between versions. Any PR that violated the contract for the current stable version got flagged automatically. No human review needed for the obvious stuff.

Version Negotiation Middleware in Go

The core of our system is a Go middleware that resolves which API version to use for each request. It checks three places in order: the URL path, a custom header, and finally the version pinned to the merchant's API key.

Version Resolution Flow
Incoming Request
Check URL path
/v1/ or /v2/
Check header
X-Api-Version
Lookup API key
pinned version
Resolved Version

Here's the actual middleware (simplified, but close to what we run in production):

func VersionMiddleware(keyStore KeyStore) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            var version string

            // 1. Check URL path prefix
            if strings.HasPrefix(r.URL.Path, "/v2/") {
                version = "v2"
            } else if strings.HasPrefix(r.URL.Path, "/v1/") {
                version = "v1"
            }

            // 2. Header override (within the major version)
            if hdr := r.Header.Get("X-Api-Version"); hdr != "" {
                if err := validateVersion(hdr); err == nil {
                    version = hdr
                }
            }

            // 3. Fall back to the version pinned to the API key
            if version == "" {
                apiKey := extractAPIKey(r)
                if pinned, err := keyStore.GetPinnedVersion(r.Context(), apiKey); err == nil {
                    version = pinned
                }
            }

            // Default to latest stable if nothing matched
            if version == "" {
                version = CurrentStableVersion
            }

            // Inject into context and set response header
            ctx := context.WithValue(r.Context(), versionCtxKey, version)
            w.Header().Set("X-Api-Version", version)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

The key insight: we always echo the resolved version back in the response header. This made debugging trivial. When a merchant reported unexpected behavior, the first thing support checked was which version the response was actually served under.

Response Transformation

Rather than maintaining entirely separate handler code for each version, we built response transformers. The handler always produces the latest response shape, and a chain of transformers downgrades it for older versions:

// Each transformer knows how to convert from version N to N-1
type ResponseTransformer func(body map[string]interface{}) map[string]interface{}

var transformers = map[string]ResponseTransformer{
    "v2:2025-09-01": func(body map[string]interface{}) map[string]interface{} {
        // v2:2025-09-01 renamed "payment_method" to "source"
        if pm, ok := body["payment_method"]; ok {
            body["source"] = pm
            delete(body, "payment_method")
        }
        return body
    },
    // ... more transformers
}

This pattern meant new features only needed to be built once against the latest version. The transformers handled backward compatibility mechanically.

The 14-Month Deprecation Lifecycle

Rushing merchants off an old API version is a recipe for churn. We designed a deprecation timeline that gave everyone plenty of runway while still keeping us moving forward.

Month 0 — Announce
Publish v2 docs, send email to all merchants, add Sunset header to v1 responses.
Month 3 — Sunset Warning
Dashboard banners go live. API responses include Deprecation header with target date. Weekly migration reports to merchant success team.
Month 6–12 — Active Migration
Dedicated migration guides per merchant tier. Office hours for enterprise merchants. Automated compatibility checker tool released.
Month 14 — Removal
v1 endpoints return 410 Gone with a JSON body pointing to v2 equivalents. By this point, all 200 merchants had migrated voluntarily.

Lesson learned: The Sunset and Deprecation HTTP headers (RFC 8594 and the draft RFC) are machine-readable. Some of our merchants' monitoring tools picked them up automatically and created tickets. Free migration nudges without any extra work on our side.

Migration Tooling and Changelog Automation

We built two tools that made the migration dramatically smoother:

1. The Compatibility Checker

A CLI tool merchants could run against their codebase. It parsed their API calls, compared request/response shapes against the v2 spec, and produced a diff report. Think of it as a linter for API compatibility. We open-sourced it, and a few merchants even contributed fixes back.

2. Automated Changelogs

Every PR that touched an API endpoint required a changelog entry in a structured YAML format. Our CI pipeline aggregated these into a per-version changelog that was published automatically to our developer portal. No more "check the release notes" emails that nobody reads — the changelog was always current and always complete.

# changelog/2025-09-01.yaml
version: "2025-09-01"
changes:
  - type: breaking
    endpoint: POST /v2/charges
    description: "Field 'source' renamed to 'payment_method'"
    migration: "Replace 'source' with 'payment_method' in request body"
  - type: addition
    endpoint: GET /v2/charges/{id}
    description: "Added 'payment_method_details' object to response"

What I'd Do Differently

If I were starting over, I'd invest in contract testing earlier. We caught most issues through integration tests, but a tool like Pact would have caught version mismatches at the unit test level. I'd also version webhooks from day one — we didn't, and retrofitting versioned webhook payloads was the most painful part of the entire project.

API versioning in payments isn't glamorous work. There's no conference talk that makes deprecation timelines sound exciting. But when you're handling someone else's money, the boring, methodical approach is exactly what earns trust. Our merchants didn't notice the migration because that was the whole point — the best infrastructure is the kind nobody has to think about.

References

Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.