HATEOAS API Versioning — Evolving Hypermedia APIs Without Breaking Clients
In this tutorial, you will learn about HATEOAS API Versioning. We cover key concepts, practical examples, and best practices to help you master this topic.
HATEOAS API versioning leverages hypermedia's natural decoupling — since clients discover URLs at runtime, the server can evolve its URL structure and add new link relations without breaking existing clients that still follow old relations.
What You'll Learn
- Why HATEOAS makes versioning easier
- Link-based versioning strategies
- Backward-compatible API evolution
- Deprecating link relations
- Content negotiation for versions
- Version discovery via links
Why It Matters
Traditional REST versioning (URL-based /v1/, /v2/) breaks HATEOAS because URLs change. With hypermedia, you can evolve the API gradually — adding new relations while keeping old ones — without clients ever noticing. DodaTech's Durga Antivirus Pro evolved its API from v1 to v2 over 18 months without any client downtime by maintaining backward-compatible link relations.
Real-World Use
The API adds a new dt:deep-scan action to device resources. Old clients ignore this unknown relation and continue using dt:scan. New clients use dt:deep-scan. After 6 months, dt:scan is removed. The API monitors client usage and notifies maintainers of deprecated relations.
flowchart LR
subgraph "API Evolution Timeline"
A["v1: dt:scan\ndevices/{id}/scan"] --> B["v1.5: +dt:deep-scan\ndevices/{id}/deep-scan"]
B --> C["v2: dt:scan removed\nOnly dt:deep-scan"]
end
D["Old Client"] -->|"Uses dt:scan"| A
D -->|"Still works"| B
E["New Client"] -->|"Uses dt:deep-scan"| B
E -->|"Only option"| C
style A fill:#bbf7d0,stroke:#16a34a
style C fill:#fef3c7,stroke:#d97706
Code Examples
Example 1: Link-Based Versioning
class VersionedApi {
constructor() {
this.relations = new Map();
}
// Register a link relation with version range
registerRelation(rel, href, minVersion, maxVersion = null) {
if (!this.relations.has(rel)) {
this.relations.set(rel, []);
}
this.relations.get(rel).push({ href, minVersion, maxVersion });
}
// Get links for a specific client version
getLinksForVersion(version) {
const links = {};
for (const [rel, implementations] of this.relations) {
const matching = implementations.find(impl =>
impl.minVersion <= version &&
(impl.maxVersion === null || version <= impl.maxVersion)
);
if (matching) {
links[rel] = { href: matching.href };
}
}
return links;
}
// Add deprecation info to links
getLinksWithDeprecation(version) {
const links = {};
for (const [rel, implementations] of this.relations) {
const active = implementations.find(impl =>
impl.minVersion <= version &&
(impl.maxVersion === null || version <= impl.maxVersion)
);
const deprecated = implementations.find(impl =>
impl.minVersion <= version &&
impl.maxVersion !== null &&
version >= impl.maxVersion - 1
);
if (active) {
links[rel] = {
href: active.href,
...(deprecated ? { deprecation: true, sunset: deprecated.maxVersion } : {}),
};
}
}
return links;
}
}
// Usage
const api = new VersionedApi();
// v1: original endpoint
api.registerRelation('dt:scan', '/v1/devices/{id}/scan', 1.0, 1.9);
// v1.5: new endpoint added (both work)
api.registerRelation('dt:deep-scan', '/v2/devices/{id}/deep-scan', 1.5);
api.registerRelation('dt:scan', '/v2/devices/{id}/scan', 2.0);
Example 2: Deprecation Headers
const express = require('express');
const app = express();
// Deprecation tracking
const deprecatedRelations = new Map();
function deprecateRelation(rel, sunsetVersion, migrationUrl) {
deprecatedRelations.set(rel, { sunsetVersion, migrationUrl });
}
function addDeprecationHeaders(res, requestVersion, links) {
for (const [rel, link] of Object.entries(links)) {
if (link.deprecation) {
res.set(`Link-Deprecation-${rel}`, `sunset=${link.sunset}; migration=${link.migrationUrl}`);
}
}
}
app.get('/devices/:id', (req, res) => {
const clientVersion = parseFloat(req.headers['accept-version'] || '1.0');
const device = getDevice(req.params.id);
const links = {
self: { href: `/devices/${device.id}` },
threats: { href: `/devices/${device.id}/threats` },
};
// Version-dependent links
if (clientVersion < 2.0) {
links['dt:scan'] = {
href: `/devices/${device.id}/scan`,
deprecation: true,
sunset: '2026-09-01',
migrationUrl: '/docs/migration-v2',
};
}
if (clientVersion >= 1.5) {
links['dt:deep-scan'] = {
href: `/devices/${device.id}/deep-scan`,
};
}
// Add Sunset header for deprecated features
addDeprecationHeaders(res, clientVersion, links);
res.json({ ...device, _links: links });
});
// Version negotiation
app.use((req, res, next) => {
const version = req.headers['accept-version'];
// Deprecation warning header
if (version && parseFloat(version) < 2.0) {
res.set('Warning', '299 - "This API version is deprecated. Upgrade to v2."');
res.set('Sunset', 'Sat, 01 Sep 2026 00:00:00 GMT');
}
next();
});
Example 3: Client-Side Version Adaptation
class VersionAwareClient {
constructor(rootUrl, clientVersion = 2.0) {
this.rootUrl = rootUrl;
this.clientVersion = clientVersion;
this.supportedRelations = new Set([
'self', 'devices', 'threats', 'scans',
'dt:scan', 'dt:deep-scan', 'dt:quarantine',
]);
}
async fetch(url) {
const res = await fetch(url, {
headers: {
'Accept-version': String(this.clientVersion),
'Accept': 'application/hal+json',
},
});
// Check deprecation warnings
const warning = res.headers.get('Warning');
if (warning && this.onDeprecationWarning) {
this.onDeprecationWarning(warning);
}
// Check Sunset header
const sunset = res.headers.get('Sunset');
if (sunset) {
console.log(`API sunset date: ${sunset}`);
}
return res.json();
}
async navigate() {
const root = await this.fetch(this.rootUrl);
const links = root._links || {};
// Find links we understand
const available = Object.keys(links)
.filter(rel => this.supportedRelations.has(rel));
console.log('Available known links:', available);
// Use the most appropriate version of a link
const scanLink = links['dt:deep-scan'] || links['dt:scan'];
if (scanLink) {
console.log('Using scan link:', scanLink.href);
await this.fetch(scanLink.href);
}
// Check for link deprecation
for (const [rel, link] of Object.entries(links)) {
if (link.deprecation) {
console.warn(`Link '${rel}' deprecated, sunset: ${link.sunset}`);
console.warn(`Migration: ${link.migrationUrl}`);
}
}
}
}
Common Mistakes
- Versioning URLs despite HATEOAS —
/v1/devicesdefeats the purpose of HATEOAS. Instead, version the link relations. New clients look for new relations, old clients find old ones. - Removing relations too quickly — deprecate a relation for at least 6 months before removing it. Monitor usage to ensure no clients rely on it.
- Not informing clients about deprecation — use Warning and Sunset HTTP headers. Include Migration URLs in deprecation notices.
- Forcing all clients to upgrade simultaneously — support at least two versions concurrently. Let clients upgrade on their own schedule.
- Breaking existing link relations — never change the meaning of a link relation. If you need new semantics, add a new relation with a different name.
Practice Questions
- How does HATEOAS make API versioning easier than traditional REST?
- What is the recommended deprecation period for link relations?
- How do you inform clients about deprecated links?
- Why should you support multiple versions concurrently?
- How do you handle client version negotiation in HATEOAS?
Challenge: Design a version migration Strategy for a HATEOAS API that transitions from v1 to v2 over 12 months. Include: backward-compatible new relations, deprecation header warnings, client version detection, and a sunset date with migration documentation.
Mini Project
Build a version-aware HATEOAS API with: versioned link relations using min/max version ranges, deprecation headers with sunset dates, client version negotiation via Accept-Version header, monitoring of deprecated relation usage, and automated sunset enforcement after the deprecation period.
FAQ
What's Next
Learn HATEOAS API design
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro