Skip to main content
WritingVideosSpeakingCodeAbout

API Design

You Can't Deprecate a Field With a Header

RFC 9745's Deprecation header covers whole resources, so a field changing shape has no HTTP signal. Where that deprecation goes instead, and where the header is exactly right.

Oct 20267 min read

When I wrote the spec for Pingback, the small CRM API I built to test api-toolbox and sdk-toolbox against each other, I planned its first change after v1 right down to the headers. A contact’s single phone was going to become a list, the old field would carry Deprecation and Sunset, and the SDK’s tests would run with Deprecations::strict() so they’d go red until the SDK had moved over.

The plan was pretty solid, but it was wrong. Even the RFC agreed with me, on one specific line.

What the header actually covers

RFC 9745 is the one that defines the Deprecation response header, and section 2.2 is all about its scope. It says the header field “applies to the resource identified with the response it occurred within”, which means the resource, the thing that actually lives at the URI. There’s nothing in there about fields, or properties, or any other part of a representation, and RFC 8594’s Sunset works the same way, giving you the date a resource is expected to stop responding.

So let’s think about what my plan would actually have done. Every single response for a contact would have carried Deprecation, and anything that reads that header, whether that’s a gateway or sdk-toolbox’s strict mode sitting in somebody’s test suite, would take it exactly the way the RFC tells it to, as “this contact resource is going away”. The contact wasn’t going anywhere, though, it was just one field on it changing shape.

It gets a little bit worse, too, because a client that never touched phone would get exactly the same warning as one that did. The header has no way of saying which part of the response it’s talking about, so it ends up crying wolf at everyone, and a warning that fires for everyone very quickly becomes a warning that everyone ignores.

A field changing shape is about the most ordinary change an API makes, and yet HTTP doesn’t have a header for it at all.

Where a field’s deprecation goes

If the wire can’t carry it, then the contract has to, and the first part of that is making sure the old field keeps working. Contacts gained phones, a list of numbers with optional labels, and phone stayed around as the first number on that list. Reading it still gives you a number, and writing it still works too, replacing the list with that one number, with null emptying it. If you send both in the same request you’ll get a 422, because there just isn’t a sensible way to honour both at once.

return [
// ...
// Deprecated (ADR-0007): the first of phones, kept so existing clients keep working.
'phone' => $contact->primaryPhone(),
'phones' => array_map(static fn(array $phone): array => ['number' => $phone['number'], 'label' => $phone['label']], $contact->phones),
];

Then the OpenAPI document needs to say it’s deprecated, and this is actually where field-level deprecation does have a standard home. OpenAPI’s Schema Object has a deprecated flag, so the property itself can carry it, in every request and response it appears in:

phone:
type: string
nullable: true
deprecated: true
description: 'Deprecated: the first of phones, or null. Read phones instead.'

Anything generated from that document can see it, from a client right through to a docs page. Pingback’s contract test checks the flag is there on contacts, and, just as importantly, that it isn’t on an organisation’s phone, because that one isn’t going anywhere.

The SDK then says the same thing in PHP. Contact::$phone carries @deprecated, so your IDE, and PHPStan if you have its deprecation rules installed, will flag every place that still reads it, and the old with(phone: ...) still works but raises an E_USER_DEPRECATED pointing you over to withPhones().

/** @deprecated the first of phones, kept while the API sends it (Pingback's ADR-0007); read phones */
public ?string $phone,

There’s a real cost to doing it this way, though, because none of it reaches a client at runtime. An integration that was written against the old document, and never looks at it again, isn’t going to find out from a response. That’s the trade-off, and I’d still make it, because the alternative is a runtime signal that tells everyone something that isn’t true. What carries the weight instead is the old field carrying on working for as long as it needs to, and the removal waiting until there’s a new version of the representation to take it away in.

Where the header is exactly right

I still wanted Pingback to show Deprecation and Sunset doing the job they were designed for, and for that I needed something that was genuinely being retired. As it turned out, the API already had one sitting there.

You could list the contacts at one organisation with a filter, GET /contacts?organization_id=.... The contacts at an organisation are a pretty natural sub-resource of it, so they got their own route, GET /organizations/{id}/contacts, and the filter was on its way out.

This is the bit that makes the header fit. A URI’s query is part of what identifies the resource (RFC 3986, section 3), so /contacts?organization_id=X isn’t really a variant of /contacts at all, it’s a resource in its own right, and that means RFC 9745 is perfectly happy for you to deprecate it while /contacts carries on completely untouched:

HTTP/1.1 200 OK
Deprecation: @1793232000
Sunset: Sun, 31 Jan 2027 23:59:59 GMT
Link: <https://github.com/JustSteveKing/pingback/blob/main/architecture/adr/0008-the-organisation-filter-becomes-a-resource-and-is-deprecated.md>; rel="deprecation"

A request to /contacts without the parameter gets none of those headers, and once the sunset passes, the filtered form starts answering 410 while the plain list just keeps on working.

When I started, api-toolbox couldn’t actually express that, because it looked notices up by route, and both forms share the same one. Pingback worked around it with a small decorator to begin with, and that workaround ended up becoming a feature in api-toolbox 0.3.3, so now it’s just two attributes on the controller:

#[Deprecation(at: '2026-10-29T00:00:00Z', link: 'https://github.com/.../0008-...md', query: 'organization_id')]
#[Sunset(at: '2027-01-31T23:59:59Z', query: 'organization_id')]
final class ListContactsController

The moment it paid off

This was the part the original plan was really after all along. Pingback’s SDK runs its contract suite against the real API on every push, with Deprecations::strict() switched on, and on the day the API deprecated the filter, the suite went red:

GET http://127.0.0.1:8000/v1/contacts?organization_id=01m63w2hv6whz62cy099nd3rqa is deprecated
as of 2026-10-29 and stops responding after 2027-01-31 (see https://github.com/JustSteveKing/pingback/blob/main/architecture/adr/0008-the-organisation-filter-becomes-a-resource-and-is-deprecated.md).

The SDK’s contacts()->all(at: $organization) moved over to the new route in the very next commit, which was three months before the sunset rather than the morning after it. That’s really the whole promise of the header, and this time it actually got kept, because the warning reached somebody’s CI while there was still plenty of time to do something about it.

And because the header only went on the resource that was genuinely going away, it was a failure worth having. If it had been on every contact response for the sake of phone, the same suite would have gone red on every single contact call, and the one useful failure would have been buried under all of that noise.

The rule I’d use

If the thing that’s going away has its own URI, and that includes a query form of one, then the Deprecation and Sunset headers can say so, and a client can actually act on them. If it’s part of a representation, like a field or a nested property, the header can’t say it without saying something that isn’t true, so the contract has to do it instead, with deprecated: true in the OpenAPI document, @deprecated in the SDK, and the old field kept working until a new version can drop it.

I’ve written both decisions up in Pingback’s ADR-0007 and ADR-0008, with the code sitting right beside them.

So the next time you reach for Deprecation, it’s worth taking a second to look at what’s actually at the URI first, because if the whole resource is going then the header’s exactly what you want, but if it’s just one field, it’s the contract you need to change.

Related

More in API Design

All posts →

Same subject

In other formats

All of API Design →