← 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
M README.mdM index.jsM lib/post.jsA lib/postiz.js
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 →