Skip to content

HATEOAS API Versioning — Evolving Hypermedia APIs Without Breaking Clients

DodaTech Updated 2026-06-28 6 min read

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

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

  1. Versioning URLs despite HATEOAS — /v1/devices defeats the purpose of HATEOAS. Instead, version the link relations. New clients look for new relations, old clients find old ones.
  2. Removing relations too quickly — deprecate a relation for at least 6 months before removing it. Monitor usage to ensure no clients rely on it.
  3. Not informing clients about deprecation — use Warning and Sunset HTTP headers. Include Migration URLs in deprecation notices.
  4. Forcing all clients to upgrade simultaneously — support at least two versions concurrently. Let clients upgrade on their own schedule.
  5. 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

  1. How does HATEOAS make API versioning easier than traditional REST?
  2. What is the recommended deprecation period for link relations?
  3. How do you inform clients about deprecated links?
  4. Why should you support multiple versions concurrently?
  5. 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

Do I still need URL versioning with HATEOAS?

No. HATEOAS clients follow links, not URL patterns. Version the link relation names instead. New relations get new names, old relations persist until sunset.

How long should deprecated relations remain?

A minimum of 6 months, preferably 12-18 months. This gives clients enough time to upgrade. Monitor usage to confirm no clients depend on the deprecated relation.

Can I change the meaning of an existing link relation?

Never. A link relation is a contract. If you need different semantics, create a new relation with a new name. Old clients depend on the existing behavior.

How do clients discover available versions?

Include a 'version' link in the root resource pointing to a version information endpoint. The version resource describes available relations and their deprecation status.

What headers should I use for deprecation?

Warning header for deprecation notices, Sunset for the removal date, and custom Link-Deprecation headers per relation for specific deprecation information.

What's Next

Learn HATEOAS API design

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro