A Forge app that listens for avi:confluence:deleted:page to keep its own storage in sync looks correct in testing. Trash a single page on its own, the event fires, your cleanup handler runs, everything's fine. Then a customer deletes a space with four hundred pages in it, and your app never hears about any of them. The storage sits there, orphaned, forever — not because your trigger is broken, but because Confluence was never going to tell you.
Atlassian's own documentation says this plainly, in a section most people never read until they've already shipped the bug: "We only emit a delete event for top-level entities." This tutorial is that rule in full, the one exception to it, the workaround Atlassian itself prescribes, and — because it shares a search cluster with a real, separate problem — a clear line between "the event doesn't fire for a descendant, by design" and "the event doesn't fire at all, which might be a bug." It's the same shape of trap as a Forge bridge call that resolves cleanly while silently doing nothing — the platform not telling you something happened is a recurring category of Forge bug, not a one-off.
[!NOTE]
Prerequisites
- A Forge app with a Confluence delete-event trigger already registered (
avi:confluence:deleted:page,avi:confluence:trashed:page, or the blog-post/space equivalents).write:confluence-contentandread:confluence-content.summaryscopes granted, per the current reference docs.- Basic familiarity with the Forge manifest and Confluence's REST API v2.
- A Confluence Cloud site (sandbox is fine) where you can create a small page hierarchy and delete it to observe the behavior yourself.
[[steps]]
- Know the rule before you build around it — cascading deletes emit exactly one event, not one per descendant.
- Understand the one exception — an individually-trashed entity still gets its own event.
- Cache the hierarchy before anything is deleted, using the REST descendants endpoint.
- Listen at the right level, and recurse over your own cache when a higher-level delete fires.
- Don't confuse this with a different, apparently open bug — events that don't fire at all.
Why Confluence page delete events never fire in your Forge trigger
Atlassian's Forge events documentation has a section called "Handling cascading events in Confluence," and it states the scope directly: "When a space is deleted in Confluence, all its descendants are also removed. This includes entities from a content tree (pages, whiteboards, databases, smart links, folders) as well as blog posts, and child entities: comments, attachments, and custom content. However, it's important to note: We only emit a delete event for top-level entities. When a space is deleted, we won't emit separate delete events for its children, which include pages, blog posts, whiteboards, databases, smart links, folders, custom content, comments, attachments, or child custom content." The same applies one level down: deleting a page or a blog post fires no delete events for its own comments, attachments, or custom content.
That's the whole rule, and it's worth restating precisely because it's easy to round it off into something looser. This isn't "delete events are unreliable" or "delete events sometimes don't fire" — it's a specific, documented, by-design scope limit: the event fires once, for the entity actually named in the delete action, and never for anything underneath it.
The reference table for these events lists what actually exists to listen for: avi:confluence:trashed:page, avi:confluence:restored:page, and avi:confluence:deleted:page for pages; avi:confluence:trashed:blogpost, avi:confluence:restored:blogpost, and avi:confluence:deleted:blogpost for blog posts; avi:confluence:trashed:custom_content, avi:confluence:restored:custom_content, and avi:confluence:deleted:custom_content for custom content; avi:confluence:trashed:attachment, avi:confluence:restored:attachment, and avi:confluence:deleted:attachment for attachments; avi:confluence:deleted:comment for comments (comments have no trashed state — they go straight to deleted); and avi:confluence:deleted:space:V2 for spaces. Page and blog-post events need the read:confluence-content.summary scope; the trashed variants additionally need write:confluence-content — a requirement the docs themselves flag as one that "may be removed in the future," so don't build a permanent assumption around it either way. One more gap worth knowing before you rely on the exception in the next section: whiteboards, databases, folders, and smart links currently have no trashed/deleted/restored trigger at all — only created/updated/moved/copied/permissions_updated — so the individually-trashed exception is only actually actionable today for pages, blog posts, attachments, comments, and custom content.
Understand the one exception
There's exactly one case where an individual entity gets its own delete event even though it's part of a hierarchy: trashing it on its own. The documentation states it exactly: "When an individual content tree entity is trashed, it gets detached from a content tree hierarchy, so all individually deleted entities will receive delete events." Trash one page by itself, in isolation, and that page's own delete event fires normally. Trash the space it lives in, and that same page's delete event never fires — because this time it wasn't deleted on its own, it was deleted as part of the space.
This is the distinction that makes the rule easy to miss in testing. If your test plan is "trash a page, confirm the event fires, ship it," you will never see the gap — you're always testing the one case that works. It's the same testing blind spot behind Confluence's nested-macro migration trap: the simple case you naturally test first is exactly the one Atlassian's own documentation already covers well, and the gap only shows up once real content gets more structured than your test fixture.
Cache the content tree with the descendants REST API before you delete
Atlassian's own prescribed fix isn't a different event to listen for — there isn't one — it's caching the structure yourself ahead of time. The documentation frames it in three steps: listen for delete events only at the higher levels (space, page, blog post, custom content) as your trigger that a cascading delete may have happened; fetch and save the entity structure before anything is deleted, using Confluence's REST API to record all the content tree entities, blog posts, comments, attachments, and custom content your app cares about; then, when a higher-level delete event fires, recurse over your own cached hierarchy to find everything else that needs handling, rather than waiting for events that will never come.
The REST endpoint that does the fetching is GET /wiki/api/v2/pages/{id}/descendants, which returns descendants in the content tree for a given page, in top-to-bottom order, going as deep as you ask:
curl -s -H "Authorization: Basic $AUTH" \
"https://your-site.atlassian.net/wiki/api/v2/pages/${PAGE_ID}/descendants?depth=5&limit=250"
{
"results": [
{
"id": "98765",
"status": "current",
"title": "Runbook: escalation paths",
"type": "page",
"parentId": "12345",
"depth": 1,
"childPosition": 0
}
],
"_links": { "next": "..." }
}
depth defaults to 2 and goes up to 10; limit defaults to 25 and goes up to 250 per page of results. It requires the read:hierarchical-content:confluence scope. Call it on a schedule, or on every page-create/update event you already receive, and keep your own record current — the whole point is that by the time a space or parent page is deleted, you already know what was underneath it, because Confluence isn't going to tell you afterward.
How you know it worked: run the curl call above against a page you know has children, and confirm the response's results array actually lists them — check depth and parentId on each entry match what you expect, and that raising depth picks up grandchildren too. If results comes back empty for a page you know has children, check the read:hierarchical-content:confluence scope is granted before assuming the endpoint itself is wrong.
Detecting page deletes in Confluence Cloud (the workaround)
Putting the pieces together, your app's delete-handling logic ends up with two distinct paths. The low-level path handles the case the exception covers: an individually trashed page, blog post, comment, or attachment fires its own event, and you handle it directly, the way you'd expect. The high-level path handles everything else: a space-deleted or page-deleted event fires once, and your handler doesn't ask "what was deleted" — it asks "what did I have cached under this entity," walks that cached list, and cleans up every one of those records itself, because no further events are coming for any of them.
export async function onPageDeleted(event, context) {
const cachedDescendants = await getCachedDescendants(event.page.id);
for (const entity of cachedDescendants) {
await cleanUpStoredData(entity.id, entity.type);
}
await clearCachedHierarchy(event.page.id);
}
How you know it worked: create a small hierarchy in a test space — a parent page with two child pages, each with a comment and an attachment — run your caching step, then delete the parent page. If your app's storage still holds references to the child pages, comments, or attachments after the parent's single delete event has fired and your handler has run, the recursion step isn't reaching them; check that your cached structure actually includes descendants at the depth you deleted, not just direct children.
Don't confuse this with a different, apparently open bug
There's a second, unrelated problem living in the same search results, and it's worth separating clearly so you don't waste time debugging the wrong thing. A developer reported in June 2026 that avi:confluence:trashed:page and avi:confluence:deleted:page weren't firing at all — not "only for the top-level entity," but zero invocations, zero logs, for any delete of any kind, despite correct event names in the manifest, the right scopes granted, and a full uninstall-and-clean-redeploy to rule out a stale install. The same developer cross-posted the same report to Atlassian's community forum — not a second, independent confirmation, the same person following up in a second venue — where it's tied to two open tickets on Atlassian's public tracker asking for avi:confluence:deleted:page and avi:confluence:deleted:attachment support and for the write:confluence-content scope requirement to be relaxed. As of this writing, that thread ends without a staff reply confirming or denying it as a known issue — the last response just points the poster at Developer Support. There's a real tension worth naming rather than glossing over: one of the two open tickets, CONFCLOUD-82453, asks Atlassian to "add support for avi:confluence:deleted:page" — but the current reference docs already list that exact trigger as existing. Nothing found resolves whether the ticket is stale, scoped more narrowly than its title suggests, or points at a genuine reliability gap between "documented as shipped" and "actually fires." This tutorial doesn't resolve it either — it's recorded here as open, not explained away.
If you've registered the right events, granted the right scopes, and redeployed cleanly, and you're still seeing nothing fire for any delete — not just the descendants this tutorial explains — that's the other problem, not this one. This tutorial doesn't attempt to fix it, and you shouldn't assume the cache-and-recurse pattern above will help if events aren't reaching your app in the first place.
What this tutorial doesn't cover
This tutorial documents Confluence's cascading-delete event scope exactly as Atlassian's current documentation states it, the REST-based workaround Atlassian itself prescribes, and the boundary between that documented behavior and a separate, apparently unresolved delivery bug. It does not reproduce the cascading-delete behavior against a live Forge app deployment — the rule and the workaround are sourced entirely to Atlassian's own current documentation and REST API reference, not to a fresh test run for this piece. It does not resolve whether the two open tickets referenced above indicate a genuine reliability gap or something narrower — that's recorded as open, not settled. And it does not track a 2023 community answer that names a avi:confluence:archived:page event, which doesn't appear anywhere in the current trigger reference — if you find that name in older material, treat it as unverified against today's documented events, not as something to build against.
[[takeaways]]
- You now have the exact scope of Confluence's cascading-delete event limitation, quoted from Atlassian's own current documentation: one delete event per top-level entity deleted, none for its descendants, with the single exception of an entity trashed entirely on its own.
-
You now have the REST-based workaround Atlassian itself prescribes — cache the hierarchy via
GET /wiki/api/v2/pages/{id}/descendantsbefore deletion, listen only at the higher levels, and recurse over your own cached structure when a higher-level delete fires. - This does not cover a separate, apparently unresolved bug where delete events don't fire at all, for any entity — reported by a real developer with correct configuration, tied to two open Atlassian tickets, and unresolved in the thread as fetched.
- This does not cover whether the two open Atlassian tickets referenced here indicate a broader reliability problem — that's recorded as an open question, not something this tutorial claims to answer.
Originally published on leanzero.net. More Atlassian, Forge and local-AI write-ups at leanzero.net/blog, and if you're planning a migration or a Forge app, that's what we do: leanzero.net/services.
Top comments (0)