Index: src/config/database.js
===================================================================
--- src/config/database.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/config/database.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
@@ -455,4 +455,6 @@
   ensureColumn('ap_outbox', 'attachments', 'TEXT');
   ensureColumn('posts', 'ap_visibility', 'TEXT');   // public|quiet|friends|direct (C2S addressing, shaer-60b)
+  ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0');        // paid post (klonkt-demo-aki)
+  ensureColumn('posts', 'paid_min_cents', 'INTEGER');        // required support; null = owner default
   ensureColumn('ap_outbox', 'visibility', 'TEXT');  // 'direct' = private mention, never Public (shaer-tqc)
   ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
Index: src/routes/posts.js
===================================================================
--- src/routes/posts.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/routes/posts.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
@@ -20,4 +20,6 @@
 import VideoCoverService from '../services/VideoCoverService.js';
 import ActivityPubService from '../services/ActivityPubService.js';
+import { premiumUnlocked } from '../services/PatreonService.js';
+import { defaultMinCents as paidDefaultMinCents } from '../services/PaidPatreonService.js';
 import MusicMeta from '../services/MusicMeta.js';
 
@@ -327,4 +329,7 @@
   const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
   const fanOnly = req.body.fan_only ? 1 : 0;
+  const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0;   // paid posts (klonkt-demo-aki)
+  const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
+  const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
   const nsfw = req.body.nsfw ? 1 : 0;
   const cw = (req.body.content_warning || '').trim().slice(0, 200);
@@ -381,4 +386,5 @@
   );
   cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
+  db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, postId);
 
   // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
@@ -399,5 +405,5 @@
         id: postId, slug: finalSlug, title: title || finalSlug,
         content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
-        published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
+        published_at: publishedAt, created_at: now, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
       }).catch(() => { /* best-effort */ });
     }
@@ -463,4 +469,7 @@
   const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
   const fanOnly = req.body.fan_only ? 1 : 0;
+  const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0;   // paid posts (klonkt-demo-aki)
+  const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim();
+  const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null;
   const nsfw = req.body.nsfw ? 1 : 0;
   const cw = (req.body.content_warning || '').trim().slice(0, 200);
@@ -522,4 +531,5 @@
   );
   cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
+  db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, post.id);
 
   // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
@@ -544,5 +554,5 @@
       id: post.id, slug: finalSlug, title: title || finalSlug,
       content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language,
-      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,
+      published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson,
     };
     if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
@@ -638,4 +648,15 @@
 // post render and the fan gate (premium fan_only) so navigation is consistent
 // everywhere. Solo: within the site (pinned first, then date). Hub: globally by date.
+// A short public teaser for a paid post: its excerpt, else the first ~280 chars
+// of the (stripped) content. Shared by the web gate and federation.
+function paidTeaser(post, max = 280) {
+  if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim();
+  // Only the FIRST paragraph: a paid teaser must never spill later content.
+  const html = String((post && post.content) || '');
+  const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || '';
+  const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
+  return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text;
+}
+
 function postNeighbors(site, post, isHub) {
   const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
@@ -1120,4 +1141,22 @@
       fgTitle: post.title || '',
       fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug,
+      newerPost,
+      olderPost,
+    });
+  }
+
+  // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who
+  // is not the owner/editor. The passkey unlock arrives in slices 3-4; for now
+  // the owner previews the full post, everyone else sees the teaser + notice.
+  const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
+  if (post.paid && !canEditThis) {
+    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
+    return renderPage(req, res, 'pages/paid-gate', {
+      pageTitle: post.title || 'Voor supporters',
+      bodyClass: 'on-special',
+      pgTitle: post.title || '',
+      pgTeaser: paidTeaser(post),
+      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
+      pgSlug: post.slug,
       newerPost,
       olderPost,
Index: src/services/ActivityPubService.js
===================================================================
--- src/services/ActivityPubService.js	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/services/ActivityPubService.js	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
@@ -278,4 +278,26 @@
   const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
   const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : '';
+
+  // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
+  // content, so nothing leaks past the paywall. No media attachments either.
+  if (post.paid) {
+    const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
+    const _firstP = (String(post.content || '').match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, ''])[1] || '';
+    const rawTeaser = String(post.excerpt || '').trim()
+      || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
+    return {
+      '@context': AP_CONTEXT,
+      id,
+      type: 'Note',
+      attributedTo: aId,
+      content: `${titleHtml}<p>${esc(rawTeaser)}${rawTeaser ? '…' : ''}</p><p><a href="${human}">Lees de volledige post (supporters)</a></p>`,
+      url: human,
+      published: toISO(post.published_at || post.created_at || Date.now()),
+      to: [PUBLIC],
+      cc: [`${aId}/followers`],
+      tag: [...hashtagTags(base, post.content)],
+      replies: `${id}/replies`,
+    };
+  }
 
   // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
Index: src/views/pages/paid-gate.ejs
===================================================================
--- src/views/pages/paid-gate.ejs	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
+++ src/views/pages/paid-gate.ejs	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
@@ -0,0 +1,36 @@
+<div class="container pg-navwrap">
+  <%- include('../partials/post-nav', { newerPost: (typeof newerPost !== 'undefined' ? newerPost : null), olderPost: (typeof olderPost !== 'undefined' ? olderPost : null) }) %>
+</div>
+
+<article class="pg-page">
+  <% if (typeof pgTitle !== 'undefined' && pgTitle) { %><h1 class="pg-title"><%= pgTitle %></h1><% } %>
+
+  <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %>
+    <div class="pg-teaser"><p><%= pgTeaser %></p></div>
+  <% } %>
+
+  <section class="pg-card">
+    <div class="pg-lock">💶</div>
+    <h2 class="pg-h2">Voor supporters</h2>
+    <p class="pg-sub">
+      Deze post is voor supporters van deze site. Steun je de maker op Patreon
+      <% if (typeof pgCents !== 'undefined' && pgCents) { %>(vanaf &euro;<%= (pgCents/100).toFixed(2) %>)<% } %>,
+      dan ontgrendel je 'm met je passkey. Geen account, geen cookie.
+    </p>
+    <p class="pg-soon">Ontgrendelen met je Patreon-passkey komt eraan.</p>
+  </section>
+</article>
+
+<style>
+  .pg-navwrap { max-width: 720px; margin: 0.5rem auto 1.5rem; padding: 0 1rem; }
+  .pg-page { max-width: 720px; margin: 0 auto 3rem; padding: 0 1rem; }
+  .pg-title { font-family: var(--font-display, serif); font-size: clamp(1.6rem, 4vw, 2.2rem); margin: 0 0 1rem; }
+  .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95;
+    -webkit-mask-image: linear-gradient(180deg, #000 55%, transparent); mask-image: linear-gradient(180deg, #000 55%, transparent); }
+  .pg-card { margin: 1.5rem 0 0; border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); border-radius: 18px; padding: 30px 26px; text-align: center; }
+  .pg-lock { font-size: 38px; margin-bottom: 6px; }
+  .pg-h2 { font-size: 22px; margin: 0 0 8px; }
+  .pg-sub { opacity: .85; line-height: 1.6; margin: 0 auto 12px; max-width: 34em; }
+  .pg-soon { display: inline-block; padding: 10px 18px; border-radius: 10px; font-weight: 600;
+    background: color-mix(in srgb, var(--accent, #6b8f71) 14%, transparent); color: var(--ink, inherit); }
+</style>
Index: src/views/pages/post-edit.ejs
===================================================================
--- src/views/pages/post-edit.ejs	(revision 61e3daf1227ad67cd38c4a5e9e79cfeb1ea92121)
+++ src/views/pages/post-edit.ejs	(revision 928d1c7ba5c6f2dd3d97312711ad81f25f420cdd)
@@ -367,4 +367,18 @@
             <span>🔒 <%= t('pedit.fan_only_label') %></span>
           </label>
+          <label class="pe-checkbox" style="margin-top:8px">
+            <input type="checkbox" name="paid" value="1" id="pe-paid" <%= post.paid ? 'checked' : '' %>>
+            <span>💶 Betaalde post (supporters ontgrendelen met Patreon)</span>
+          </label>
+          <div id="pe-paid-price" style="margin:6px 0 0 26px;<%= post.paid ? '' : 'display:none' %>">
+            <label style="font-size:.85rem;opacity:.8">Vereist steunbedrag (euro, leeg = standaard)
+              <input type="text" name="paid_min_eur" inputmode="decimal" style="width:100px"
+                     value="<%= post.paid_min_cents ? (post.paid_min_cents/100).toFixed(2) : '' %>">
+            </label>
+          </div>
+          <script nonce="<%= cspNonce %>">
+            (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price');
+              if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })();
+          </script>
           <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
           <label class="pe-checkbox" style="margin-top:8px">
