Deleting an integration by moving it to Postiz
blog-manager grew up with a split personality. Full-article cross-posts to Dev.to and Medium each had their own API client, draft-then-publish flow, and sync job. Everything social - Bluesky, Mastodon, X, LinkedIn - went through Postiz, a self-hosted scheduler. The line between “native” and “Postiz” wasn’t a design decision. It was just the order I built things in.
When I ripped out Hashnode recently, the duplication got hard to ignore. Two of my integrations were ~80% the same boilerplate: a client, a draft creator, a sync job, per-platform status columns. So I ran a spike with one blunt question: does Postiz actually publish full articles with canonical URLs, or only short social blurbs? If it can do articles, my native Dev.to and Medium code isn’t earning its keep. It’s redundant.
It can. Postiz’s POST /public/v1/posts supports article providers, using the same dev.to /articles endpoint my native client called. So the plan: prove it on Dev.to first (it’s already a connected Postiz channel, low risk), then delete the native integration. This post is that first phase.
What I built
A new Postiz::ArticlePoster. It resolves the connected Dev.to channel, pulls the post’s markdown from GitHub (frontmatter stripped, same source the old code used), and posts the full body with the article metadata in settings:
{
type: "draft",
posts: [{
integration: { id: devto_channel_id },
value: [{ content: full_markdown_body, image: [] }],
settings: { title:, tags:, main_image:, canonical: }
}]
}
I wrote it straight from the Postiz provider source. dev.to.provider.ts reads settings.main_image.path and settings.tags.map(t => t.label), so that’s what I sent. Tests green, payload looked right. I shipped it to my own machine and clicked the button.
Three things the source didn’t tell me
1. The API validates stricter than the code that runs. First real publish, instant HTTP 400:
posts.0.settings.main_image.id must be a string
posts.0.settings.tags.0.value must be a number
The provider’s post() method only reads path and label. But the public API validates the request against a separate NestJS DTO before the provider ever runs, and that DTO wants more: main_image has to be { id, path }, and each tag has to be { value: <number>, label }. The runtime ignores id and value entirely. They exist only to satisfy validation. I’d read the right file and still got it wrong, because the contract lives in two files and only one of them does anything at runtime. Lesson I keep relearning: with a third-party API, the live request is the real spec.
2. There’s no Dev.to draft. I assumed type: "draft" would create a reviewable draft on Dev.to, like the native flow did. It doesn’t. The provider hardcodes published: true, so the moment Postiz processes the post, the article is live. type: "draft" only holds it inside Postiz. So “review before it goes public” now means reviewing in the Postiz UI and publishing from there, not previewing a draft on Dev.to. Not better or worse, just different, and worth knowing before you promise someone a draft step.
3. The cover image broke in a way tests can’t catch. Second publish went through. Everything was perfect: body, code blocks, the four tags, “Originally published at vdaluz.com.” And a big grey box where the cover should be: image no longer exists.
I’d been a good engineer and uploaded the hero image to Postiz first, then passed the Postiz URL as the cover. But my Postiz runs STORAGE_PROVIDER=local - uploads live on a volume on a box in my house. Dev.to’s servers can’t durably fetch that. The old native code had quietly done the right thing all along: it passed the original public Pexels URL straight through. So I stopped uploading and did the same. The id the DTO demands? A throwaway string. The cover renders now.
The part I almost got wrong quietly
Choosing the Postiz-draft workflow had a consequence I didn’t see at first. My existing status reconciler polls Postiz on a short, event-driven schedule that gives up after about an hour. That’s fine for a social post that publishes in minutes. It’s useless for a draft that sits in Postiz until I get around to publishing it, which might be days. So consolidating onto Postiz, which was supposed to remove a sync job, actually needed me to add a recurring sweep back. I pulled the matching logic into a shared Postiz::StatusReconciler and pointed two jobs at it: the fast event poll for social, and a slow wide-window sweep for drafts.
What I deleted, and what’s next
Once a real article round-tripped with the cover intact, I deleted the native Dev.to integration: the client, draft creator, publisher, sync job, the status columns (reversible migration), the enums, the UI. Net change for the whole issue was about 880 fewer lines, and three different status-detection mechanisms collapsed toward one.
The honest scorecard: the build was bigger than the spike implied, entirely because of the draft workflow and the two API surprises. None of it showed up in the test suite. All of it showed up the first time I clicked the button against the real thing. Medium is next, and I’m holding off on a reflex deletion there - its API token can’t be reissued, so that one earns a deliberate keep-or-migrate decision rather than the confident “I don’t need this” I got to use on Dev.to.
Related reading
Add a feature, or move a responsibility?
Adding Postiz social cross-posting looked done until a blunt question exposed a double-post bug, and a full audit of every posting path in the app found two more like it.
The Postiz webhook I couldn't build, and polling instead
A spike that ended in "don't build this": private-IP rejection, an unauthenticated empty payload, and the pull API that had everything the push lacked.
Scheduling and smarter link-sharing for my Postiz cross-poster
Future scheduling, richer blurbs with thumbnails, and a which-URL picker, plus the wall-clock timezone bug and the import that copied the wrong column.