REST API Deprecation

Sunset headers, migration guides, and consumer communication for graceful API end-of-life

Last Updated:

Why Deprecation is a First-Class Concern

Breaking a production API breaks your users' systems. The cost of a surprise API removal includes emergency developer time, customer churn, and support load. A well-communicated deprecation process turns a potentially adversarial event into a collaborative migration.

This extends naturally from our API versioning guide — versioning creates the path, deprecation manages the sunset. Together they form your API lifecycle management strategy.

The Deprecation Lifecycle

PhaseDurationActions
ActiveIndefiniteNormal support, bug fixes, improvements
DeprecatedMin 3–6 monthsSunset headers, changelog notice, migration docs published
Sunset2–4 weeks410 Gone returned, redirect headers present, final warnings
Removed—Endpoint deleted, docs archived

HTTP Headers: Sunset & Deprecation

Two standard HTTP headers communicate deprecation status to API consumers:

Sunset Header (RFC 8594)

HTTP/1.1 200 OK
Sunset: Thu, 31 Dec 2026 23:59:59 GMT
Deprecation: @1780272000
Link: <https://api.example.com/v2/users>; rel="successor-version",
      <https://docs.example.com/migration/v1-to-v2>; rel="deprecation"
Content-Type: application/json

RFC 9745 defines Deprecation as a structured date. The value @1780272000 means June 1, 2026, at 00:00 UTC.

Sunset uses an HTTP date for the expected end of service. That date cannot precede the deprecation date. The replacement appears in the successor link.

Node.js Deprecation Middleware

// Reusable deprecation middleware factory
function deprecate({ deprecationDate, sunsetDate, successorUrl, docsUrl }) {
  const deprecationTime = Date.parse(deprecationDate);
  const sunsetTime = Date.parse(sunsetDate);
  if (!Number.isFinite(deprecationTime) || !Number.isFinite(sunsetTime)) {
    throw new RangeError('Both dates must be valid');
  }
  if (sunsetTime < deprecationTime) {
    throw new RangeError('Sunset cannot precede deprecation');
  }
  const deprecationHeaderValue = `@${Math.floor(deprecationTime / 1000)}`;
  const sunsetHeaderValue = new Date(sunsetTime).toUTCString();

  return (req, res, next) => {
    // RFC 8594: Sunset header
    res.set('Sunset', sunsetHeaderValue);

    // RFC 9745: structured date, not a boolean or HTTP date
    res.set('Deprecation', deprecationHeaderValue);

    // Link to successor and migration docs
    const links = [];
    if (successorUrl) links.push(`<${successorUrl}>; rel="successor-version"`);
    if (docsUrl)      links.push(`<${docsUrl}>; rel="deprecation"`);
    if (links.length) res.set('Link', links.join(', '));

    next();
  };
}

// Apply to deprecated routes
app.use('/v1/users', deprecate({
  deprecationDate: '2026-06-01T00:00:00Z',
  sunsetDate: '2026-12-31T23:59:59Z',
  successorUrl: 'https://api.example.com/v2/users',
  docsUrl: 'https://docs.example.com/migration/users-v1-to-v2'
}));

// After sunset date: return 410 Gone
function goneAfterSunset(sunsetDate) {
  return (req, res, next) => {
    if (new Date() > new Date(sunsetDate)) {
      return res.status(410).json({
        error: 'endpoint_removed',
        message: 'This endpoint was removed on ' + new Date(sunsetDate).toDateString(),
        migration_guide: 'https://docs.example.com/migration/users-v1-to-v2'
      });
    }
    next();
  };
}

app.use('/v1/users', goneAfterSunset('2026-12-31T23:59:59Z'), v1UserRoutes);

Versioning Sunset Strategy

API VersionTimelineNotes
v1 (current)ActiveFull support
v2 (new)Launch dateDeprecate v1 same day, start 6-month countdown
v1 sunset+6 months410 Gone, all consumers migrated

Minimum deprecation window by API type:

  • Internal APIs: 1–3 months
  • Partner APIs: 3–6 months
  • Public APIs: 6–12 months (Stripe gives 12+ months)

Monitoring Deprecated Endpoint Usage

// Track who is still calling deprecated endpoints
const deprecationUsage = new Map();

function trackDeprecationUsage(version) {
  return (req, res, next) => {
    const clientId = req.headers['x-client-id'] || req.ip;
    const key = `${version}:${clientId}`;

    // Increment counter in Redis (for production)
    // redis.zincrby('deprecated_usage', 1, key);

    // Log for monitoring
    logger.warn({
      event: 'deprecated_endpoint_called',
      version,
      client_id: clientId,
      path: req.path,
      user_agent: req.headers['user-agent']
    });

    next();
  };
}

app.use('/v1/', trackDeprecationUsage('v1'));

// Report: which clients still use v1?
// SELECT client_id, COUNT(*) FROM deprecated_usage
// WHERE version = 'v1' AND date > NOW() - INTERVAL '7 days'
// GROUP BY client_id ORDER BY count DESC;

Migration Guide for Consumers

A good migration guide includes:

  • Side-by-side diff — v1 request/response vs v2 side by side
  • Breaking changes list — renamed fields, removed fields, changed types
  • Code snippets — before/after in the top 2–3 languages your users use
  • Migration script — if you can provide one (e.g., for data format changes)
  • Staging environment — let consumers test v2 before cutover
  • Support channel — dedicated Slack/forum thread for migration questions

Case Studies: Stripe, GitHub, Twilio

CompanyApproachWindow
StripeDate-versioned (2024-01-01), each key locked to a version, automatic upgrade path12+ months
GitHubSunset + Deprecation headers on every deprecated endpoint, 3-month minimum3–12 months
TwilioEmail notifications to all API key holders + dashboard banner + headers6 months