REST API Deprecation
Sunset headers, migration guides, and consumer communication for graceful API end-of-life
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
| Phase | Duration | Actions |
|---|---|---|
| Active | Indefinite | Normal support, bug fixes, improvements |
| Deprecated | Min 3–6 months | Sunset headers, changelog notice, migration docs published |
| Sunset | 2–4 weeks | 410 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 Version | Timeline | Notes |
|---|---|---|
| v1 (current) | Active | Full support |
| v2 (new) | Launch date | Deprecate v1 same day, start 6-month countdown |
| v1 sunset | +6 months | 410 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
| Company | Approach | Window |
|---|---|---|
| Stripe | Date-versioned (2024-01-01), each key locked to a version, automatic upgrade path | 12+ months |
| GitHub | Sunset + Deprecation headers on every deprecated endpoint, 3-month minimum | 3–12 months |
| Twilio | Email notifications to all API key holders + dashboard banner + headers | 6 months |