Approved drafts publish via a single n8n `publish-content` webhook; the panel POSTs the effective content (founder edits merged over generated body) and awaits a synchronous Respond-to-Webhook result (sp.semih.ai is Tailscale-only, so we avoid an n8n→panel callback). n8n routes by channel. - lib/n8n.ts: publishToN8n client (X-Content-Secret header, timeout, tolerant result parsing: ok|success + publishedUrl|url|postUrl|permalink) - publishDraft server action: approved|failed → publishing → published(+url) / failed(+error), audit-logged; effective content = bodyJson + founderEdits - DraftCard: "Yayınla" / "Yeniden yayınla" button + publishing state - docs/n8n: importable publish-content workflow (Webhook → Switch → LinkedIn / HTTP-blog → Respond) + runbook (contract, panel envs, LinkedIn OAuth setup, blog endpoint = sase.tr POST /blog/posts/internal Bearer) Needs panel-web envs N8N_PUBLISH_WEBHOOK_URL + N8N_WEBHOOK_SECRET. Publish is a graceful no-op (clear error) until those are set and the n8n workflow exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5.2 KiB
Content publish — n8n publish-content workflow (Phase 8c)
The panel generates + reviews drafts; n8n publishes them. The panel POSTs an
approved draft to a single n8n webhook and waits for a synchronous response.
n8n routes by channel (LinkedIn = native node, blog = HTTP to sase.tr API).
Synchronous by design: sp.semih.ai is Tailscale-only, so we avoid an
n8n→panel callback. n8n returns the result via a Respond to Webhook node.
Contract
Panel → n8n (POST to N8N_PUBLISH_WEBHOOK_URL)
Headers: Content-Type: application/json, X-Content-Secret: <N8N_WEBHOOK_SECRET>
{
"draftId": "clx…",
"channel": "linkedin", // linkedin | blog | x | instagram
"projectKey": "sase",
"topicTitle": "Şase Numarası (VIN) Nedir?",
"content": { // effective body (founder edits merged over generated)
// linkedin: { body, hashtags[], cta? }
// blog: { title, slug, meta_description, body_markdown, tags[], cta? }
// x: { tweets[], hashtags[] }
// instagram:{ caption, hashtags[], image_prompt? }
}
}
n8n → panel (Respond to Webhook, synchronous)
{ "ok": true, "publishedUrl": "https://www.linkedin.com/feed/update/urn:li:share:123" }
// or
{ "ok": false, "error": "linkedin 401: token expired" }
The panel accepts ok|success true, and reads the URL from any of
publishedUrl|url|postUrl|permalink. A 2xx with no JSON is treated as success
without a URL.
Panel env (set on panel-web in Coolify)
| key | value |
|---|---|
N8N_PUBLISH_WEBHOOK_URL |
https://n8n.semih.ai/webhook/publish-content (prod) — use /webhook-test/publish-content while building |
N8N_WEBHOOK_SECRET |
a long random string; also set as the n8n Header Auth credential value |
N8N_PUBLISH_TIMEOUT_MS |
optional, default 30000 |
After setting these, redeploy panel-web (push to main auto-deploys web).
Build the workflow in n8n
Import publish-content.workflow.json (Workflows → Import from File) or
build these 5 nodes:
- Webhook —
POST, pathpublish-content, Respond = "Using Respond to Webhook node". Authentication = Header Auth → credential checking headerX-Content-SecretequalsN8N_WEBHOOK_SECRET. - Switch (on
={{ $json.body.channel }}): routelinkedinandblog(addx/instagramlater). Add a fallback output → error response. - LinkedIn node (
linkedinbranch) — see OAuth setup below. Post text:={{ $json.body.content.body }}{{ $json.body.content.hashtags ? '\n\n' + $json.body.content.hashtags.join(' ') : '' }} - HTTP Request (
blogbranch) —POST {SASE_BLOG_API}/blog/posts/internal, Header AuthAuthorization: Bearer <BLOG_AUTOMATION_TOKEN>(mirrors sase.tr's existingchangelog/internalautomation pattern), JSON body =={{ $json.body.content }}plus{ "projectKey": "sase" }. - Respond to Webhook (one per branch, or a shared Set→Respond) — return
{ "ok": true, "publishedUrl": "<from node response>" }. On the fallback / error path return{ "ok": false, "error": "<message>" }.
Map publishedUrl from each node's response:
- LinkedIn node returns the share/ugcPost id → build
https://www.linkedin.com/feed/update/<urn>. - Blog HTTP returns
{ url }from the sase.tr API (see below).
Activate the workflow (toggle top-right) to use the /webhook/ (prod) path.
LinkedIn OAuth (n8n credential)
- LinkedIn Developer (https://www.linkedin.com/developers/) → Create app, associate it with the company page you post from.
- Products: request "Share on LinkedIn" and "Advertising API" /
"Community Management API" as needed for organization posting. Member
posting uses
w_member_social; company-page posting usesw_organization_social(needs page admin + may need app review). - Auth tab → add redirect URL:
https://n8n.semih.ai/rest/oauth2-credential/callback. - In n8n → Credentials → LinkedIn OAuth2 API → paste Client ID/Secret,
set scopes (
w_member_socialand/orw_organization_social r_organization_social), connect, authorize. - In the LinkedIn node pick "Post" and, for a company page, set
Post As = Organization+ the organization URN.
Note: organization posting often requires LinkedIn app review. Start with member posting (
w_member_social) to validate end-to-end, then upgrade.
Blog (sase.tr API) — see the sase.tr repo
The blog branch POSTs to the new sase.tr blog API
(POST /blog/posts/internal, header Authorization: Bearer <BLOG_AUTOMATION_TOKEN>
— mirrors sase.tr's existing changelog/internal automation auth). The
Drizzle model/endpoint live in the sase.tr codebase (apps/api/src/blog,
modeled on the changelog module). The API returns
{ url: "https://sase.tr/blog/<slug>" }, which n8n echoes back as
publishedUrl.
Testing end-to-end
- Set the panel envs to the test webhook URL, click "Listen for test event" in the n8n Webhook node.
- In the panel: approve a draft → Yayınla. Watch n8n execute; the panel
draft flips to
published(with URL) orfailed(with the error). - Switch the env to the prod
/webhook/URL and activate the workflow.