Internals
How it works
Three details decide whether a LinkedIn CLI works at all. Get any of them wrong and you get a tool that appears to succeed and loses data.
The post lifecycle
postiee post makes, in order.
Trap one — the ID is in a header
POST /rest/posts returns 201 Created with an
empty body. The post's ID arrives in the
x-restli-id response header:
HTTP/1.1 201 Created
x-restli-id: urn:li:ugcPost:1234567890123456789
<no body> A client that reads the body finds nothing, and if it doesn't check, reports success anyway. Because LinkedIn will not let any API read your posts back, that ID is the only handle you will ever have on your own post. Lose it and the post is unreachable — not hidden, unreachable.
postiee treats a missing header as a hard error that says the post may already exist. That way you check before retrying instead of posting twice.
Trap two — person IDs are scoped per app
Every post needs an author field, something like
urn:li:person:8675309. That identifier is
unique to your application. The same human has a different
ID under a different app, and copying one across apps returns 404.
GET https://api.linkedin.com/v2/userinfo
Authorization: Bearer $TOKEN
{ "sub": "AbCdEfGh1Jk", "name": "Your Name" } author = "urn:li:person:" + sub
This must be resolved at runtime, every time. It cannot be a config value or
a literal, and it requires the openid scope.
Trap three — URNs need full percent-encoding
URNs in URL paths must be completely encoded:
urn:li:ugcPost:12345 → urn%3Ali%3AugcPost%3A12345
Go's url.PathEscape does not do this — colons are legal
in a path segment, so it leaves them bare and the request 404s. postiee uses
QueryEscape, which does. A test pins this, because the failure
looks like "that post doesn't exist" rather than "you escaped it wrong".
Taking a post down
Deletion is the one write that cannot be undone, so it is worth being explicit about what happens:
postiee unpublish urn:li:share:12345
→ DELETE /rest/posts/urn%3Ali%3Ashare%3A12345
→ 204 No Content
→ local row removed The order matters. postiee deletes on LinkedIn first and drops the local record only after LinkedIn confirms, so a refusal leaves your history intact and the post still findable. Doing it the other way round would destroy the only identifier you have for a post that is still live.
There is no undo. Once the post is gone, nothing can
reconstruct it — not postiee, and not LinkedIn, because
r_member_social is closed and no API can read posts back.
Check the URN, and use --dry-run first.
This sits inside the self-serve tier. It needs no partner approval and no
scope beyond w_member_social, which is the same one
post already uses. Reading a post back is a different
story — that is partner-gated.
Two more things worth knowing
Images are two steps, not three
initializeUpload returns a signed URL and the final
image URN. You PUT the bytes and you are done. There is no
finalizeUpload for images — that step belongs to the Videos API,
and assuming otherwise is the most common way people get stuck here.
The version header is a moving target
Every versioned route needs Linkedin-Version: YYYYMM. A version
is supported for about twelve months and then sunset, so this is not a
permanent constant. postiee pins it in one place, currently
202609, which is a single-line change when LinkedIn sunsets it.
The scope gate
What you can reach depends entirely on which LinkedIn products your developer app has been granted — and one of those permissions is closed outright.
This gate is the reason the tool is shaped the way it is. Because
r_member_social is closed, reading posts back is impossible —
so the local record becomes the product rather than a feature. Because
comments and analytics are partner-gated, there is nothing to sync — so the
CLI stays small enough to be correct.
Storage
History lives in a single SQLite file with an FTS5 index over post bodies. WAL journalling, one row per post, upsert on the post ID so retrying never duplicates.
CREATE TABLE posts (
post_urn TEXT PRIMARY KEY,
body TEXT NOT NULL,
permalink TEXT NOT NULL DEFAULT '',
media_urn TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL DEFAULT 'PUBLIC',
created_at INTEGER NOT NULL
);
CREATE VIRTUAL TABLE posts_fts USING fts5(
post_urn UNINDEXED,
body,
tokenize = 'porter unicode61'
); Search behaviour
The porter unicode61 tokenizer gives stemming, so
authentic finds authentication. It indexes
whole tokens, so a partial-word prefix does not match:
search harn will not find harness.
That limit is stated plainly rather than hidden behind a workaround, because a search that silently returns nothing is worse than one that documents its boundary.