Index: src/routes/activitypub.js
===================================================================
--- src/routes/activitypub.js	(revision 7ccdced94ed9434ec28142de104082d14bd8cd78)
+++ src/routes/activitypub.js	(revision e4119c22651ba589d6fceee5bc1802a11716f279)
@@ -965,4 +965,54 @@
     }, 'private, no-store');
   }
+  // ── Ouder dan wat je hebt (shaer-ewrt) ──────────────────────────
+  //
+  // De volle lezing hieronder is vier poten van de nieuwste zestig, en wat
+  // daaronder lag was nergens te vragen: een 'load more' had geen adres. Dit is
+  // dat adres, met een samengestelde cursor zoals bij /messages, en het antwoord
+  // is een OrderedCollectionPage met partOf -- een deel, en dat zegt het ook.
+  //
+  // De pagina's volgen elkaar via `next`; de client bouwt er geen na.
+  const inboxId = `${base}/ap/users/${auth.site.slug}/inbox`;
+  const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
+  const ouderLink = (pagina) => (pagina.more && pagina.oldest
+    ? `${inboxId}?before=${encodeURIComponent(pagina.oldest)}${size}` : undefined);
+  if (req.query.before) {
+    const pagina = AP.inboxPage(auth.site.slug, { before: String(req.query.before), limit: req.query.limit });
+    const ids = (leg) => pagina.rows.filter((r) => r.leg === leg).map((r) => r.ref);
+    const tl = new Map(AP.timelineRowsByIds(auth.site.slug, ids('timeline')).map((r) => [r.id, r]));
+    const mn = new Map(AP.messageRowsByUri(auth.site.slug, ids('message')).map((r) => [r.object_uri, r]));
+    const rp = new Map(AP.replyRowsByUri(auth.site.slug, ids('reply')).map((r) => [r.object_uri, r]));
+    const reacties = AP.getReactionsFor(auth.site.slug, [...tl.keys()]);
+    const ctx = { base, me, myHandle, p: P };
+    const mine = AP.selfAuthor(base, auth.site);
+    const items = [];
+    for (const r of pagina.rows) {
+      if (r.leg === 'timeline') {
+        const t = tl.get(r.ref);
+        if (t) items.push(timelineItem(t, { p: P, reactions: reacties }));
+      } else if (r.leg === 'message') {
+        // Dezelfde poort als de volle lezing: een dichte berichtenpoort houdt
+        // vreemden buiten, nooit de hulpvraag en nooit de guardians.
+        const m = mn.get(r.ref);
+        if (m && (messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) items.push(messageItem(m, ctx));
+      } else if (r.leg === 'reply') {
+        const m = rp.get(r.ref);
+        if (m) items.push(replyItem(m, ctx));
+      } else {
+        const n = AP.getOutboxNote(base, r.ref);
+        if (n) items.push(sentItem(n, { me, mine }));
+      }
+    }
+    return AP.sendAP(res, {
+      '@context': AP.AP_CONTEXT,
+      id: `${inboxId}?before=${encodeURIComponent(String(req.query.before))}${size}`,
+      type: 'OrderedCollectionPage',
+      partOf: inboxId,
+      orderedItems: items,
+      // Ontbreekt next, dan is de inbox op. De client hoort dat te weten zonder
+      // te gokken, anders kan zijn knop niet eerlijk verdwijnen.
+      next: ouderLink(pagina),
+    }, 'private, no-store');
+  }
   const rows = AP.getTimeline(auth.site.slug, 60);
   // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
@@ -1021,4 +1071,16 @@
     totalItems: items.length,
     orderedItems: items,
+    // Waar het oudere begint (shaer-ewrt). Een `next` op de wortel en geen
+    // `first`, en dat is een afweging: de items hierboven ZIJN de eerste
+    // pagina, en een `first` ernaast laat een lezer die de paginaweg voorrang
+    // geeft -- de onze, op iOS en op Android -- bij elke verversing de hele
+    // inbox opnieuw in pagina's binnenhalen. `next` volgt niemand vanzelf; hij
+    // is er voor wie erom vraagt.
+    //
+    // De cursor komt uit dezelfde unie als de pagina's, over de nieuwste zestig
+    // van alle poten samen. Die zitten hier allemaal tussen, dus de volgende
+    // pagina sluit aan zonder gat; wat de wortel per poot dieper had komt daar
+    // hooguit nog een keer langs.
+    next: ouderLink(AP.inboxPage(auth.site.slug, { limit: req.query.limit || 60 })),
   });
   return undefined;
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 7ccdced94ed9434ec28142de104082d14bd8cd78)
+++ src/services/ActivityPubService.js	(revision e4119c22651ba589d6fceee5bc1802a11716f279)
@@ -55,5 +55,5 @@
   getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
   feedCursor, feedChangesSince, waitForFeedChange,
-  conversationHeads, conversationHistory, messageRowsByUri,
+  conversationHeads, conversationHistory, inboxPage, messageRowsByUri,
   readMarkers, markRead, unreadPerConversation, getDirectMessages,
   isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
@@ -64,5 +64,5 @@
   getTimeline, replyRowsByUri, timelineRowsByIds, getReplyMessages,
   feedCursor, feedChangesSince, waitForFeedChange,
-  conversationHeads, conversationHistory, messageRowsByUri,
+  conversationHeads, conversationHistory, inboxPage, messageRowsByUri,
   readMarkers, markRead, unreadPerConversation, getDirectMessages,
   isoStamp, timelineAttachments, extractEmojiTags, gateAttachments,
@@ -4395,5 +4395,5 @@
   getInteractions, getInteractionById, setInteractionBoosted, setInteractionLiked, buildReplyNote, getOutboxNote, getSentNotes, deliverReply, resolveRemoteNote, noteAudience, mayReadNote,
   listOutbox, deliverOutboxDelete, deliverOutboxUpdate, deliverDirectNote,
-  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, contentWarning, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
+  webfingerResolve, followActor, resolveRemoteActor, unfollowActor, handleMoveInbox, moveAccount, listFollowing, setAutoBoost, backfillFromOutbox, getTimeline, timelineRowsByIds, contentWarning, getDirectMessages, readMarkers, markRead, unreadPerConversation, messageRowsByUri, replyRowsByUri, conversationHeads, conversationHistory, inboxPage, isoStamp, timelineAttachments, timelineEmojis, timelineObjectLinks, timelineQuote, timelineEmbed, applyQuoteProps, deliverToActor, sendInteraction, voteOnPoll, voteOnRemotePoll,
   acceptGatedFollow, rejectGatedFollow, isWardGuardian, outboxAudience, sendFollowDecision,
   gateOutgoingFollow, performApprovedFollow, recordGuardianEvent, listGuardianEvents, GUARDIAN_EVENT_KEEP,
Index: src/services/ap-timeline.js
===================================================================
--- src/services/ap-timeline.js	(revision 7ccdced94ed9434ec28142de104082d14bd8cd78)
+++ src/services/ap-timeline.js	(revision e4119c22651ba589d6fceee5bc1802a11716f279)
@@ -351,4 +351,55 @@
 }
 
+/**
+ * Een pagina van de inbox, nieuwste eerst, met een cursor (shaer-ewrt).
+ *
+ * De inbox-lezing is vier poten van elk de nieuwste zestig, en daaronder hield
+ * het op: wat ouder was kon de app nergens vragen. Dit is de weg naar beneden.
+ *
+ * EEN LIMIET VOOR ALLE VIER SAMEN, om dezelfde reden als bij een gesprek: per
+ * poot afkappen geeft een pagina die bij de ene poot tot vorige week reikt en
+ * bij de andere tot gisteren, en dan valt er bij de volgende pagina iets in het
+ * gat ertussen. Over de unie afgekapt is een pagina compleet voor zijn periode.
+ *
+ * Alleen referenties: welke poot, welk object, welk stempel. Het kaartwerk doet
+ * de route met dezelfde bouwers als de verschil-lezing, zodat een kaart er
+ * hetzelfde uitziet hoe hij ook binnenkwam.
+ *
+ * De poten zijn dezelfde als in de volle lezing: de berichtpoot slaat over wat
+ * ook in de tijdlijn staat (getDirectMessages), en de stempels zijn die waarop
+ * elke poot daar sorteert.
+ */
+const INBOX_UNION = `
+  SELECT 'timeline' AS leg, t.id AS ref, ${STEMPEL('COALESCE(t.published, t.created_at)')} AS stamp
+    FROM ap_timeline t WHERE t.slug = @slug
+  UNION ALL
+  SELECT 'message', m.object_uri, ${STEMPEL('COALESCE(m.published, m.created_at)')}
+    FROM ap_mentions m
+   WHERE m.slug = @slug
+     AND NOT EXISTS (SELECT 1 FROM ap_timeline t WHERE t.slug = m.slug AND t.id = m.object_uri)
+  UNION ALL
+  SELECT 'reply', i.object_uri, ${STEMPEL('COALESCE(i.published, i.created_at)')}
+    FROM ap_interactions i JOIN posts p ON p.id = i.post_id JOIN sites s ON s.id = p.site_id
+   WHERE s.slug = @slug AND i.kind = 'reply'
+  UNION ALL
+  SELECT 'sent', o.id, ${STEMPEL('o.created_at')}
+    FROM ap_outbox o WHERE o.site_slug = @slug`;
+
+export function inboxPage(slug, { before = null, limit = 60 } = {}) {
+  try {
+    const n = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
+    const sep = String(before || '').indexOf('|');
+    const bStamp = before && sep > 0 ? String(before).slice(0, sep) : null;
+    const bRef = before && sep > 0 ? String(before).slice(sep + 1) : null;
+    const rows = db.prepare(`
+      SELECT leg, ref, stamp FROM (${INBOX_UNION})
+       WHERE (@bStamp IS NULL OR stamp < @bStamp OR (stamp = @bStamp AND ref < @bRef))
+       ORDER BY stamp DESC, ref DESC LIMIT @n`).all({ slug, bStamp, bRef, n: n + 1 });
+    const more = rows.length > n;
+    const page = more ? rows.slice(0, n) : rows;
+    return { rows: page, more, oldest: cursorOf(page[page.length - 1]) };
+  } catch { return { rows: [], more: false, oldest: null }; }
+}
+
 // De kolommen die een bericht tot kaart maken. Een constante, want de
 // gesprekslezing haalt dezelfde rows op: twee lijsten die uiteenlopen leveren
