Integration & APIs/
Lesson

Your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. is a contract. The moment someone builds a mobile app, a third-party integration, or an internal service on top of your API, you have made a promiseWhat is promise?An object that represents a value you don't have yet but will get in the future, letting your code keep running while it waits.: "this will keep working." But software evolves. You need to add fields, change response shapes, deprecate endpoints. Versioning is how you evolve without breaking that promise.

The question is not whether to version, it is how.

AI pitfall
AI almost always adds versioning infrastructure from the start, even for internal APIs with a single consumer. If you control both the client and the server and deploy them together, you do not need /v1/ in your URLs. Add versioning when you have external consumers or independent release cycles, not before.

URL path versioning

This is by far the most popular approach. You embed the version number directly in the URL:

// Express router with URL versioning
const v1Router = express.Router();
const v2Router = express.Router();

v1Router.get('/users', (req, res) => {
  // V1: returns flat user object
  const users = await db.users.findAll();
  res.json(users.map(u => ({
    id: u.id,
    name: u.name,
    email: u.email
  })));
});

v2Router.get('/users', (req, res) => {
  // V2: returns nested profile object
  const users = await db.users.findAll({ include: ['profile'] });
  res.json(users.map(u => ({
    id: u.id,
    name: u.name,
    email: u.email,
    profile: {
      avatar: u.profile.avatar,
      bio: u.profile.bio
    }
  })));
});

app.use('/v1', v1Router);
app.use('/v2', v2Router);

The appeal is obvious: you can see the version in the URL, test it in a browser, and route traffic at the load balancerWhat is load balancer?A server that distributes incoming traffic across multiple backend servers so no single server gets overwhelmed. level. GitHub, Stripe, and Twilio all use this approach. CDNs and proxies cache /v1/users and /v2/users independently with zero configuration.

The downside is that every version creates a new set of routes. If you have 40 endpoints and only 3 changed between v1 and v2, you still need to maintain the full surface area of both versions, or build an internal routing layer that forwards unchanged endpoints from v2 to v1 handlers.

02

Header versioning

Instead of changing the URL, you use the Accept header to specify which version of the response you want:

app.get('/users', (req, res) => {
  const accept = req.headers['accept'] || '';

  if (accept.includes('application/vnd.myapi.v2+json')) {
    // V2 response with nested profile
    const users = await db.users.findAll({ include: ['profile'] });
    return res.json(users.map(u => ({
      id: u.id,
      name: u.name,
      profile: { avatar: u.profile.avatar }
    })));
  }

  // Default to V1
  const users = await db.users.findAll();
  res.json(users.map(u => ({
    id: u.id,
    name: u.name,
    email: u.email
  })));
});

This approach keeps your URLs clean, /users is always /users regardless of version. The Azure RESTWhat is rest?An architectural style for web APIs where URLs represent resources (nouns) and HTTP methods (GET, POST, PUT, DELETE) represent actions on those resources. APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. and parts of the GitHub API use this pattern. It follows HTTPWhat is http?The protocol browsers and servers use to exchange web pages, API data, and other resources, defining how requests and responses are formatted. semantics more closely, since the Accept header is designed for content negotiation.

But it is harder to work with in practice. You cannot paste a versioned URL into a browser tab. Developers must set custom headers in Postman, curl, or their HTTP client, which adds friction. Caching becomes more complex because the URL alone no longer determines the response, you need Vary: Accept headers, and not all CDNs handle that well.

03

Query parameter versioning

The simplest approach: append the version as a query parameter.

app.get('/users', (req, res) => {
  const version = parseInt(req.query.version) || 1;

  if (version === 2) {
    // V2 response
    return res.json(await getUsersV2());
  }

  // Default to V1
  res.json(await getUsersV1());
});

Google and some AWS services use this pattern. It is easy to implement and easy to test, just change ?version=1 to ?version=2 in the URL. But it mixes versioning concerns with regular query parameters, which muddies the APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. surface. If a consumer forgets the parameter, they get the default version, which might not be what they expect.

Good to know
Google and some AWS services use query parameter versioning. It is easy to implement and test, but it has a subtle risk: if a consumer forgets the ?version=2 parameter, they silently get the default version. This causes bugs that are hard to trace because the request "works", just with the wrong response shape.
04

Comparing the strategies

CriteriaURL path (/v1/)Header (Accept)Query param (?version=)
VisibilityHigh, version in URLLow, hidden in headersMedium, in URL but optional
Browser testableYesNo (needs custom headers)Yes
CachingSimple (URL-based)Complex (needs Vary header)Simple (URL-based)
Routing complexitySeparate route treesConditional logic in handlersConditional logic in handlers
URL cleanlinessCluttered (/v1/, /v2/)Clean (same URL always)Slightly cluttered
CDN/proxy supportExcellentRequires configurationGood
Industry adoptionMost common (Stripe, GitHub)Less common (Azure, GitHub partial)Moderate (Google, AWS)
Risk of consumer errorLow (explicit in URL)Medium (wrong header = wrong version)High (missing param = default)
Edge case
Header versioning breaks CDN caching unless you configure Vary: Accept on every response. Most CDNs cache by URL only, so two clients requesting different versions of /users get the same cached response. This causes intermittent bugs that are extremely hard to reproduce.
05

Semantic versioningWhat is semantic versioning?A numbering system (major.minor.patch) that communicates whether a release contains breaking changes, new features, or bug fixes. for APIs

Even if your URL says /v2/, you need a more granular system to communicate change scopeWhat is scope?The area of your code where a variable is accessible; variables declared inside a function or block are invisible outside it.. Semantic versioning (SemVer) uses three numbers: major.minor.patch.

2.3.1
│ │ └── Patch: bug fixes, no behavior change
│ └──── Minor: new features, backward compatible
└────── Major: breaking changes

In practice, the URL version maps to the major version. Minor and patch versions are communicated through changelogs, response headers, or APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. documentation:

// Communicate the precise version in response headers
app.use((req, res, next) => {
  res.setHeader('X-API-Version', '2.3.1');
  next();
});

The key rule: only major version bumps break existing integrations. If you follow SemVer correctly, a consumer on v2 can safely ignore minor and patch updates, their code will keep working.

06

When you don't need versioning

Versioning adds overhead. Before you set up /v1/ on day one, consider whether you actually need it:

Skip versioning when:

  • Your APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. is internal (only your own frontend calls it), you deploy both sides simultaneously
  • You are in early development (pre-launch, no external consumers yet)
  • You have a single consumer that you control and can update at will
  • Your API is behind a feature flagWhat is feature flag?A configuration toggle that lets you turn a feature on or off without redeploying your app, useful for gradual rollouts or instantly disabling broken functionality. system that already handles gradual rollouts

Add versioning when:

  • Third-party developers build on your API
  • Mobile apps use your API (you cannot force users to update)
  • Multiple internal teams consume your API on their own release schedules
  • Your API is a product (Stripe, Twilio, SendGrid)

Starting without versioning and adding it later is perfectly valid, and simpler than maintaining version infrastructure you do not need yet. The cost of adding /v1/ to your URLs retroactively is much lower than maintaining two version routers for an API that only you use.

07

Practical implementation pattern

Here is what AI typically generates for version routing, separate complete route files for each version, and the cleaner alternative:

Here is a clean pattern for URL versioning that avoids duplicating unchanged endpoints:

// Shared handlers that haven't changed between versions
const sharedHandlers = {
  getHealth: (req, res) => res.json({ status: 'ok' }),
  getProducts: (req, res) => { /* same in v1 and v2 */ }
};

// V1-specific handlers
const v1Handlers = {
  getUsers: (req, res) => { /* flat response */ }
};

// V2-specific handlers
const v2Handlers = {
  getUsers: (req, res) => { /* nested response */ }
};

function mountVersion(app, prefix, versionHandlers) {
  const merged = { ...sharedHandlers, ...versionHandlers };
  const router = express.Router();

  router.get('/health', merged.getHealth);
  router.get('/users', merged.getUsers);
  router.get('/products', merged.getProducts);

  app.use(prefix, router);
}

mountVersion(app, '/v1', v1Handlers);
mountVersion(app, '/v2', v2Handlers);

This way, unchanged endpoints share the same code, and you only maintain version-specific handlers for endpoints that actually differ.