{"id":21,"date":"2026-10-16T09:00:00","date_gmt":"2026-10-16T03:00:00","guid":{"rendered":"https:\/\/nazmulrobin.com\/blog\/?p=21"},"modified":"2026-10-11T10:29:58","modified_gmt":"2026-10-11T04:29:58","slug":"share-wordpress-posts-on-linkedin","status":"publish","type":"post","link":"https:\/\/nazmulrobin.com\/blog\/share-wordpress-posts-on-linkedin\/","title":{"rendered":"Sharing WordPress posts on LinkedIn with my own small plugin"},"content":{"rendered":"\n<p>When I publish a post here, I want it on my LinkedIn profile too. I did not want a large social sharing plugin for one network, so I wrote the feature into this blog&#8217;s own site plugin. It is one PHP file.<\/p>\n<p>This is the second post about how this blog is built. The first one covers <a href=\"https:\/\/nazmulrobin.com\/blog\/laravel-and-wordpress-on-one-domain\/\">running WordPress at \/blog on a Laravel site<\/a>.<\/p>\n<h2>What it does<\/h2>\n<ul>\n<li><strong>Automatic mode<\/strong> shares a post the first time it is published. That includes scheduled posts, which are shared when they go live.<\/li>\n<li><strong>Manual mode<\/strong> adds a &#8220;Share on LinkedIn&#8221; link under each published post in the Posts list.<\/li>\n<li><strong>A post is shared once.<\/strong> After that the link reads &#8220;Shared on LinkedIn&#8221; and opens the LinkedIn post.<\/li>\n<li><strong>Pages, drafts and password-protected posts are never shared.<\/strong><\/li>\n<\/ul>\n<p>The share is a link post: the post title and excerpt as the text, and the post URL as the article.<\/p>\n<h2>Part one: the LinkedIn app<\/h2>\n<p>This is the part that looks harder than it is. It took me about fifteen minutes.<\/p>\n<ol>\n<li>\n<p><strong>Create a LinkedIn Page.<\/strong> LinkedIn will not let you create an app without linking it to a Page, even when you only want to post to your own profile. A basic company page is enough.<\/p>\n<\/li>\n<li>\n<p><strong>Create the app<\/strong> at <a href=\"https:\/\/www.linkedin.com\/developers\/apps\">linkedin.com\/developers\/apps<\/a> and select that Page.<\/p>\n<\/li>\n<li>\n<p><strong>Add two products<\/strong> on the app&#8217;s Products tab: &#8220;Share on LinkedIn&#8221; and &#8220;Sign In with LinkedIn using OpenID Connect&#8221;. Both were granted to me straight away.<\/p>\n<\/li>\n<li>\n<p><strong>Add the redirect URL<\/strong> on the Auth tab. This is the URL LinkedIn sends you back to after you approve. Mine is an <code>admin-post.php<\/code> URL:<\/p>\n<pre><code>https:\/\/example.com\/blog\/wp-admin\/admin-post.php?action=myblog_linkedin_callback<\/code><\/pre>\n<\/li>\n<li>\n<p><strong>Copy the Client ID and Client Secret<\/strong> into the plugin&#8217;s settings page.<\/p>\n<\/li>\n<\/ol>\n<p>The first product gives the <code>w_member_social<\/code> permission, which allows posting as you. The second gives <code>openid<\/code> and <code>profile<\/code>, which the plugin needs to find out your member ID.<\/p>\n<h2>Part two: connecting<\/h2>\n<p>Connecting is a standard OAuth flow with three steps.<\/p>\n<p><strong>Send the user to LinkedIn.<\/strong> The <code>state<\/code> value is a WordPress nonce, so the callback can check that the request started here:<\/p>\n<pre><code class=\"language-php hljs\">$url = add_query_arg(\n    <span class=\"hljs-keyword\">array<\/span>(\n        <span class=\"hljs-string\">'response_type'<\/span> =&gt; <span class=\"hljs-string\">'code'<\/span>,\n        <span class=\"hljs-string\">'client_id'<\/span>     =&gt; rawurlencode( $settings[<span class=\"hljs-string\">'client_id'<\/span>] ),\n        <span class=\"hljs-string\">'redirect_uri'<\/span>  =&gt; rawurlencode( myblog_linkedin_redirect_uri() ),\n        <span class=\"hljs-string\">'state'<\/span>         =&gt; wp_create_nonce( <span class=\"hljs-string\">'myblog_linkedin_oauth'<\/span> ),\n        <span class=\"hljs-string\">'scope'<\/span>         =&gt; rawurlencode( <span class=\"hljs-string\">'openid profile w_member_social'<\/span> ),\n    ),\n    <span class=\"hljs-string\">'https:\/\/www.linkedin.com\/oauth\/v2\/authorization'<\/span>\n);<\/code><\/pre>\n<p><strong>Swap the code for a token.<\/strong> LinkedIn redirects back with a <code>code<\/code>. The callback checks the nonce and the user&#8217;s capability, then exchanges it:<\/p>\n<pre><code class=\"language-php hljs\">$token = wp_remote_post(\n    <span class=\"hljs-string\">'https:\/\/www.linkedin.com\/oauth\/v2\/accessToken'<\/span>,\n    <span class=\"hljs-keyword\">array<\/span>(\n        <span class=\"hljs-string\">'timeout'<\/span> =&gt; <span class=\"hljs-number\">15<\/span>,\n        <span class=\"hljs-string\">'body'<\/span>    =&gt; <span class=\"hljs-keyword\">array<\/span>(\n            <span class=\"hljs-string\">'grant_type'<\/span>    =&gt; <span class=\"hljs-string\">'authorization_code'<\/span>,\n            <span class=\"hljs-string\">'code'<\/span>          =&gt; $code,\n            <span class=\"hljs-string\">'redirect_uri'<\/span>  =&gt; myblog_linkedin_redirect_uri(),\n            <span class=\"hljs-string\">'client_id'<\/span>     =&gt; $settings[<span class=\"hljs-string\">'client_id'<\/span>],\n            <span class=\"hljs-string\">'client_secret'<\/span> =&gt; $settings[<span class=\"hljs-string\">'client_secret'<\/span>],\n        ),\n    )\n);<\/code><\/pre>\n<p><strong>Ask who the token belongs to.<\/strong> Posting needs your member ID. The <code>userinfo<\/code> endpoint returns it as <code>sub<\/code>:<\/p>\n<pre><code class=\"language-php hljs\">$profile = wp_remote_get(\n    <span class=\"hljs-string\">'https:\/\/api.linkedin.com\/v2\/userinfo'<\/span>,\n    <span class=\"hljs-keyword\">array<\/span>(\n        <span class=\"hljs-string\">'timeout'<\/span> =&gt; <span class=\"hljs-number\">15<\/span>,\n        <span class=\"hljs-string\">'headers'<\/span> =&gt; <span class=\"hljs-keyword\">array<\/span>( <span class=\"hljs-string\">'Authorization'<\/span> =&gt; <span class=\"hljs-string\">'Bearer '<\/span> . $token[<span class=\"hljs-string\">'access_token'<\/span>] ),\n    )\n);<\/code><\/pre>\n<p>The plugin stores the token, its expiry time and the member ID in one option. That option is saved with autoload off, so the secret and token are not loaded on every page view.<\/p>\n<h2>Part three: sharing a post<\/h2>\n<p>Sharing is one request to the <code>ugcPosts<\/code> endpoint:<\/p>\n<pre><code class=\"language-php hljs\">$response = wp_remote_post(\n    <span class=\"hljs-string\">'https:\/\/api.linkedin.com\/v2\/ugcPosts'<\/span>,\n    <span class=\"hljs-keyword\">array<\/span>(\n        <span class=\"hljs-string\">'timeout'<\/span> =&gt; <span class=\"hljs-number\">15<\/span>,\n        <span class=\"hljs-string\">'headers'<\/span> =&gt; <span class=\"hljs-keyword\">array<\/span>(\n            <span class=\"hljs-string\">'Authorization'<\/span>             =&gt; <span class=\"hljs-string\">'Bearer '<\/span> . $settings[<span class=\"hljs-string\">'access_token'<\/span>],\n            <span class=\"hljs-string\">'Content-Type'<\/span>              =&gt; <span class=\"hljs-string\">'application\/json'<\/span>,\n            <span class=\"hljs-string\">'X-Restli-Protocol-Version'<\/span> =&gt; <span class=\"hljs-string\">'2.0.0'<\/span>,\n        ),\n        <span class=\"hljs-string\">'body'<\/span>    =&gt; wp_json_encode(\n            <span class=\"hljs-keyword\">array<\/span>(\n                <span class=\"hljs-string\">'author'<\/span>          =&gt; <span class=\"hljs-string\">'urn:li:person:'<\/span> . $settings[<span class=\"hljs-string\">'person_id'<\/span>],\n                <span class=\"hljs-string\">'lifecycleState'<\/span>  =&gt; <span class=\"hljs-string\">'PUBLISHED'<\/span>,\n                <span class=\"hljs-string\">'specificContent'<\/span> =&gt; <span class=\"hljs-keyword\">array<\/span>(\n                    <span class=\"hljs-string\">'com.linkedin.ugc.ShareContent'<\/span> =&gt; <span class=\"hljs-keyword\">array<\/span>(\n                        <span class=\"hljs-string\">'shareCommentary'<\/span>    =&gt; <span class=\"hljs-keyword\">array<\/span>( <span class=\"hljs-string\">'text'<\/span> =&gt; trim( $title . <span class=\"hljs-string\">\"\\n\\n\"<\/span> . $excerpt ) ),\n                        <span class=\"hljs-string\">'shareMediaCategory'<\/span> =&gt; <span class=\"hljs-string\">'ARTICLE'<\/span>,\n                        <span class=\"hljs-string\">'media'<\/span>              =&gt; <span class=\"hljs-keyword\">array<\/span>(\n                            <span class=\"hljs-keyword\">array<\/span>(\n                                <span class=\"hljs-string\">'status'<\/span>      =&gt; <span class=\"hljs-string\">'READY'<\/span>,\n                                <span class=\"hljs-string\">'originalUrl'<\/span> =&gt; get_permalink( $post ),\n                                <span class=\"hljs-string\">'title'<\/span>       =&gt; <span class=\"hljs-keyword\">array<\/span>( <span class=\"hljs-string\">'text'<\/span> =&gt; $title ),\n                                <span class=\"hljs-string\">'description'<\/span> =&gt; <span class=\"hljs-keyword\">array<\/span>( <span class=\"hljs-string\">'text'<\/span> =&gt; $excerpt ),\n                            ),\n                        ),\n                    ),\n                ),\n                <span class=\"hljs-string\">'visibility'<\/span>      =&gt; <span class=\"hljs-keyword\">array<\/span>( <span class=\"hljs-string\">'com.linkedin.ugc.MemberNetworkVisibility'<\/span> =&gt; <span class=\"hljs-string\">'PUBLIC'<\/span> ),\n            )\n        ),\n    )\n);<\/code><\/pre>\n<p>A <code>201<\/code> response with an <code>id<\/code> means it worked. The plugin saves that ID in post meta, which is how it knows not to share the post again and where the &#8220;Shared on LinkedIn&#8221; link points. On failure it saves the error message instead, and the link becomes &#8220;Retry LinkedIn share&#8221;.<\/p>\n<h2>Part four: when to share<\/h2>\n<p>Automatic mode hooks <code>wp_after_insert_post<\/code>, which runs after the post and its terms and meta are saved. It hands over the post as it was before the save, so &#8220;first publish&#8221; is a simple check:<\/p>\n<pre><code class=\"language-php hljs\">add_action(\n    <span class=\"hljs-string\">'wp_after_insert_post'<\/span>,\n    <span class=\"hljs-function\"><span class=\"hljs-keyword\">function<\/span> <span class=\"hljs-params\">( $post_id, $post, $update, $post_before )<\/span> <\/span>{\n        <span class=\"hljs-keyword\">if<\/span> ( <span class=\"hljs-string\">'publish'<\/span> !== $post-&gt;post_status || ( $post_before &amp;&amp; <span class=\"hljs-string\">'publish'<\/span> === $post_before-&gt;post_status ) ) {\n            <span class=\"hljs-keyword\">return<\/span>;\n        }\n\n        <span class=\"hljs-keyword\">if<\/span> ( <span class=\"hljs-string\">'post'<\/span> === $post-&gt;post_type &amp;&amp; <span class=\"hljs-string\">'auto'<\/span> === myblog_linkedin_settings()[<span class=\"hljs-string\">'mode'<\/span>] ) {\n            myblog_linkedin_share( $post_id );\n        }\n    },\n    <span class=\"hljs-number\">10<\/span>,\n    <span class=\"hljs-number\">4<\/span>\n);<\/code><\/pre>\n<p>WordPress fires the same hook when its cron publishes a scheduled post, so those are covered without extra code. Editing a post that is already live does nothing, because the status before the save was already <code>publish<\/code>.<\/p>\n<p>Manual mode is a link added with the <code>post_row_actions<\/code> filter. It goes to an <code>admin-post.php<\/code> handler that checks a nonce and the <code>edit_post<\/code> capability before sharing.<\/p>\n<h2>Things that caught me<\/h2>\n<ul>\n<li><strong>The token lasts about 60 days<\/strong> and an app like this cannot renew it by itself. The plugin shows a warning in wp-admin from a week before, and reconnecting is one button.<\/li>\n<li><strong>It posts to a personal profile<\/strong>, not to the Page. Posting as a Page needs a different permission that LinkedIn reviews.<\/li>\n<li><strong>Notices can go missing.<\/strong> I run a plugin that removes everything hooked to <code>admin_notices<\/code>, so my own result messages vanished. They are hooked to <code>all_admin_notices<\/code> now.<\/li>\n<li><strong>Test with a mocked API first.<\/strong> I tested the logic by filtering <code>pre_http_request<\/code> to return a fake <code>201<\/code>, which let me check the share-once rule and the scheduled-post path without posting test content to my real profile.<\/li>\n<\/ul>\n<p>For one network and one author, this is about 500 lines including the settings page, and I know exactly what it does.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I wanted new blog posts to go to my LinkedIn profile, automatically or with one click, without a big social sharing plugin. This is the LinkedIn app setup and the code behind it.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[8,15,16],"class_list":["post-21","post","type-post","status-publish","format-standard","hentry","category-wordpress","tag-building-this-blog","tag-linkedin","tag-plugin-development"],"_links":{"self":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/21","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/comments?post=21"}],"version-history":[{"count":1,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/21\/revisions"}],"predecessor-version":[{"id":34,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/21\/revisions\/34"}],"wp:attachment":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/media?parent=21"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/categories?post=21"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/tags?post=21"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}