What If There's No Webhook for That
some opinions on building around events that don't exist, from someone who went looking for a "post deleted" webhook and found a shrug instead
Confession first. When someone asked me "can we show when a post got deleted on the platform?" I said yes before I finished reading the sentence. Of course we can. right? Every platform has webhooks. There's going to be a post.deleted event, I'll wire it to a queue, flip a column, done by lunch. Especially since we have Claude Code.
There was no post.deleted event.
I read the webhook catalogue twice. Then I opened the other webhook catalogue (because of course there are two, one for the business API and one for the developer API) and read that twice too. Comment deleted? Yes. Authorization removed? Yes. Video upload failed? Sure. Post deleted? Nothing. Not in either one.
What I did find was this one:
post.publish.no_longer_publicly_available: "Post published is no longer available for public viewership. This may be because of subsequent moderation or because the user has turned the post to friends-only/private viewership."
I read it again. It doesn't say deleted. It says "no longer public," and then lists two reasons, and neither of them is deletion. And the payload has no reason field. Just "hey, this one's gone dark" and a post id.
This post is about what I think you should do (or not, your choice) when the event you need doesn't exist, and the closest thing you have is ambiguous. Its mostly about TikTok because that's where I got burned, but I've hit the same wall on Instagram (archived and deleted look identical from the outside) and I'm pretty sure it's a general law of third party APIs.
The thing you wish existed vs the thing you get
Here's what I had in my head:

And here's what actually happens:

Four very different real world things. One identical message. And to make it more fun, the docs mark this event as one that "may occur at any time after a post has become publicly available." May. Not will.
Oh, and events only fire for posts that were published through the API. If the creator posted from the app on their phone and deleted it later, you hear nothing at all. Silence.
First, what are we even modelling
Before any code, I think you have to be honest about what you actually know. I drew this out because I kept confusing myself:

Everything on the left funnels into one event, and the only thing on the right you can say without lying is "not public anymore."
And this is where I want to plant my first flag.
Opinion 1: Model what you know, not what you wish you knew
The tempting thing is to treat "no longer public" as "deleted." Set deleted_at, hide the post from the history list, move on. Most of the time it probably was a delete, right? People clear out test posts all the time.
Don't.
Think about what happens when you're wrong. The creator set a post to private for a day because they wanted to re-edit the caption. Your app hides it from their history. Now it's gone from your app, and when they flip it back to public, maybe you get a publicly_available event again, maybe you don't (spoiler: you won't know until you test it). If you don't, the post stays gone from your app forever, for a video that is happily sitting on their profile.
That's a much worse bug than "we showed a deleted post for a bit." Hiding something real is worse than showing something stale. People forgive stale. They don't forgive "where did my stuff go." (I definitely wont).
So the minimum honest design is a separate state. Not deleted. Not live. Its own thing:

Two things I want to point out in that diagram.
First, Deleted and DeletedHere are different states. "Gone on the platform" and "the user removed it from our app" are not the same fact, and if they share one deleted_at column, future you is going to have a very confusing afternoon. I'd use a separate column, or at the very least a deleted_by that's null when the platform did it.
Second, the NotPublic --> Public arrow is labelled with an "if." That arrow is a hope, not a contract, until you've tested it. which brings me to........
Opinion 2: Run the experiment before you write the code
I know. Boring. But hear me out.
The docs told me what events exist. They did not tell me:
- does deleting a post actually fire
no_longer_publicly_available? - do delete and privacy-change look different in any way? timing, extra events, anything?
- if a post goes private and then public again, do I get a second
publicly_available? - when you delete a post with comments, do the comments fire their own delete events?
Every one of those answers changes the design. So before writing any of it, I wrote down an experiment. Four throwaway videos on a test account, published through the API, and then in the app:
video 1 -> delete it
video 2 -> set to "only me"
video 3 -> set to "friends"
video 4 -> "only me", wait 2 min, back to "everyone"
then: dump every webhook received in the last hour, in orderHalf an hour of clicking. And the output tells you which designs are even possible. If video 4 never gets a second publicly_available, then any design that relies on "we'll un-hide it when it comes back" is dead on arrival, and you just found that out for free instead of from a support ticket.
My rule now: if the design depends on an event, someone has to have seen that event with their own eyes. Not read about it. Seen it. Docs describe the happy path the platform's writers had in mind. Your app lives in all the other paths.
The options, laid out
Once I accepted there was no delete event, I could see four ways to go. Here they are, with how I feel about each:

C is what you have before you start, and it's not great. B is what everyone reaches for first and I think it's the worst one. A is the safe floor. D is the one I ended up wanting, with A as the fallback.
Opinion 3: When the signal is ambiguous, ask the source, but ask the right person
The idea behind D is simple: the webhook says "something happened to post X." Fine. Go ask the platform directly whether post X still exists.
The catch is who asks. If you check from the outside, like an anonymous public lookup, a private video and a deleted video look exactly the same: both are gone. You learned nothing. You paid an API call to learn nothing.
But the creator can still see their own private videos. So if you ask with the creator's own token, there's a real chance the answer splits:

"There's a real chance" is doing a lot of work in that sentence, and I want to be upfront about it. One of the API's list endpoints literally says it "may return fewer videos... due to reasons such as videos deleted or marked as private." If the query endpoint behaves the same way, then even the owner's token can't tell private from gone, and option D collapses back into option A.
So D is gated. Built, but switched off, until a probe proves the API actually separates the two cases. More on that in opinion 6.
Opinion 4: "absent" and "error" are not the same answer
This is the opinion I'd underline twice.
When you're checking if something still exists, there's a very easy bug waiting for you: you call the API, it fails, you get back no videos, and your code goes "no videos -> it's deleted." Now a rate limit just deleted someone's post. Well, deleted it in your app, which from the user's point of view is the same thing.
The only response that's allowed to mean "deleted" is a clean, successful, well-formed response that doesn't include the id. Everything else is "I don't know," and "I don't know" must never write a delete.

A few things in there:
- Check the success marker exactly. Some APIs put an
errorobject on every response, including successful ones, where the "error" code is the literal string"ok". If you check "is there an error object" instead of "is the code ok," you'll treat every success as a failure, or worse, the other way around. Read the success example in the docs, not just the error examples. - Wrong account means no verdict. If the creator reconnected a different account since publishing, their new token will never see the old video. Absent, but not deleted. Compare the account id on the webhook with the account id on the token before you trust a "missing."
- Expired token? Don't refresh it here. It's tempting to refresh inline. But a background consumer refreshing tokens is how you get two workers racing on the same refresh token and one of them invalidating the other. Let whatever owns token refresh do it. Retry, and if you run out of retries, close the event with no verdict. The post just stays NotPublic, which is still true.
- On the last retry, give up quietly. Don't park it in the dead letter queue forever. "We couldn't find out" is a legit final answer, and the post is already in the honest NotPublic state.
Opinion 5: Webhooks arrive out of order. Your state machine needs a clock.
Picture this. A creator sets a post to private, then changes their mind 20 seconds later and makes it public again. Two events go out: no_longer_publicly_available, then publicly_available.
Now picture them arriving in the other order. (They will. eventually. Retries, different workers, network weather.)

Without that guard, the late event wins and you show a public post as hidden. The fix is to store the platform's event timestamp alongside the state, and only apply an event if it's at least as new as the last one you applied.
-- example
update posts
set not_public_since = to_timestamp($2),
visibility_event_at = to_timestamp($2)
where platform_publish_id = $1
and deleted_at is null
and (visibility_event_at is null
or visibility_event_at <= to_timestamp($2));
One small detail:
- Use the platform's timestamp, not your
now(). Your clock tells you when you processed it. Theirs tells you when it happened. Only one of those is the order of events in the real world.
Also, check your dedupe key. If you dedupe webhooks on (account, event_type, timestamp), then a creator bulk-deleting three posts in the same second gives you three events with identical keys. Two of them get thrown away as "duplicates." Put the post id in the key.
Opinion 6: Ship it switched off, and flip it with a probe
I built option D. The code is done. It's tested. And it's not routed, on purpose.
The whole design rests on one fact nobody has proven yet: does the owner's token still see their own private video? If yes, D works. If no, D is a fancy way to delete private posts, which is the exact bug I spent opinion 1 complaining about.
So the rollout looks like this:

Opinion 7: Your UI copy is part of the design
Once you accept that you can't always tell what happened, the words on the screen have to accept it too.
Bad:
This post was deleted.
Better:
No longer public on TikTok: it may have been deleted, made private, or removed.
And hide the "View on TikTok" link when it's in that state, because it's going to 404 or show "this video is unavailable" and that just looks like your app is broken.
Same for notifications. If you want to tell the user "hey, your post went dark" (which is genuinely useful when moderation is the cause), the wording has to stay neutral. You don't know it was moderation. Don't say it was. "Your post is no longer public" is true in every case. "Your post was removed for violating guidelines" is a guess, and a scary one.
If the backend has three possible truths, the frontend should not pick one at random.