[object Object]

← back to Dw Slideshow Gen

Postiz integration: verified public-API client (integrations/upload/draft/schedule/delete), draft-staging sidesteps TikTok #1338, dry-run + --confirm gate, integrations cmd

05c2f1d2e521c071380f11c2b98a408742aaa24a · 2026-06-29 10:22:41 -0700 · Steve Abrams

Files touched

Diff

commit 05c2f1d2e521c071380f11c2b98a408742aaa24a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 29 10:22:41 2026 -0700

    Postiz integration: verified public-API client (integrations/upload/draft/schedule/delete), draft-staging sidesteps TikTok #1338, dry-run + --confirm gate, integrations cmd
---
 README.md     | 37 +++++++++++++++++++-----
 index.js      | 46 ++++++++++++++++++++++--------
 lib/post.js   | 65 ++++++++++++++++++++++++------------------
 lib/postiz.js | 90 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 192 insertions(+), 46 deletions(-)

diff --git a/README.md b/README.md
index 27695eb..cec84e6 100644
--- a/README.md
+++ b/README.md
@@ -49,13 +49,36 @@ Flags:
 ```
 If `facts` is omitted the generator writes on-brand body slides from the topic.
 
-## Posting (Postiz) — fail-closed
-`post` always builds a local draft package (6 PNGs + `caption.txt` +
-`POST-INSTRUCTIONS.md`, zipped). A **live** TikTok draft is only attempted when
-`POSTIZ_API_KEY` + `POSTIZ_INTEGRATION_ID` are set **and** you pass `--confirm` —
-and even then it creates a **SELF_ONLY (private) DRAFT**. You add the trending
-sound and publish in the TikTok app (the Content API can't add music — and that
-human step drives the algorithm). Going live is outward-facing → Steve-gated.
+## Posting (Postiz) — verified API, fail-closed
+Client (`lib/postiz.js`) is built to the verified Postiz public API
+(`https://api.postiz.com/public/v1`, `Authorization: <key>`; shapes lifted from the
+official SDK + DTOs). `post` **always** builds the local zip package ($0). With
+`--postiz` it stages into Postiz:
+
+```bash
+node index.js integrations                 # list connected channels (needs key)
+node index.js post --postiz                # DRY-RUN: prints the exact request body, no network
+node index.js post --postiz --confirm      # stage as a Postiz DRAFT (reversible)
+node index.js post --postiz --channel tiktok --schedule 2026-07-01T15:00:00Z --confirm
+```
+
+**Why `draft`, not direct-to-TikTok:** we stage as Postiz `type:'draft'`. It needs no
+per-provider settings, lands in the Postiz dashboard for you to review + publish (you
+add the trending sound there), and is reversible via `DELETE /posts/{id}`. This
+deliberately sidesteps Postiz issue **#1338** (still OPEN) — TikTok `UPLOAD`+`SELF_ONLY`
+posts get stuck in QUEUE because `privacy_level` is dropped upstream. `--schedule`
+uses the schedule path with full TikTok settings if you'd rather queue directly.
+
+**Gate:** network calls fire only with `POSTIZ_API_KEY` **and** `--confirm`. Without
+`--confirm` it's a dry-run. Going live is outward-facing → Steve-gated.
+
+**Setup:** Postiz → connect TikTok → Settings → Developers → Public API → generate key
+→ paste it (routed via `/secrets` as `POSTIZ_API_KEY`). No `INTEGRATION_ID` needed —
+the engine lists channels and picks TikTok automatically (override with `--channel`).
+
+**Analytics:** Postiz's analytics are **dashboard-only** (platform.postiz.com/analytics) —
+there is no public-API analytics endpoint, so per-post stats can't be pulled
+programmatically. The engine integrates posting + channels; analytics stays in the UI.
 
 ## Cost
 Render: **$0 (local)** — no image-gen API. Catalog photo download: **$0**.
diff --git a/index.js b/index.js
index 82d06a1..1959b25 100644
--- a/index.js
+++ b/index.js
@@ -10,7 +10,8 @@ import { loadDesign } from './lib/design.js'
 import { buildPlan } from './lib/plan.js'
 import { renderSpec } from './lib/render.js'
 import { buildCatalogQueue } from './lib/catalog.js'
-import { packageDraft, postToPostiz } from './lib/post.js'
+import { packageDraft, stageToPostiz } from './lib/post.js'
+import { Postiz, pickIntegration } from './lib/postiz.js'
 import { readJSON, writeJSON, ensureDir, slugify, today } from './lib/util.js'
 
 const ROOT = path.dirname(fileURLToPath(import.meta.url))
@@ -32,6 +33,9 @@ function parseArgs(argv) {
     else if (t === '--mode') a.mode = argv[++i]
     else if (t === '--no-photos') a.photos = false
     else if (t === '--confirm') a.confirm = true
+    else if (t === '--postiz') a.postiz = true
+    else if (t === '--channel') a.channel = argv[++i]
+    else if (t === '--schedule') a.schedule = argv[++i]
     else a._.push(t)
   }
   return a
@@ -57,7 +61,9 @@ if (!cmd || cmd === 'help' || cmd === '-h') {
   plan    <queue.json> [--design F] [--ai] [--model M]   write specs JSON
   render  <plan.json>  [--design F] [--bg DIR]           render PNGs for each spec
   all     <queue.json> [--design F] [--ai] [--bg DIR]    plan then render
-  post    [renderDir...] [--confirm]            build TikTok draft package (fail-closed)
+  integrations                                  list connected Postiz channels (needs key)
+  post    [renderDir...] [--postiz] [--channel C] [--schedule ISO] [--confirm]
+                                                build local package; --postiz stages a Postiz DRAFT
 
   --ai         copy via headless Claude Code (Max-plan login, no API key)
   --bg DIR     photos as slide backgrounds (slide_N.jpg | bg.jpg) with a scrim
@@ -66,6 +72,15 @@ if (!cmd || cmd === 'help' || cmd === '-h') {
   process.exit(0)
 }
 
+if (cmd === 'integrations') {
+  const key = process.env.POSTIZ_API_KEY
+  if (!key) { console.error('  [integrations] no POSTIZ_API_KEY — get it at Postiz → Settings → Developers → Public API, then route via /secrets'); process.exit(1) }
+  const list = await new Postiz(key).integrations()
+  console.error(`  [integrations] ${list.length} connected channel(s):`)
+  for (const i of list) console.error(`    ${i.disabled ? '✗' : '✓'} ${i.identifier.padEnd(12)} ${i.name}   id=${i.id}`)
+  process.exit(0)
+}
+
 if (cmd === 'post') {
   // Build the postable draft package for one render dir (or every dir under output/renders).
   let dirs = a._.filter(Boolean)
@@ -78,20 +93,27 @@ if (cmd === 'post') {
   if (!dirs.length) { console.error('no render dirs found — run `render`/`catalog`/`all` first'); process.exit(1) }
   for (const dir of dirs) {
     const pkg = packageDraft(dir, { zip: true })
-    console.error(`  [post] ${pkg.spec_id}: draft package (${pkg.slides} slides) -> ${pkg.zipPath || pkg.pkgDir}  ($0, no network)`)
-    const live = await postToPostiz(dir, process.env, { confirm: a.confirm })
-    if (live.gated) console.error(`  [post] ${pkg.spec_id}: NOT posted — ${live.reason}`)
-    else if (live.posted) console.error(`  [post] ${pkg.spec_id}: Postiz DRAFT created (${live.privacy}) id=${live.postiz_id}`)
+    console.error(`  [post] ${pkg.spec_id}: local draft package (${pkg.slides} slides) -> ${pkg.zipPath || pkg.pkgDir}  ($0, no network)`)
+    if (!a.postiz) continue
+    const r = await stageToPostiz(dir, process.env, { confirm: a.confirm, channel: a.channel, schedule: a.schedule })
+    if (r.gated) console.error(`  [post] ${pkg.spec_id}: Postiz skipped — ${r.reason}`)
+    else if (r.error) console.error(`  [post] ${pkg.spec_id}: Postiz error — ${r.error}`)
+    else if (r.dryRun) {
+      console.error(`  [post] ${pkg.spec_id}: DRY-RUN ${r.type} → ${r.channel.identifier} (${r.channel.name}), ${r.images} images. Add --confirm to stage. Body:`)
+      console.error('  ' + JSON.stringify(r.body).replace(/\n/g, ' ').slice(0, 600))
+    } else if (r.staged) console.error(`  [post] ${pkg.spec_id}: Postiz ${r.type} STAGED → ${r.channel.identifier} (${r.channel.name}) postId=${r.postId} — review/publish at platform.postiz.com`)
   }
   console.error([
     '',
     '  ── GATE ─────────────────────────────────────────────',
-    '  Draft packages are ready locally. Going live needs (all of):',
-    '    1. a Postiz account + connected TikTok integration',
-    '    2. POSTIZ_API_KEY + POSTIZ_INTEGRATION_ID in env (route via /secrets)',
-    '    3. re-run with --confirm',
-    '  Even then it creates a SELF_ONLY (private) DRAFT — you add the',
-    '  trending sound and publish in the TikTok app. Outward-facing: Steve-gated.',
+    '  Local draft packages are always built ($0). To stage into Postiz:',
+    '    1. connect TikTok (+ any channel) in the Postiz dashboard',
+    '    2. Settings → Developers → Public API → generate key → paste to me',
+    '       (I route POSTIZ_API_KEY via /secrets)',
+    '    3. preview:  post --postiz            (dry-run, no network)',
+    '       stage:    post --postiz --confirm  (creates a Postiz DRAFT)',
+    '  Staged as a DRAFT (reversible) — you review + publish in Postiz; you',
+    '  add the trending sound there. Outward-facing: Steve-gated.',
     '  ─────────────────────────────────────────────────────',
   ].join('\n'))
   process.exit(0)
diff --git a/lib/post.js b/lib/post.js
index 4f1c13c..e79941d 100644
--- a/lib/post.js
+++ b/lib/post.js
@@ -12,6 +12,7 @@ import fs from 'node:fs'
 import path from 'node:path'
 import { execFileSync } from 'node:child_process'
 import { readJSON, ensureDir, exists } from './util.js'
+import { Postiz, buildDraftBody, buildScheduleBody, pickIntegration } from './postiz.js'
 
 function loadManifest(renderDir) {
   const mp = path.join(renderDir, 'render-manifest.json')
@@ -57,34 +58,44 @@ export function packageDraft(renderDir, opts = {}) {
   return { spec_id: m.spec_id, pkgDir, zipPath, slides: m.png_paths.length, caption }
 }
 
-// Live Postiz draft — only runs when fully credentialed AND explicitly confirmed.
-export async function postToPostiz(renderDir, env, opts = {}) {
+const captionOf = m => `${m.caption || ''}\n\n${(m.hashtags || []).join(' ')}`.trim()
+
+// Stage a carousel into Postiz. Default = a DRAFT (no settings, sidesteps the TikTok
+// UPLOAD #1338 bug, reversible, human publishes from the dashboard). Network calls
+// (upload + create) only fire with a key AND opts.confirm — otherwise a dry-run that
+// returns the exact request body so the shape is verifiable without posting.
+export async function stageToPostiz(renderDir, env, opts = {}) {
   const apiKey = env.POSTIZ_API_KEY
-  const integrationId = env.POSTIZ_INTEGRATION_ID
-  const base = (env.POSTIZ_URL || 'https://api.postiz.com').replace(/\/$/, '')
-  if (!apiKey || !integrationId || !opts.confirm) {
-    return { posted: false, gated: true,
-      reason: 'fail-closed: needs POSTIZ_API_KEY + POSTIZ_INTEGRATION_ID + --confirm' }
-  }
-  // NOTE: Postiz public-API shape — VERIFY against the live account before first use.
+  if (!apiKey) return { staged: false, gated: true, reason: 'no POSTIZ_API_KEY (Settings → Developers → Public API)' }
+
   const m = loadManifest(renderDir)
-  const media = []
-  for (const p of m.png_paths) {
-    const fd = new FormData()
-    fd.append('file', new Blob([fs.readFileSync(p)], { type: 'image/png' }), path.basename(p))
-    const up = await fetch(`${base}/public/v1/upload`, { method: 'POST', headers: { Authorization: apiKey }, body: fd })
-    if (!up.ok) throw new Error(`upload ${up.status}`)
-    media.push((await up.json()).id)
-  }
-  const body = {
-    type: 'draft',
-    content: [{ integration: { id: integrationId }, value: `${m.caption}\n\n${(m.hashtags || []).join(' ')}`, image: media }],
-    settings: { tiktok: { privacy_level: 'SELF_ONLY', content_disclosure: false } },
+  const content = captionOf(m)
+  const dateISO = opts.schedule || new Date().toISOString()
+  const client = new Postiz(apiKey)
+
+  // pick channel (read-only)
+  let integration
+  try {
+    const list = await client.integrations()
+    integration = pickIntegration(list, opts.channel)
+  } catch (e) { return { staged: false, error: `integrations: ${e.message}` } }
+  if (!integration) return { staged: false, gated: true, reason: 'no connected channels — connect TikTok in Postiz first' }
+
+  const mkBody = media => opts.schedule
+    ? buildScheduleBody({ integrationId: integration.id, identifier: integration.identifier, content, media, dateISO, tiktok: opts.tiktok })
+    : buildDraftBody({ integrationId: integration.id, content, media, dateISO })
+
+  // dry-run: show the body with placeholder media, no upload, no post
+  if (!opts.confirm) {
+    const placeholder = m.png_paths.map((p, i) => ({ id: `<upload_${i + 1}>`, path: `https://uploads.postiz.com/${path.basename(p)}` }))
+    return { staged: false, dryRun: true, channel: integration, type: opts.schedule ? 'schedule' : 'draft',
+      images: m.png_paths.length, body: mkBody(placeholder) }
   }
-  const res = await fetch(`${base}/public/v1/posts`, {
-    method: 'POST', headers: { Authorization: apiKey, 'Content-Type': 'application/json' }, body: JSON.stringify(body),
-  })
-  if (!res.ok) throw new Error(`posts ${res.status}: ${await res.text()}`)
-  const j = await res.json()
-  return { posted: true, draft: true, privacy: 'SELF_ONLY', postiz_id: j.id || j.postId || null }
+
+  // live: upload each PNG, then create the post
+  const media = []
+  for (const p of m.png_paths) media.push(await client.upload(p))
+  const created = await client.createPost(mkBody(media))
+  const postId = Array.isArray(created) ? created[0]?.postId || created[0]?.id : created.id || created.postId
+  return { staged: true, type: opts.schedule ? 'schedule' : 'draft', channel: integration, postId, raw: created }
 }
diff --git a/lib/postiz.js b/lib/postiz.js
new file mode 100644
index 0000000..a67159b
--- /dev/null
+++ b/lib/postiz.js
@@ -0,0 +1,90 @@
+// lib/postiz.js — verified Postiz public-API client (shapes lifted from the official
+// SDK + DTOs at github.com/gitroomhq/postiz-app, June 2026). Node 22 globals only.
+//
+//   Base:   https://api.postiz.com   (cloud).  Dashboard UI is platform.postiz.com.
+//   Auth:   Authorization: <api-key>  (raw, no "Bearer"). Key from
+//           Postiz → Settings → Developers → Public API.
+//
+// Design note: we stage carousels as type:'draft'. The public API also supports
+// 'schedule'/'now', but the TikTok UPLOAD inbox-draft path is broken upstream
+// (issue #1338, still OPEN — privacy_level dropped, posts stuck in QUEUE). 'draft'
+// requires NO per-provider settings, lands in the Postiz dashboard for human review,
+// and is reversible via DELETE — so it sidesteps the bug and keeps the publish gate.
+import fs from 'node:fs'
+import path from 'node:path'
+
+const BASE = (process.env.POSTIZ_URL || 'https://api.postiz.com').replace(/\/$/, '')
+
+export class Postiz {
+  constructor(apiKey, base = BASE) {
+    if (!apiKey) throw new Error('POSTIZ_API_KEY required')
+    this.key = apiKey; this.base = base
+  }
+  async #json(p, init = {}) {
+    const res = await fetch(`${this.base}/public/v1${p}`, {
+      ...init, headers: { Authorization: this.key, ...(init.headers || {}) },
+    })
+    const text = await res.text()
+    if (!res.ok) throw new Error(`Postiz ${init.method || 'GET'} ${p} -> ${res.status}: ${text.slice(0, 300)}`)
+    try { return text ? JSON.parse(text) : {} } catch { return text }
+  }
+
+  // GET connected channels: [{id, name, identifier, picture, disabled, profile}]
+  integrations() { return this.#json('/integrations', { headers: { 'Content-Type': 'application/json' } }) }
+
+  // POST multipart upload of one image -> {id, path, ...}
+  async upload(filePath) {
+    const buf = fs.readFileSync(filePath)
+    const ext = path.extname(filePath).slice(1).toLowerCase()
+    const type = ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : 'image/jpeg'
+    const fd = new FormData()
+    fd.append('file', new Blob([buf], { type }), path.basename(filePath))
+    return this.#json('/upload', { method: 'POST', body: fd })
+  }
+
+  createPost(body) {
+    return this.#json('/posts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
+  }
+  listPosts(filters = {}) {
+    const qs = new URLSearchParams(Object.entries(filters).filter(([, v]) => v != null)).toString()
+    return this.#json(`/posts${qs ? '?' + qs : ''}`, { headers: { 'Content-Type': 'application/json' } })
+  }
+  deletePost(id) { return this.#json(`/posts/${id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' } }) }
+}
+
+// ---- pure request-body builders (testable without a key, via --dry-run) ----
+const imageRefs = media => media.map(m => ({ id: m.id, path: m.path, ...(m.alt ? { alt: m.alt } : {}) }))
+
+export function buildDraftBody({ integrationId, content, media, dateISO }) {
+  return {
+    type: 'draft', shortLink: false, date: dateISO, tags: [],
+    posts: [{ integration: { id: integrationId }, value: [{ content, image: imageRefs(media) }] }],
+  }
+}
+
+export function buildScheduleBody({ integrationId, identifier, content, media, dateISO, tiktok = {} }) {
+  const settings = identifier === 'tiktok'
+    ? {
+        __type: 'tiktok', title: '',
+        privacy_level: tiktok.privacy || 'SELF_ONLY',
+        duet: false, stitch: false, comment: true, autoAddMusic: 'no',
+        brand_content_toggle: false, brand_organic_toggle: false, video_made_with_ai: false,
+        content_posting_method: tiktok.method || 'UPLOAD',
+      }
+    : { __type: identifier }
+  return {
+    type: 'schedule', shortLink: false, date: dateISO, tags: [],
+    posts: [{ integration: { id: integrationId }, value: [{ content, image: imageRefs(media) }], settings }],
+  }
+}
+
+// pick the channel to post to: explicit id/name/identifier match, else first TikTok, else first enabled
+export function pickIntegration(list, want) {
+  const live = (list || []).filter(i => !i.disabled)
+  if (want) {
+    const w = want.toLowerCase()
+    const hit = live.find(i => i.id === want || i.identifier?.toLowerCase() === w || i.name?.toLowerCase() === w)
+    if (hit) return hit
+  }
+  return live.find(i => i.identifier === 'tiktok') || live[0] || null
+}

← d59b3ab post leg: fail-closed TikTok draft package (zip+caption+inst  ·  back to Dw Slideshow Gen  ·  engine: auto-load .env (POSTIZ_API_KEY) so integrations/post a0d2ef6 →