Posting to LinkedIn from Oracle APEX: A Complete Beginner's Guide
Free oracleapex.com tier · personal LinkedIn profile posting · step-by-step with every real error included
If you've ever wanted a button in your Oracle APEX app that posts directly to your personal LinkedIn feed — no Zapier, no third-party middleman, just your own APEX app talking straight to LinkedIn's API — this guide walks through the whole thing, start to finish, including every mistake made along the way so you don't have to repeat them.
By the end, you'll have:
- A LinkedIn Developer App configured for personal posting
- Two APEX pages: one to connect/authorize, one that handles LinkedIn's callback and does the posting
- A working "Post to LinkedIn" button that publishes text posts to your own profile
This is written for a free oracleapex.com workspace, but the same steps work on any hosted or on-premises APEX instance — just adjust the domain.
The Big Picture — How It All Connects
Before touching any code, it helps to see the shape of the whole thing. This is a standard OAuth2 Authorization Code flow — the same pattern used by "Sign in with Google" or "Sign in with Facebook." The difference here is we're using it to get permission to post on your behalf, not to log you in.
Part 1 — Create Your LinkedIn Developer App
Go to linkedin.com/developers/apps and click Create app.
Fill in the app name and logo.
The "LinkedIn Page" field trips up most beginners
LinkedIn requires every developer app to be linked to a Company Page, even if you only want to post to your own personal profile. You don't need a real business page:
- Start typing into the field:
Default Company Page for Individual Developer - Select it from the autocomplete dropdown — a real, LinkedIn-provided placeholder made exactly for this situation.
Add a privacy policy URL — any generic CCPA/GDPR-compliant link works, since you're only handling your own data.
Click Create app.
Add the Products You Need
Go to the Products tab and add:
- Sign In with LinkedIn using OpenID Connect
- Share on LinkedIn
Both products are self-serve — they activate instantly, no LinkedIn review team involved. That manual review only applies if you need w_organization_social for a real Company Page, which you don't for personal posting. Just confirm both show status "Added" before moving on.
Get Your Client ID and Client Secret
Go to the Auth tab. Copy the Client ID and Client Secret somewhere safe — you'll need them shortly. Leave this tab open; you'll come back to register your redirect URL.
Part 2 — Create Settings Tables in APEX
In SQL Workshop → SQL Commands, run:
SQL · settings tableCREATE TABLE li_oauth_settings (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
client_id VARCHAR2(200),
client_secret VARCHAR2(200),
access_token VARCHAR2(4000),
refresh_token VARCHAR2(4000),
expires_at TIMESTAMP,
person_urn VARCHAR2(200)
);
INSERT INTO li_oauth_settings (client_id, client_secret)
VALUES ('PASTE_YOUR_CLIENT_ID', 'PASTE_YOUR_CLIENT_SECRET');
COMMIT;
This stores your secret in a plain database table rather than an encrypted credential store. For a personal, single-user tool this is a reasonable tradeoff — just never export this table's data when sharing your app.
A second table is genuinely useful too — a simple debug log that will save you hours later:
SQL · debug log tableCREATE TABLE li_debug_log (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
log_time TIMESTAMP DEFAULT SYSTIMESTAMP,
tag VARCHAR2(50),
message CLOB
);
Part 3 — Build Page 1: The "Connect" Page
This page's only job: build LinkedIn's authorization URL and redirect the browser there.
Create a new blank page called "LinkedIn Connect."
Add a hidden page item: P1_REDIRECT_URL
Add a button: BTN_CONNECT
Add a Dynamic Action → Execute Server-Side Code on the button:
PL/SQL · build the LinkedIn auth URLDECLARE
l_redirect_uri VARCHAR2(500) := 'https://oracleapex.com/ords/r/YOUR_WORKSPACE/YOUR_APP_ALIAS/page-2';
l_client_id VARCHAR2(200);
l_url VARCHAR2(1000);
BEGIN
SELECT client_id INTO l_client_id FROM li_oauth_settings WHERE id = 1;
l_url := 'https://www.linkedin.com/oauth/v2/authorization'
|| '?response_type=code'
|| '&client_id=' || l_client_id
|| '&redirect_uri=' || APEX_UTIL.URL_ENCODE(l_redirect_uri)
|| '&scope=' || APEX_UTIL.URL_ENCODE('openid profile w_member_social')
|| '&state=xyz123';
:P1_REDIRECT_URL := l_url;
END;
Follow with a "Redirect to URL" True Action pointing at &P1_REDIRECT_URL. — this actually sends the browser to LinkedIn.
Getting the Redirect URI Exactly Right (the biggest time-sink)
The l_redirect_uri value must be the exact, real URL of your Page 2, registered character-for-character in LinkedIn's Auth tab. Run this to get it:
SQL · find your real page URLSELECT apex_page.get_url(p_application => YOUR_APP_ID, p_page => 2) FROM dual;
1. Drop everything from ?session= onward — that ID is tied to your current login and breaks the moment your session changes.
2. Prepend your actual domain. On a free workspace this is https://oracleapex.com — not https://apex.oracle.com, which is a completely different, Oracle-hosted domain. Mixing these up produces LinkedIn's "redirect_uri does not match the registered value" error.
The final, correct value should look like:
https://oracleapex.com/ords/r/pothiarun/linkedin-integration-application/page-2
Paste this exact string into LinkedIn's Auth tab → Authorized redirect URLs for your app, and click Update.
Part 4 — Build Page 2: The Callback Page
This is where LinkedIn sends the browser back after you click "Allow." It needs to read the code, exchange it for a token, look up your member ID, and give you a textarea + button to post.
Create Two Page Items
CODE(Hidden) — name must match exactly, since APEX auto-maps query string parameters to items with matching names.STATE(Hidden) — same reasoning.
Make the Page Public
LinkedIn's redirect is an anonymous browser request with no logged-in APEX session. If Page 2 requires authentication, APEX intercepts it and forces a login screen — and the code parameter gets lost in that bounce.
Page 2 → page-level node in Page Designer → Security section → set Authentication / "Page is Public" to Yes.
Add the Token-Exchange Process (Pre-Rendering)
PL/SQL · Page 2, Pre-Rendering, sequence 10DECLARE
l_resp CLOB;
l_client_id VARCHAR2(200);
l_client_secret VARCHAR2(200);
l_redirect_uri VARCHAR2(500) := 'https://oracleapex.com/ords/r/YOUR_WORKSPACE/YOUR_APP_ALIAS/page-2';
BEGIN
INSERT INTO li_debug_log (tag, message) VALUES ('A_START', 'CODE=' || :CODE);
COMMIT;
IF :CODE IS NOT NULL AND LENGTH(:CODE) > 0 THEN
SELECT client_id, client_secret INTO l_client_id, l_client_secret
FROM li_oauth_settings WHERE id = 1;
APEX_WEB_SERVICE.g_request_headers.delete;
APEX_WEB_SERVICE.g_request_headers(1).name := 'Content-Type';
APEX_WEB_SERVICE.g_request_headers(1).value := 'application/x-www-form-urlencoded';
l_resp := APEX_WEB_SERVICE.make_rest_request(
p_url => 'https://www.linkedin.com/oauth/v2/accessToken',
p_http_method => 'POST',
p_body => 'grant_type=authorization_code'
|| '&code=' || APEX_UTIL.URL_ENCODE(:CODE)
|| '&client_id=' || APEX_UTIL.URL_ENCODE(l_client_id)
|| '&client_secret=' || APEX_UTIL.URL_ENCODE(l_client_secret)
|| '&redirect_uri=' || APEX_UTIL.URL_ENCODE(l_redirect_uri)
);
INSERT INTO li_debug_log (tag, message) VALUES ('B_RESPONSE', l_resp);
COMMIT;
UPDATE li_oauth_settings
SET access_token = JSON_VALUE(l_resp, '$.access_token'),
refresh_token = JSON_VALUE(l_resp, '$.refresh_token' NULL ON ERROR),
expires_at = SYSTIMESTAMP + NUMTODSINTERVAL(JSON_VALUE(l_resp,'$.expires_in'),'SECOND')
WHERE id = 1 AND JSON_VALUE(l_resp, '$.access_token') IS NOT NULL;
COMMIT;
:CODE := NULL; -- prevent this same code being reused on a later page load
ELSE
INSERT INTO li_debug_log (tag, message) VALUES ('A_SKIP', 'no code present');
COMMIT;
END IF;
EXCEPTION
WHEN OTHERS THEN
INSERT INTO li_debug_log (tag, message) VALUES ('C_EXCEPTION', SQLERRM);
COMMIT;
END;
1. Encode every field — including client_id and client_secret, not just code and redirect_uri. An un-encoded special character in the secret corrupts the form body and LinkedIn silently rejects it with invalid_client, even though the stored secret is completely correct. This one cost the most debugging time in the whole project.
2. Clear :CODE after use — LinkedIn's authorization codes are single-use. Without nulling it, a stale code gets resubmitted on the next page reload, LinkedIn rejects it, and without a guard on the WHERE clause that failed response can silently overwrite a previously-good token with NULL.
Add the "Fetch My Member ID" Process (runs after the one above)
PL/SQL · Page 2, Pre-Rendering, sequence 20DECLARE
l_resp CLOB;
l_token VARCHAR2(4000);
BEGIN
SELECT access_token INTO l_token FROM li_oauth_settings WHERE id = 1;
IF l_token IS NOT NULL THEN
APEX_WEB_SERVICE.g_request_headers.delete;
APEX_WEB_SERVICE.g_request_headers(1).name := 'Authorization';
APEX_WEB_SERVICE.g_request_headers(1).value := 'Bearer ' || l_token;
l_resp := APEX_WEB_SERVICE.make_rest_request(
p_url => 'https://api.linkedin.com/v2/userinfo',
p_http_method => 'GET'
);
INSERT INTO li_debug_log (tag, message) VALUES ('D_USERINFO', l_resp);
COMMIT;
UPDATE li_oauth_settings
SET person_urn = 'urn:li:person:' || JSON_VALUE(l_resp, '$.sub')
WHERE id = 1;
COMMIT;
END IF;
END;
Add the posting UI to Page 2 as well: a textarea item P2_COMMENT and a button BTN_POST.
Part 5 — The Reusable Posting Procedure
Create this once, in SQL Workshop:
PL/SQL · post_to_linkedin procedureCREATE OR REPLACE PROCEDURE post_to_linkedin (
p_content IN CLOB
) IS
l_body CLOB;
l_resp CLOB;
l_status_code NUMBER;
l_token VARCHAR2(4000);
l_person_urn VARCHAR2(200);
BEGIN
SELECT access_token, person_urn INTO l_token, l_person_urn
FROM li_oauth_settings WHERE id = 1;
l_body := '{
"author": "' || l_person_urn || '",
"commentary": ' || APEX_JSON.stringify(p_content) || ',
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": []
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": false
}';
APEX_WEB_SERVICE.g_request_headers.delete;
APEX_WEB_SERVICE.g_request_headers(1).name := 'Authorization';
APEX_WEB_SERVICE.g_request_headers(1).value := 'Bearer ' || l_token;
APEX_WEB_SERVICE.g_request_headers(2).name := 'LinkedIn-Version';
APEX_WEB_SERVICE.g_request_headers(2).value := '202506';
APEX_WEB_SERVICE.g_request_headers(3).name := 'X-Restli-Protocol-Version';
APEX_WEB_SERVICE.g_request_headers(3).value := '2.0.0';
APEX_WEB_SERVICE.g_request_headers(4).name := 'Content-Type';
APEX_WEB_SERVICE.g_request_headers(4).value := 'application/json';
l_resp := APEX_WEB_SERVICE.make_rest_request(
p_url => 'https://api.linkedin.com/rest/posts',
p_http_method => 'POST',
p_body => l_body
);
l_status_code := APEX_WEB_SERVICE.g_status_code;
IF l_status_code != 201 THEN
RAISE_APPLICATION_ERROR(-20001,
'LinkedIn API error (' || l_status_code || '): ' || l_resp);
END IF;
END post_to_linkedin;
/
LinkedIn's newer /rest/posts endpoint (not the deprecated /v2/ugcPosts) requires the LinkedIn-Version header in YYYYMM format plus X-Restli-Protocol-Version: 2.0.0. A successful call returns HTTP 201 with an empty body — the new post's ID comes back in a response header, not the JSON.
Wire Up the Post Button
On BTN_POST, add a Dynamic Action → Execute Server-Side Code (After Submit):
PL/SQL · button actionBEGIN
post_to_linkedin(:P2_COMMENT);
apex_application.g_print_success_message := 'Posted to LinkedIn!';
EXCEPTION
WHEN OTHERS THEN
apex_error.add_error(
p_message => 'Failed to post: ' || SQLERRM,
p_display_location => apex_error.c_inline_in_notification
);
END;
Part 6 — Test the Whole Flow
Reset your tables so you're starting clean:
DELETE FROM li_debug_log;
UPDATE li_oauth_settings SET access_token=NULL, refresh_token=NULL, person_urn=NULL WHERE id=1;
COMMIT;
Navigate to Page 1 and click Connect.
You'll be sent to LinkedIn's login/consent screen — log in and click Allow.
You'll land automatically back on Page 2 — no login prompt this time, since it's public.
Check the debug log:
SELECT id, tag, log_time, message FROM li_debug_log ORDER BY id;
You should see A_START with a real code value, and B_RESPONSE containing a real access_token, expires_in, and id_token.
Confirm the table now has real values:
SELECT SUBSTR(access_token,1,30) token_preview, LENGTH(access_token) len, person_urn
FROM li_oauth_settings WHERE id = 1;
Type something into P2_COMMENT and click Post to LinkedIn.
Check your actual LinkedIn feed — your post should be there.
Common Errors and What They Actually Mean
A summary of every error hit while building this — chances are you'll see at least one of these too.
| Error | What it actually means | Fix |
|---|---|---|
redirect_uri does not match the registered value | URL registered in LinkedIn's Auth tab doesn't character-for-character match your PL/SQL. Often a domain mismatch or leftover ?session= fragment. | Use apex_page.get_url, strip the session param, prepend the correct domain, keep both identical. |
PLS-00306: wrong number or types of arguments in call to 'MAKE_REST_REQUEST' | APEX_WEB_SERVICE.MAKE_REST_REQUEST has no p_content_type parameter. | Set Content-Type via g_request_headers instead. |
ERR-1002 Unable to find item ID for item "CODE" | Newer APEX auto-maps query string parameters to page items with matching names, and errors if none exists. | Create hidden items named exactly CODE and STATE. |
PLS-00302: component 'URL_DECODE' must be declared | APEX_UTIL.URL_DECODE doesn't exist in older APEX versions. | Usually unnecessary — once CODE is a proper item, APEX auto-decodes it. |
{"code":"EMPTY_ACCESS_TOKEN"} (401) | access_token column is NULL — the exchange never actually succeeded. | Check the debug log for LinkedIn's real response from the exchange step. |
| Callback page silently asks you to log in again | Page requires APEX authentication, but LinkedIn's redirect is anonymous — the login prompt swallows the code. | Make the callback page Public. |
{"error":"invalid_client"} | Client ID/Secret mismatch, often from an un-encoded special character in the secret corrupting the form body. | URL-encode every field in the token-exchange POST body. |
What Happens Next — Token Expiry
LinkedIn access tokens for this product tier last roughly 60 days. When yours expires, simply repeat the Page 1 → Connect flow to get a fresh one — for a personal tool used occasionally, this is simpler than building automatic refresh-token handling, though that's a reasonable next enhancement.
Wrap-up: what looks like a simple "click a button, post to LinkedIn" feature actually touches a surprising number of APEX and OAuth details — friendly URL routing, page-level public access, form encoding, and LinkedIn's specific API version requirements. None of the individual pieces are hard, but they only click into place once you've hit each error at least once. Hopefully seeing them laid out end-to-end here saves you a few of those debugging cycles.
Comments
Post a Comment