Our LinkedIn integration worked for weeks. Then, with no deploy on our side and no email from LinkedIn, every publish started failing with HTTP 426 and a body containing NONEXISTENT_VERSION. Nothing in our code had changed. That is the confusing part, and it is why this error costs people an afternoon.
This is a short account of what is actually happening, written from fixing it in production rather than from the docs.
What 426 NONEXISTENT_VERSION actually means
LinkedIn’s REST API is versioned by a required header on every single request. Not a URL path segment, not a query parameter, not an Accept header — a bespoke header:
- LinkedIn-Version: 202606
- X-Restli-Protocol-Version: 2.0.0
The version is a YYYYMM string. LinkedIn ships a new one roughly monthly and supports each for about twelve months after release. When your version passes out of that window it is not deprecated with a warning — it stops existing. Every request then returns 426 Upgrade Required with NONEXISTENT_VERSION in the body.
The second header is unrelated to versioning and catches people separately. X-Restli-Protocol-Version identifies the Rest.li protocol serialisation, not the API version, and its value is a fixed 2.0.0 that does not change with releases. Omitting it produces different and less obvious failures — usually a malformed-request error about how your parameters were encoded, which sends you looking at your JSON body rather than your headers.
So the failure is time-triggered, not deploy-triggered. Your code is byte-for-byte identical to the day it worked. That mismatch between "I changed nothing" and "everything is broken" is the whole reason this is worth writing down — it defeats the first debugging instinct everyone has, which is to look at the diff.
Why you get no warning
There is no runtime deprecation warning in the response while your version is still valid. There is no failing-soft period, no Sunset header counting down, no elevated error rate that trends upward before the cliff. The header is either in the supported window or it is not, and the transition between those two states happens on a date, silently, on LinkedIn’s side.
The result is a step function. Your error rate for that platform goes from zero to one hundred percent between two consecutive requests. If your alerting is threshold-based on total job failures across every platform, a single integration going fully dark may not clear the threshold at all — the other platforms keep succeeding and the aggregate looks merely worse, not broken.
If your integration is a side feature that a handful of users touch, you will most likely learn about it from a user rather than from your monitoring. That is how we learned about it.
The fix, and the fix that matters
The immediate fix is to bump the header to a current version. That takes a minute and it is not the interesting part. The fix that matters is making sure the next bump is a one-line change rather than a search across your codebase.
The version had been hardcoded inline at every call site. That is the actual bug — the stale string was only a symptom. It is an easy shape to end up in, because each call site is written at a different time and copying the header block from the one above it is the path of least resistance. By the time it matters you have the same magic string in the image-upload initialiser, the binary upload, the publish call and whatever you added last month, and you will miss one.
- Define the version once, near the top of the adapter, in a named constant with a comment saying that it expires and roughly when.
- Reference that constant from every request that talks to /rest/*. Grep for the literal string afterwards and make sure the only hit is the constant.
- Treat a 426 anywhere in your logs as "bump the constant", not as an outage to debug. Match on the error name, not the status code alone — 426 from anything else means something different.
- Put a calendar reminder roughly nine months out. This is unglamorous and it is the only mechanism that actually works, because there is no signal to alert on until it has already broken.
A stronger version of the same idea, if the integration is load-bearing for you: run a scheduled synthetic that makes one cheap authenticated GET against a /rest/* endpoint and alerts on a 426. It converts a silent time bomb into a page, and it costs one request a day.
One caveat worth knowing while you are in here: not every LinkedIn endpoint wants the header. The older /v2/* endpoints, including /v2/userinfo which you probably use to fetch the signed-in member’s profile, predate the versioning scheme and do not take a version header at all. Only the /rest/* endpoints do. Sending it where it is not expected is harmless; forgetting it where it is required is a 426. This is also why a token that works fine for reading a profile can fail every publish — the two calls go to endpoints with different rules.
The other version-shaped traps in the same API
Once you are sending the header correctly, two more things about this API have the same character: correct-looking code that fails on a condition you did not test.
The first is the created post’s identifier. A successful publish returns the new post URN in an x-restli-id response header, not in the response body. If you parse the body looking for an id you will find nothing useful, conclude the call half-failed, and possibly retry a post that already went out. Read the header.
The second is image count. LinkedIn has two different content shapes for attached media and the boundary between them is not where you would guess. A single image goes through the singular media content type. Two or more go through multiImage, which is documented as accepting between 2 and 20 images. Send one image through multiImage and you get a 422 telling you the number of images found is out of range — which is a good error message, and still surprising the first time, because a one-element array is the obvious way to write it. Branch on the count.
The image upload itself is a three-step dance that is easy to get half-right: POST to /rest/images with an initializeUpload action to register the upload, which returns both an upload URL and an asset URN; PUT the raw bytes to that upload URL; then reference the asset URN in the post body. The URN is what goes in the post, not the URL, and the upload URL is single-use.
The second surprise: there is no refresh token
There is a related problem that catches people later and is worth designing for now. LinkedIn’s standard authorization-code flow does not issue a refresh token to most applications. Programmatic refresh is gated behind a separate approval that most small products do not have.
The practical consequence: access tokens expire after roughly sixty days and the only way to get a new one is for the human to click through the consent screen again. There is no background job that can fix this for them, and no clever token-rotation scheme that gets around it.
That is not a bug you can code around, so build for it instead:
- Store the token expiry when you receive it, and treat it as a real deadline rather than an edge case. The token response gives you an expires_in; convert it to an absolute timestamp at the moment you receive it.
- Flip the account into an explicit needs-reconnecting state before it expires, not after a publish has already failed. A sweep that looks ahead by a few days is enough.
- Make reconnecting re-authorise onto the existing account record rather than creating a duplicate — otherwise every reconnection leaves a dead row behind, and scheduled posts that were queued against the old row quietly point at a stale credential.
- Tell the user why. "LinkedIn requires you to reconnect every 60 days" is an answerable complaint; a silent failure is not.
- Make sure a refresh attempt that is structurally impossible fails loudly at the right layer. An adapter that cannot refresh should say so explicitly, so the account gets marked for reconnection rather than retried on a loop forever.
The failure mode you are avoiding is the quiet one: an account that looks connected in your UI, and silently drops every scheduled post on the floor. Between the version cliff and the token expiry, LinkedIn gives you two independent ways to arrive there, both of them time-triggered, neither of them announced.
A checklist for a LinkedIn posting integration
- Send LinkedIn-Version on every /rest/* request, from a single constant.
- Send X-Restli-Protocol-Version: 2.0.0 alongside it — it is protocol, not version, and its value never changes.
- Do not send either to /v2/* endpoints. They predate the versioning scheme.
- Expect 426 NONEXISTENT_VERSION roughly annually. Alert on the error name, and keep a calendar reminder as the real backstop.
- Read the created post URN from the x-restli-id response header, not the body.
- Branch media handling on image count: one image is a different content shape from two or more.
- Register, PUT, then reference the asset URN — the upload URL is not what goes in the post.
- Assume no refresh token. Design the reconnect flow before you need it, and make reconnect upsert rather than insert.
- For personal-profile posting, w_member_social on an app in development mode with the posting user as a tester is enough — no app review.
Why we care about this more than most
seenpaid publishes to 21 platforms, which means maintaining 23 of these relationships and inheriting every one of their quirks. LinkedIn’s versioned header is one. Meta has a typo in its own setup guide that sends people to a scope that does not exist. Bluesky accepts posts whose links are silently unclickable. A sandboxed TikTok app reports success on posts nobody can see. Each one is a day someone loses, and none of them appear in the getting-started guide.
That is most of the argument for not building this yourself. The other part is what happens after the post goes out: the link in each post is tracked, the seller’s own payment data is read back, and you get told which posts actually produced revenue — which is the question all the API work was in service of in the first place.