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

What happens when postiee creates a post Five steps: read credentials, resolve the member URN from the OpenID userinfo endpoint, optionally upload an image in two steps, create the post and read the ID from the x-restli-id response header, then record it in a local SQLite database with full-text search. 1. read credentials ~/.config/postiee/ 2. resolve member URN GET /v2/userinfo openid scope TRAP person ids are app-scoped 3. upload image (opt) initializeUpload → PUT → urn two steps, no finalizeUpload TRAP images have NO finalizeUpload step — that is a Videos API concept 4. create the post POST /rest/posts → 201 Created body is EMPTY — id is in x-restli-id LinkedIn-Version: 202609 THE TRAP THAT MATTERS r_member_social is CLOSED. No API can read your posts back. Miss this id → post is unrecoverable. 5. record locally SQLite + FTS5 index post_id, body, media_urn, created_at → postiee history / search WHY THIS IS THE PRODUCT This local record is the only copy that will ever exist. LinkedIn's own export is static, 72h, desktop-only.
Every network call 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.

The three-tier LinkedIn scope gate Tier one, self-serve, granted immediately by the Share on LinkedIn and Sign In with LinkedIn products: posting and identifying yourself. Tier two, Community Management API, requires vetted partner approval: comments, reactions, analytics, reading posts back. Tier three, closed entirely, no public API at any tier: messaging and connection invitations. TIER 1 — SELF-SERVE Granted instantly. No review, no payment. w_member_social create a post openid + profile identify yourself ✓ postiee works here post · history · search TIER 2 — COMMUNITY MANAGEMENT Vetted partner review. Requires a business, legal entity, and an agreement to the Marketing API terms. w_member_social_feed comment · react r_member_postAnalytics impressions · followers ✗ not in postiee would 403 on first use r_member_social — reading your OWN posts — is CLOSED, not gated. Not accepting requests at this time. TIER 3 — CLOSED AT EVERY TIER No product in the developer catalogue. No endpoint in the docs. Explicitly prohibited use. messaging no DM API exists invitations no networking API ✗ never implementable at any access level
Three tiers. postiee operates entirely inside the first.

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.