Skip to content
Development

The Postiz webhook I couldn't build, and polling instead

By Victor Da Luz
railsrubypostizdev-logblog-manager

My blog manager schedules social posts through Postiz, a self-hosted scheduler. Scheduling is the easy half. The hard half is finding out, afterward, where the post actually landed - Postiz publishes asynchronously, so the “schedule” call returns before anything is live and with no link to the published post. The plan on paper was simple: receive Postiz’s publish webhook and record the URL. I never wrote that receiver, and the reason it died is more interesting than the code that replaced it.

Reading before building

Postiz is open source, so instead of guessing at the webhook payload I pinned my reading to the exact version my box runs (v2.21.8) and traced the publish path. Two findings, either one fatal.

It won’t accept my URL. The webhook config validates the target with a function called IsSafeWebhookUrl. It does a real DNS lookup and rejects anything that resolves to a private address - 10.x, 192.168.x, and so on. My blog manager lives at a homelab domain that resolves to a private 192.168.x.x address. Postiz refuses to register it. There’s no public endpoint to point it at, so I can’t even create the webhook.

And the payload is empty anyway. Even setting the URL problem aside, the v2.21.8 webhook is unauthenticated (no secret, no signature - I’d have no way to know a POST is really from Postiz), fires only on success (so failures are invisible), and - because of an id mismatch in the source - POSTs an empty array. The function that builds the body looks the post up by the wrong id, finds nothing, and sends []. I traced it line by line and still half-expected to be wrong, but the path is unambiguous.

So the feature as specced was unbuildable. That’s a useful outcome: a spike that ends in “don’t build this” saved me from standing up a public ingress and a receiver to catch empty, unverifiable pings.

Polling, the boring answer that works

The thing about an async system with a bad webhook is that you can usually just ask it. Postiz has a read endpoint - GET /posts over a date range - and when I hit it with my API key, every field the webhook was supposed to deliver was right there: per channel, the state (QUEUE / PUBLISHED / ERROR), the published URL, the provider name. Authenticated, complete, and it even tells me about failures the webhook never would.

So I poll. When a post is scheduled, I capture the per-channel post id Postiz hands back, and a background job checks the calendar a minute later, matches my ids against it, and records where each channel landed - flipping the post to “posted” once every channel is live, backing off and retrying while any are still queued. Less elegant than a push, but it’s the kind of less-elegant that actually runs.

The gotcha: posts from before I was capturing ids

I shipped the id-capture and tested, and a post I’d published earlier in the day still showed as “scheduled.” Of course it did - it went out before the code that captures the correlation id existed, so the poller had nothing to match on. Rather than guess, I checked what the calendar actually returns and found the post’s URL sitting right there in the row’s content. So I gave the poller a fallback: if there’s no captured id, match the Postiz row whose content contains the post’s URL. New posts match by id; old ones self-heal by URL. One reconcile pass and the back catalog lit up with its real links.

What I took away

Two things. First, read the source of the thing you’re integrating with before you design around its docs or its issue tracker - the webhook “exists” in the sense that there’s code, and “doesn’t work” in the sense that the code sends an empty body, and only the source tells you which. Second, when a push API fails you, check whether the same system has a pull API. It usually does, and it’s usually the one that’s actually maintained.

Related reading

Development

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.

Read