Fetch Saved Posts
Retrieve all the bookmarks that you've created. You can use the query parameters to filter and paginate results.
GET /v1/bookmarks is the endpoint most integrations start with. It returns the LinkedIn posts (and saved comments) the authenticated account has collected in LinkedMash, newest first, as a cursor-paginated list. Each item carries the post text, permalink, attachments, the author's name, bio and profile image, your labels, read and archive state, and both posted_at and imported_at timestamps — enough to mirror a library into another system without a second call.
The same filters the LinkedMash web app uses are exposed here, and they behave identically: read and unread posts are both returned unless you ask otherwise, and archived posts are hidden by default. Keyword search (q) matches post content; vector_search_term runs semantic search over the same library and needs a vector store that has been initialised at least once.
Example request
Authenticate with a bearer token in the Authorization header. Create a key on the API page in your LinkedMash settings and export it as LINKEDMASH_API_KEY before running this.
curl "https://api.linkedmash.com/v1/bookmarks?limit=20&author=Dickie%20Bush&posted_from=2026-01-01T00%3A00%3A00Z" \
-H "Authorization: Bearer $LINKEDMASH_API_KEY"Header parameters
Your secret API key. Should be provided as a Bearer token.
Query parameters
Combine these parameters to narrow your results.
The maximum number of bookmarks to return. Default is 20, maximum is 100.
Cursor for pagination. Use the next_cursor value from a previous response.
Return only unread posts. Defaults to false — read and unread are both returned unless you set this to true.
Filter by archived status. Default is true (hides archived bookmarks).
Filter by media type. Must be one of image, video or pdf; any other value returns 400. Paid plans.
Filter by comment: 'only' returns just saved comments, 'exclude' returns only top-level posts (excludes comments). Paid plans.
Filter by author name.
Filter by tag.
Filter bookmarks posted after this date/time (ISO 8601 format).
Filter bookmarks posted before this date/time (ISO 8601 format).
Filter bookmarks created after this date/time (ISO 8601 format).
Filter bookmarks created before this date/time (ISO 8601 format).
Field to sort by (e.g., 'created_at', 'title').
Value of the sort field from the last item of the previous page, used for stable sorting with pagination.
Search query to filter bookmarks by title or content.
Vector search term for semantic search.
Response
{
"status": true,
"data": [
{
"post_id": "7336731872414035968",
"post_details": {
"text": "Every single night, I run through a dead-simple 2-minute journal prompt.\n\nIt only has 5 questions.\n\nHere's how it works:",
"link": "https://www.linkedin.com/feed/update/urn:li:activity:7336731872414035968",
"posted_at": "2025-06-06T12:33:08.27+00:00",
"attachments": [
{
"type": "document",
"title": "My 2-Minute Evening Journal Prompt (5 Questions)",
"url": "https://media.licdn.com/dms/document/media/v2/D4E1FAQF_Jxg36sxpIg/feedshare-document-pdf-analyzed/B4EZdFOIHNGcAc-/0/1749213108945?e=1750291200&v=beta&t=HmRZA2gVOuUaGdOqfKz5WDWcnJ_fKWYSv-hXEhg4WvA",
"thumbnail": [
{
"url": "https://media.licdn.com/dms/document/pl/v2/D4E1FAQF_Jxg36sxpIg/feedshare-document-images_1920/B4EZdFOIHNGcAY-/0/1749213108945?e=1750291200&v=beta&t=UTLn8IylWinjDWp31RgkPOa-ImYgdpePUEclQuy-xjw",
"width": 1545,
"height": 1931
}
]
}
]
},
"author_id": "urn:li:member:414777096",
"author_details": {
"bio": "I talk about digital writing & personal progress",
"name": "Dickie Bush 🚢",
"username": "member:414777096",
"profile_image": {
"url": "https://media.licdn.com/dms/image/v2/D4E03AQFjX-BmKVA8sw/profile-displayphoto-shrink_100_100/profile-displayphoto-shrink_100_100/0/1681174265529?e=1756339200&v=beta&t=dVOLCn5tuwDg8ZPO4N5lbb4gsKyKUjRDoZGp62QMi2I",
"width": 100,
"height": 100,
"expiresAt": 1756339200000
}
},
"tags": null,
"is_read": false,
"is_archived": false,
"posted_at": "2025-06-06T12:33:08.27",
"imported_at": "2025-06-13T06:13:08.373",
"sort_index": "1749795181410"
}
],
"meta": {
"next_cursor": "MTc1OTYxNw==",
"limit": 1,
"total_count": 396
}
}Response fields
Every response is wrapped in the same envelope: a status flag and a data payload, with meta present where there is pagination or a plan limit to report.
true on success. Every LinkedMash API response carries this envelope flag alongside data, so a client can branch on it without re-reading the HTTP status.
The matching saved posts, newest first.
The LinkedIn activity id. It is the last path segment of a LinkedIn permalink and the value you pass to GET /v1/bookmarks/{id} and to the label endpoints.
The full post body as saved, including line breaks and emoji. Not truncated.
Canonical LinkedIn permalink for the post.
ISO-8601 timestamp of when the author published the post on LinkedIn.
Media and link previews carried by the post. Each entry has a type (image, video, article, document), a title, a url, and a thumbnail array of sized image variants.
LinkedIn identifier for the author, stable across posts, so you can group a library by writer.
The author's display name.
The public LinkedIn vanity handle, i.e. the /in/ segment of their profile URL.
The headline shown under the name on LinkedIn. This is the field most integrations use to infer role and company.
url, width, height and expiresAt. The URL is a LinkedIn CDN address that stops resolving after expiresAt — re-host it if you need it to persist.
The label names you have applied to this post. Empty when the post is untagged.
Whether the post has been marked read in LinkedMash. Both read and unread posts are returned unless you set is_unread_only.
Whether the post has been archived. Archived posts are hidden from list results by default.
Same as post_details.posted_at, lifted to the top level for sorting. This is the field to sort on — not created_at, which reflects when the record entered LinkedMash.
ISO-8601 timestamp of when LinkedMash first pulled the post into the library.
Opaque ordering key. The encoded form of the last item’s sort_index is exactly what meta.next_cursor contains.
Pass back as ?cursor= for the next page. Present only when the response returned exactly `limit` items; null means you have reached the end, and it is always null on a free-tier response.
The limit actually applied, after clamping. Compare it with what you sent to detect the 100 ceiling or the free-tier cap of 5.
Total posts matching the filter, ignoring pagination.
Present only on free-tier responses, as { limited: true, reason: "FREE_TIER_LIMIT" }. Its absence is how you tell a genuinely short library from a truncated one.
Mirroring a saved-posts library
The common job is not one call — it is walking the whole library once, then keeping it current. This is the sequence.
Size the job first
Call GET /v1/bookmarks/count with the same filters you plan to page. It costs one request and tells you whether the sync is 40 posts or 4,000, which is the difference between doing it inline and doing it on a queue.
Page with the cursor until it runs out
Request with limit=100, read meta.next_cursor, and pass it back as ?cursor= on the next call. Stop when next_cursor is null. Do not stop on a short page — a page can be short and still have a cursor.
Store sort_index, not your own counter
Each post carries sort_index, and the cursor is derived from the last item's. Persisting it means a resumed sync picks up exactly where it stopped rather than re-walking from the top.
Keep it current with bookmarked_from
For incremental runs, pass bookmarked_from set to the timestamp of your last successful sync. That returns only what has been saved since, which on most accounts is a single small page.
Handle the free-tier shape explicitly
If meta.subscription is present the response was truncated to 5 with no cursor. Treat that as 'cannot sync' and say so, rather than writing 5 posts and reporting success.
Behaviour and limits
Page with the cursor, never with an offset
A response only includes next_cursor when it returned exactly `limit` items, so an empty next_cursor is the end of the list rather than a hint to try again. Pass it back as ?cursor= to get the next page. Combine it with sort_cursor when you also set sort_by, otherwise ordering can drift between pages.
Free accounts are capped at 5 and cannot page
Without an active subscription the limit is forced to 5, next_cursor is nulled, has_more is false, and meta.subscription is set to { limited: true, reason: "FREE_TIER_LIMIT" }. Sending an explicit limit or cursor on a free key returns 402 with code PAGINATION_NOT_ALLOWED rather than silently truncating.
Filtering is a paid capability; keyword search is not
media_type, author, tag, posted_from, posted_to, bookmarked_from, bookmarked_to and comment all return 402 with code FILTER_NOT_ALLOWED on a free key. Plain listing and q search still work, which is what makes q the right first call when you do not know the plan behind a key.
Paid limits: 20 by default, 100 max, 50 on semantic search
limit defaults to 20 and is clamped to 100. The vector_search_term path is clamped lower, to 50, and returns has_more: false because semantic results are ranked rather than paged.
media_type accepts image, video and pdf only
Anything else — including 'article' — is rejected with 400 before the query runs. comment accepts only 'only' (saved comments) or 'exclude' (top-level posts). limit must be a positive integer.
Semantic search needs a vector store
vector_search_term is paid-only (402 VECTOR_SEARCH_NOT_ALLOWED on free). If the account has never initialised a vector store the call returns 500 with "Vector Store Not Setup" — run the setup step once and the same request starts working.
Errors
Failures carry the same envelope as a success, with the reason in message and a stable machine-readable code where the status alone is ambiguous — several distinct conditions share a 402.