<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Perufitlife</title>
    <description>The latest articles on DEV Community by Perufitlife (@perufitlife).</description>
    <link>https://dev.to/perufitlife</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3897417%2F545b848b-bfb9-4725-95f7-29d6af2e1bc7.png</url>
      <title>DEV Community: Perufitlife</title>
      <link>https://dev.to/perufitlife</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/perufitlife"/>
    <language>en</language>
    <item>
      <title>How to let Claude schedule your social media posts (MCP, step by step)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Thu, 01 Oct 2026 10:01:02 +0000</pubDate>
      <link>https://dev.to/perufitlife/how-to-let-claude-schedule-your-social-media-posts-mcp-step-by-step-3d41</link>
      <guid>https://dev.to/perufitlife/how-to-let-claude-schedule-your-social-media-posts-mcp-step-by-step-3d41</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm Renzo, and I build PostWire, the MCP server used here. It has a free plan. The same steps work with any MCP server that exposes scheduling tools; the tool names will differ.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude can't post to LinkedIn or Bluesky on its own. It has no access to your accounts. It can once you connect an &lt;strong&gt;MCP server&lt;/strong&gt; (Anthropic calls these &lt;em&gt;connectors&lt;/em&gt;) that holds those connections and exposes tools like "schedule this post". This tutorial sets that up, schedules a post, plans a week of posts, and cancels one. Along the way it shows the actual tool calls Claude makes, so you can do the same from your own agent.&lt;/p&gt;

&lt;p&gt;Everything below was run on Oct 1, 2026 against PostWire's hosted server with a test account.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Add the server
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In Claude (web, desktop, mobile):&lt;/strong&gt; PostWire is in Claude's connector directory. Search for "PostWire", press &lt;strong&gt;Connect&lt;/strong&gt;, and sign in with your email. You get a 6-digit code; there's no password and no card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In Claude Code&lt;/strong&gt;, one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http postwire https://postwire.io/api/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without a key, Claude Code signs you in with OAuth: run &lt;code&gt;/mcp&lt;/code&gt; inside Claude Code and pick &lt;code&gt;postwire&lt;/code&gt;. To use an API key instead (dashboard → &lt;strong&gt;API &amp;amp; MCP → Copy key&lt;/strong&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http postwire https://postwire.io/api/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--header&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer pw_live_your_key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ claude mcp list
postwire: https://postwire.io/api/mcp (HTTP) - ✔ Connected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hosted server has 15 tools. The ones this tutorial uses are &lt;code&gt;create_connect_link&lt;/code&gt;, &lt;code&gt;generate_posts&lt;/code&gt;, &lt;code&gt;schedule_post&lt;/code&gt;, &lt;code&gt;plan_week&lt;/code&gt;, &lt;code&gt;list_scheduled_posts&lt;/code&gt; and &lt;code&gt;cancel_scheduled_post&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Connect the networks you post to
&lt;/h2&gt;

&lt;p&gt;Ask: &lt;em&gt;"Connect my LinkedIn."&lt;/em&gt; Claude calls &lt;code&gt;create_connect_link&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"create_connect_link"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"linkedin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and gets back a link that's valid for one hour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://postwire.io/connect/#…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"linkedin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You open it and sign in on LinkedIn itself. Claude never sees a password. The networks you can connect are TikTok, Instagram, YouTube, LinkedIn (personal profiles), Facebook Pages, Bluesky, Mastodon, Telegram and Discord. &lt;strong&gt;X, Threads, Reddit and Pinterest aren't available.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Tell Claude your time zone once
&lt;/h2&gt;

&lt;p&gt;Scheduling needs an exact time with an offset (&lt;code&gt;run_at&lt;/code&gt;, ISO 8601, up to 365 days ahead). Say &lt;em&gt;"I'm in New York"&lt;/em&gt; once and Claude will turn "Friday at 9" into &lt;code&gt;2026-10-09T09:00:00-04:00&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Schedule one post
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"Write a short post announcing our autumn menu for LinkedIn and Bluesky and schedule it for Friday 9:00."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude first calls &lt;code&gt;generate_posts&lt;/code&gt;, which returns one draft per network and publishes nothing. It shows you the drafts. After you approve, it calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"schedule_post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"run_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-09T09:00:00-04:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"platforms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"linkedin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bluesky"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"per_platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"linkedin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bluesky"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Menu launch"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer (trimmed, from my run against a test channel):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scheduled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0f8b4368-…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"run_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-09T13:00:00+00:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"queued"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Menu launch"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Scheduled for 2026-10-09T13:00:00.000Z to …"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostWire's servers publish it&lt;/strong&gt;, so the chat and your laptop can be closed at 9:00.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The checks happen now, not at 9:00.&lt;/strong&gt; If a network isn't connected, or a network needs a video and the post has none, &lt;code&gt;schedule_post&lt;/code&gt; refuses immediately with a &lt;code&gt;not_connected&lt;/code&gt; or &lt;code&gt;media_required&lt;/code&gt; code. You find out while you're still in the conversation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: Or plan the whole week
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"Plan the next 5 days of posts about our autumn menu and the farmers behind it, LinkedIn and Bluesky, 9 am New York time."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Claude calls &lt;code&gt;plan_week&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"plan_week"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"topic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"our new autumn menu and the farmers behind it"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"platforms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"linkedin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bluesky"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"days"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hour"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timezone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"America/New_York"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It writes one post per day, each from a different angle and each in the format of its network, and queues them starting tomorrow. In my run with &lt;code&gt;"days": 2&lt;/code&gt; (to a test Discord channel) it queued &lt;code&gt;2026-10-02T13:00:00Z&lt;/code&gt; and &lt;code&gt;2026-10-03T13:00:00Z&lt;/code&gt; (9:00 in New York). Day one opened with a lesson ("building our autumn menu taught us something we didn't expect…") and day two with an opinion ("hot take 🍂 most people scroll past a new menu…").&lt;/p&gt;

&lt;p&gt;Limits you should know about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;plan_week&lt;/code&gt; is for &lt;strong&gt;text networks&lt;/strong&gt;. It refuses TikTok, Instagram and YouTube, because those need a video or photo. Schedule those one post at a time.&lt;/li&gt;
&lt;li&gt;Each day uses one AI draft. The free plan allows 15 a day and 30 posts a month, and a post to two networks counts as 2.&lt;/li&gt;
&lt;li&gt;If the week doesn't fit in the month's posts, the extra days are saved as &lt;em&gt;held&lt;/em&gt;. They're never published on the current plan, and get scheduled if you upgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Check and cancel
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;"What's scheduled?"&lt;/em&gt; → &lt;code&gt;list_scheduled_posts&lt;/code&gt; returns each post's id, time, networks and status (&lt;code&gt;queued&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;, &lt;code&gt;canceled&lt;/code&gt;…).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;"Cancel Friday's post."&lt;/em&gt; →&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cancel_scheduled_post"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"arguments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0f8b4368-…"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A post can be cancelled until it starts publishing. The same queue shows up in the dashboard, with Reschedule and Cancel buttons.&lt;/p&gt;

&lt;h2&gt;
  
  
  Videos: the attachment problem
&lt;/h2&gt;

&lt;p&gt;If you attach a video in the chat, it never reaches an MCP server. Claude can't hand it over. Instead it calls &lt;code&gt;create_upload_link&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://postwire.io/upload/#…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expires_in"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max_bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1073741824&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You open that link on the device that has the file and upload it (up to 1 GB). Claude then calls &lt;code&gt;get_uploaded_file&lt;/code&gt; to get its &lt;code&gt;media_url&lt;/code&gt; and uses that as &lt;code&gt;video_url&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One caveat I found while writing this: &lt;code&gt;schedule_post&lt;/code&gt; has no privacy field today. &lt;strong&gt;A TikTok or YouTube video scheduled through it goes out private&lt;/strong&gt; (with no privacy value, PostWire posts to TikTok as private, and YouTube uploads default to private) unless the connected account's default visibility is set to public. For YouTube you can set that in the dashboard under Accounts. For a public video now, &lt;code&gt;post_to_social&lt;/code&gt; accepts &lt;code&gt;"privacy": "public"&lt;/code&gt;. For a public video later, use the REST call below, which passes &lt;code&gt;privacy&lt;/code&gt; through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without Claude: the same thing over REST
&lt;/h2&gt;

&lt;p&gt;Any agent framework (or a cron job) can do the same with one HTTP call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://postwire.io/api/schedule &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$POSTWIRE_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"run_at":"2026-10-09T09:00:00-05:00","platforms":["linkedin","bluesky"],"text":"Launch day.","label":"Launch"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GET /api/schedule&lt;/code&gt; lists the queue and &lt;code&gt;DELETE /api/schedule/&amp;lt;id&amp;gt;&lt;/code&gt; cancels a post. If a network isn't connected, you get &lt;code&gt;400&lt;/code&gt; with &lt;code&gt;"code": "not_connected"&lt;/code&gt; and the list of missing networks.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this is the wrong tool
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;If you need X, Threads, Pinterest or Reddit, PostWire doesn't publish there.&lt;/li&gt;
&lt;li&gt;LinkedIn is personal profiles only, not Company Pages.&lt;/li&gt;
&lt;li&gt;Claude asks before it publishes, and it should keep doing that. Every &lt;code&gt;post_to_social&lt;/code&gt; call is a real post on a real account, so approve the exact text.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Configs for Claude Desktop, Cursor, VS Code and Windsurf, plus runnable curl, Node and Python versions of the REST calls, are in the &lt;code&gt;Perufitlife/postwire-examples&lt;/code&gt; repository on GitHub. The step-by-step guide, with the FAQ, is here: &lt;a href="https://postwire.io/claude/schedule-social-media-posts/" rel="noopener noreferrer"&gt;How to schedule social media posts with Claude&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>mcp</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to post one video to TikTok, Instagram Reels and YouTube Shorts from n8n (works on n8n Cloud)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Thu, 01 Oct 2026 10:00:54 +0000</pubDate>
      <link>https://dev.to/perufitlife/how-to-post-one-video-to-tiktok-instagram-reels-and-youtube-shorts-from-n8n-works-on-n8n-cloud-25a9</link>
      <guid>https://dev.to/perufitlife/how-to-post-one-video-to-tiktok-instagram-reels-and-youtube-shorts-from-n8n-works-on-n8n-cloud-25a9</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I'm Renzo, and I build PostWire, the posting API this tutorial uses. It has a free plan, and the workflow below runs on it. If you'd rather call each network's API yourself, the first section explains what that involves.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You have a vertical video and you want it on TikTok, Instagram Reels and YouTube Shorts, posted from n8n, ideally with a caption that suits each network instead of one caption pasted three times.&lt;/p&gt;

&lt;p&gt;n8n has no core node that publishes to TikTok. Calling each network's API yourself means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TikTok&lt;/strong&gt;: a developer app with the Content Posting API. Until TikTok audits it, it can only post privately (&lt;code&gt;SELF_ONLY&lt;/code&gt;), to at most 5 users in 24 hours, and those accounts must be private.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instagram&lt;/strong&gt;: a Meta app that has been through App Review, then for each post you create a media container, poll until the video has processed, and publish it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;YouTube&lt;/strong&gt;: a Google Cloud project, with Google verification of the &lt;code&gt;youtube.upload&lt;/code&gt; scope before strangers can authorize it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shortcut is to call an API that already has those approvals. Below, that means four nodes and two HTTP requests. They only use the core &lt;strong&gt;HTTP Request&lt;/strong&gt; node, so the workflow runs on &lt;strong&gt;n8n Cloud&lt;/strong&gt; too, where community nodes that n8n hasn't verified can't be installed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manual Trigger → Set (idea, video, networks) → HTTP Request: /api/generate → HTTP Request: /api/post
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;You give it one idea and one video link.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/generate&lt;/code&gt; writes a TikTok caption, an Instagram caption, and a YouTube title, description and tags. &lt;strong&gt;Nothing is published at this step.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/api/post&lt;/code&gt; publishes the video to all three networks in one call, each with its own text, and returns one result per network.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I ran this exact workflow on Oct 1, 2026 in n8n 2.40.7 against the live API. The output further down comes from that run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;n8n (Cloud or self-hosted).&lt;/li&gt;
&lt;li&gt;A free PostWire account: one brand, 30 posts a month, no card. &lt;strong&gt;One video posted to three networks counts as 3 posts.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A vertical MP4 at a &lt;strong&gt;public URL that points straight at the file&lt;/strong&gt;. A Google Drive or Dropbox share link won't work, because it opens a web page, not the file. (If your video isn't public, see "No public URL?" below.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Connect the three accounts
&lt;/h2&gt;

&lt;p&gt;Create the account at postwire.io, open &lt;strong&gt;Accounts&lt;/strong&gt;, press &lt;strong&gt;Connect&lt;/strong&gt; on TikTok, Instagram and YouTube, and sign in on each network. Instagram needs a &lt;strong&gt;Business or Creator&lt;/strong&gt; account; "Log in with Instagram" works without a Facebook Page. You don't need a TikTok, Meta or Google developer app of your own.&lt;/p&gt;

&lt;p&gt;Then open &lt;strong&gt;API &amp;amp; MCP&lt;/strong&gt; and press &lt;strong&gt;Copy key&lt;/strong&gt;. The key starts with &lt;code&gt;pw_live_&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: One credential in n8n
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Credentials → Add credential → Header Auth&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Name: &lt;code&gt;Authorization&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Value: &lt;code&gt;Bearer pw_live_your_key&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both HTTP Request nodes use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The input node
&lt;/h2&gt;

&lt;p&gt;Add a &lt;strong&gt;Set&lt;/strong&gt; node called &lt;code&gt;Your idea, video and platforms&lt;/code&gt; with four fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;idea&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Announce that our AI meal planner now builds a full week of recipes from one photo of your fridge. Friendly, build-in-public tone.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;video_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://example.com/your-vertical-video.mp4&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;platforms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Array&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ ["tiktok", "instagram", "youtube"] }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;brand_voice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Plain language, first person, no hype, at most one emoji.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In a real workflow these come from the previous node: a new file in a folder, a row marked "ready" in a sheet, or the output of an AI video step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Write a caption per network
&lt;/h2&gt;

&lt;p&gt;Add an &lt;strong&gt;HTTP Request&lt;/strong&gt; node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Method: &lt;code&gt;POST&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;https://postwire.io/api/generate&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Authentication: &lt;strong&gt;Generic Credential Type → Header Auth →&lt;/strong&gt; the credential from step 2&lt;/li&gt;
&lt;li&gt;Send Body: on, Body Content Type &lt;strong&gt;JSON&lt;/strong&gt;, Specify Body &lt;strong&gt;Using JSON&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ JSON.stringify({ prompt: $json.idea, platforms: $json.platforms, media_url: $json.video_url, brand_voice: $json.brand_voice }) }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes 10–30 seconds. Here is what came back in my run (YouTube trimmed):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"drafts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tiktok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"One photo of your fridge → a full week of recipes. No more &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;what do I even make?&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; 😅 We just shipped this in our meal planner and I'm honestly still a bit shocked it works this well. #mealplanning #AIcooking #buildinpublic #mealprep #foodtech Drop a 🙋 if you want early access."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"We taught our AI to read your fridge 🧠"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"instagram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"One fridge photo. Seven days of recipes. 📸 We just shipped this — save this if you want to try it.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;#mealplanning #buildinpublic #AIcooking #mealprep #foodtech"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"youtube"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"We just shipped a feature I've been wanting to build for a long time — our AI meal planner can now generate a full week of recipes f…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three texts really are different. They're also not perfect: the brand voice asked for "at most one emoji" and the TikTok caption has two. Read the drafts before you publish anything unattended. The free plan allows 15 of these generations a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Publish to all three
&lt;/h2&gt;

&lt;p&gt;Add a second &lt;strong&gt;HTTP Request&lt;/strong&gt; node with the same method, credential and body settings, URL &lt;code&gt;https://postwire.io/api/post&lt;/code&gt;, and this body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{{ JSON.stringify({ platforms: $('Your idea, video and platforms').item.json.platforms, video_url: $('Your idea, video and platforms').item.json.video_url, per_platform: $json.drafts, options: { tiktok: { privacy_level: 'SELF_ONLY' } } }) }}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;per_platform&lt;/code&gt; is the &lt;code&gt;drafts&lt;/code&gt; object from step 4, passed back unchanged. Each network gets its own text, and YouTube gets the title and tags too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About privacy, before you go live.&lt;/strong&gt; This body posts the TikTok video as private (&lt;code&gt;SELF_ONLY&lt;/code&gt;) so a first test isn't public. YouTube uploads with no privacy value are private too. &lt;strong&gt;Instagram has no private mode: the Reel is public as soon as it posts.&lt;/strong&gt; When you're ready, replace the &lt;code&gt;options&lt;/code&gt; line with &lt;code&gt;privacy: 'public'&lt;/code&gt;, which makes both TikTok and YouTube public.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Read the result
&lt;/h2&gt;

&lt;p&gt;The answer has &lt;code&gt;posted&lt;/code&gt; (how many networks published) and &lt;code&gt;results&lt;/code&gt;, one per network. On success, each result has &lt;code&gt;ok: true&lt;/code&gt; and the network's id. YouTube also returns a &lt;code&gt;youtu.be&lt;/code&gt; link. TikTok returns &lt;code&gt;status: "processing"&lt;/code&gt;, because TikTok keeps processing the video after upload. &lt;code&gt;GET https://postwire.io/api/post/status?platform=tiktok&amp;amp;id=&amp;lt;id&amp;gt;&lt;/code&gt; tells you when it's live.&lt;/p&gt;

&lt;p&gt;My test account had no networks connected, so this is what my run returned. It also shows what a failure looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"posted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tiktok"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tiktok is not connected — open Accounts in the dashboard and connect it, then post again"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"not_connected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"what"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"TikTok was not connected to your account when the post was sent."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"fix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Connect TikTok in Accounts, or leave it out of the post. Only the networks you have connected can be published to."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Instagram and YouTube returned the same.) One network failing doesn't stop the others. Check &lt;code&gt;posted&lt;/code&gt;, not just the HTTP status: the response is a &lt;code&gt;200&lt;/code&gt; even when a network fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it safe to run unattended
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retries don't double-post.&lt;/strong&gt; Add &lt;code&gt;idempotency_key&lt;/code&gt; to the publish body, set to something unique per video (the file id, the sheet row). A retry with the same key is refused for 24 hours with &lt;code&gt;409 duplicate_post&lt;/code&gt;. That matters if you turn on n8n's "Retry On Fail". Without a key, an identical payload is still refused for 2 minutes. If nothing went out, the key is released, so a real retry still works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap the trigger.&lt;/strong&gt; Replace the Manual Trigger with a Google Drive, Google Sheets or Schedule trigger, and map &lt;code&gt;idea&lt;/code&gt; and &lt;code&gt;video_url&lt;/code&gt; from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish later.&lt;/strong&gt; POST the same body plus &lt;code&gt;run_at&lt;/code&gt; (ISO 8601 with offset, e.g. &lt;code&gt;2026-10-09T09:00:00-05:00&lt;/code&gt;) to &lt;code&gt;https://postwire.io/api/schedule&lt;/code&gt;. The connections and media are checked when you schedule, not at 7 a.m. when the post is due.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  No public URL?
&lt;/h2&gt;

&lt;p&gt;If the video is a binary item in n8n (from a Drive download, an AI video node, a form upload), use three more HTTP Request nodes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;POST https://postwire.io/api/media/upload-url&lt;/code&gt; with &lt;code&gt;{"content_type": "video/mp4", "size_bytes": &amp;lt;size&amp;gt;}&lt;/code&gt; returns an &lt;code&gt;upload_url&lt;/code&gt; and a &lt;code&gt;path&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;PUT&lt;/code&gt; to that &lt;code&gt;upload_url&lt;/code&gt;, Body Content Type &lt;strong&gt;n8n Binary File&lt;/strong&gt;, field &lt;code&gt;data&lt;/code&gt;. No auth header: the URL is signed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET https://postwire.io/api/media/finalize?path=&amp;lt;path&amp;gt;&lt;/code&gt; (with the credential) returns &lt;code&gt;media_url&lt;/code&gt;, which you use as &lt;code&gt;video_url&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Through n8n that path takes files up to 50 MB. From a script the API accepts up to 1 GB in parts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits, honestly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;TikTok, Instagram and YouTube never take a text-only post. They need a video (or photos for TikTok and Instagram).&lt;/li&gt;
&lt;li&gt;PostWire covers TikTok, Instagram, YouTube, LinkedIn (personal profiles), Facebook Pages, Bluesky, Mastodon, Telegram and Discord. &lt;strong&gt;Not X, Threads, Reddit or Pinterest.&lt;/strong&gt; If you need those, use another tool.&lt;/li&gt;
&lt;li&gt;PostWire's own n8n community node (&lt;code&gt;n8n-nodes-postwire&lt;/code&gt;, with a "Smart Distribute" option that does steps 4 and 5 in one node) isn't verified by n8n yet, so it installs on self-hosted n8n only. That's why this tutorial uses plain HTTP Request nodes.&lt;/li&gt;
&lt;li&gt;PostWire publishes the file you give it. It doesn't edit or clip videos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The importable workflow JSON, plus versions that start from Google Sheets, OneDrive and Veo 3.1, are in the &lt;code&gt;Perufitlife/postwire-examples&lt;/code&gt; repository on GitHub. The full guide, with a comparison against Blotato, Upload-Post and calling TikTok's API directly, is here: &lt;a href="https://postwire.io/n8n/post-to-tiktok-instagram-youtube/" rel="noopener noreferrer"&gt;n8n: post to TikTok, Instagram and YouTube in one node&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>tiktok</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Supabase's Oct 30 grants change: the changelog's rollback reopens every table without RLS</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Sat, 26 Sep 2026 04:47:34 +0000</pubDate>
      <link>https://dev.to/perufitlife/supabases-oct-30-grants-change-the-changelogs-rollback-reopens-every-table-without-rls-3a5o</link>
      <guid>https://dev.to/perufitlife/supabases-oct-30-grants-change-the-changelogs-rollback-reopens-every-table-without-rls-3a5o</guid>
      <description>&lt;p&gt;On &lt;strong&gt;October 30, 2026&lt;/strong&gt; Supabase stops auto-granting new objects in the &lt;code&gt;public&lt;/code&gt; schema to &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; on every existing project. The five-week notice email went out on September 23, and since then the questions have been the same ones over and over: &lt;em&gt;will my app break?&lt;/em&gt;, &lt;em&gt;which tables?&lt;/em&gt;, &lt;em&gt;what do I run?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Short answers: your running app does not break, the tables that get hurt are the ones you haven't created yet (or the ones you re-create from migrations), and the SQL you are most likely to run is the one that makes things worse.&lt;/p&gt;

&lt;p&gt;This post covers what actually changes, what doesn't, the fix that quietly reopens every table without RLS, and how to write migrations that survive October 30 without exposing anything. Every Postgres behaviour below I reproduced on a real Postgres (18.3); the Supabase specifics are quoted from the &lt;a href="https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; and from Supabase engineers in &lt;a href="https://github.com/orgs/supabase/discussions/45329" rel="noopener noreferrer"&gt;discussion #45329&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Existing tables keep their grants.&lt;/strong&gt; Nothing that works today stops working on Oct 30. Supabase engineers confirmed this in the discussion thread.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New tables, views and sequences&lt;/strong&gt; created in &lt;code&gt;public&lt;/code&gt; after Oct 30 are born with no grants: &lt;code&gt;supabase-js&lt;/code&gt; gets &lt;code&gt;42501 permission denied&lt;/code&gt;. That includes server code using the &lt;code&gt;service_role&lt;/code&gt; key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replaying your migrations&lt;/strong&gt; on a new project, a preview branch or &lt;code&gt;supabase db reset&lt;/code&gt; hits this for &lt;em&gt;every&lt;/em&gt; table your migrations create without an explicit &lt;code&gt;GRANT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The changelog's own rollback snippet is &lt;code&gt;grant ... on all tables in schema public to anon, authenticated, service_role&lt;/code&gt;. On a table without RLS, that grant publishes every row to anyone holding your anon key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Functions are not covered by the change.&lt;/strong&gt; Postgres grants &lt;code&gt;EXECUTE&lt;/code&gt; to &lt;code&gt;PUBLIC&lt;/code&gt; by default, &lt;code&gt;anon&lt;/code&gt; inherits it, and the per-schema revoke in the docs has no effect on that.&lt;/li&gt;
&lt;li&gt;The durable fix: in the same migration that creates the object, write grants &lt;strong&gt;per table, per role, mirroring your RLS policies&lt;/strong&gt;. Then add a CI check so the next migration can't regress.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What changes, exactly
&lt;/h2&gt;

&lt;p&gt;Until now, a Supabase project shipped with default privileges that granted &lt;code&gt;select, insert, update, delete&lt;/code&gt; on every new table in &lt;code&gt;public&lt;/code&gt; to the three API roles. Create a table and it was reachable through PostgREST (and GraphQL) straight away, protected only by RLS, if you remembered to turn it on.&lt;/p&gt;

&lt;p&gt;The change removes those default privileges. The official "opt in early" SQL is exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;sequences&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things to note:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Tables" includes views.&lt;/strong&gt; In Postgres, default privileges &lt;code&gt;on tables&lt;/code&gt; apply to all relations, so a new view is born without grants too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sequences are separate.&lt;/strong&gt; A table grant does not cover the sequence behind a &lt;code&gt;serial&lt;/code&gt; column (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;service_role&lt;/code&gt; loses the automatic grant as well.&lt;/strong&gt; &lt;code&gt;service_role&lt;/code&gt; bypasses RLS, not privileges. If your backend or Edge Function uses &lt;code&gt;supabase-js&lt;/code&gt; with the service key against a new table, it gets the same &lt;code&gt;42501&lt;/code&gt;. Only direct Postgres connections (psql, an ORM with a connection string) are unaffected, because they don't go through the Data API roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not affected:&lt;/strong&gt; &lt;code&gt;storage&lt;/code&gt;, &lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;realtime&lt;/code&gt; and custom schemas keep their current defaults (changelog FAQ).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What you'll see
&lt;/h3&gt;

&lt;p&gt;A missing grant is not a silent empty result. PostgREST answers with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"42501"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"permission denied for table your_table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Grant the required privileges to the current role with: GRANT SELECT ON public.your_table TO anon;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;over HTTP 401 for &lt;code&gt;anon&lt;/code&gt; requests and 403 for &lt;code&gt;authenticated&lt;/code&gt; ones. A useful rule of thumb when debugging:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;42501 permission denied for table&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The role has &lt;strong&gt;no grant&lt;/strong&gt;. RLS was never even evaluated.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;42501 permission denied for sequence&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The table grant is there, the &lt;code&gt;serial&lt;/code&gt; sequence grant isn't.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;[]&lt;/code&gt; (empty array, 200)&lt;/td&gt;
&lt;td&gt;Grant is fine, &lt;strong&gt;RLS&lt;/strong&gt; returns no rows for this role.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PGRST205&lt;/code&gt; table not found in schema cache&lt;/td&gt;
&lt;td&gt;PostgREST hasn't reloaded yet: &lt;code&gt;notify pgrst, 'reload schema';&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What does NOT break
&lt;/h2&gt;

&lt;p&gt;This is the part the email made scarier than it is. From the changelog: &lt;em&gt;"Existing tables are not affected in your project, they keep their current grants and stay reachable."&lt;/em&gt; In the discussion, a user asked whether existing tables on existing projects are "guaranteed to retain their current grants and will never be silently revoked by the October 30 rollout", and a Supabase engineer answered: &lt;em&gt;"Both are correct."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So if your project is live and you never create another table, nothing happens on October 30.&lt;/p&gt;

&lt;p&gt;That is also the uncomfortable part: every table that is over-exposed today &lt;strong&gt;stays&lt;/strong&gt; over-exposed. The change only protects what you create next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it actually bites
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The next migration that creates a table.&lt;/strong&gt; It deploys fine, and the first request from the app fails with &lt;code&gt;42501&lt;/code&gt;. With AI agents generating migrations, this tends to show up as "the feature works locally and fails in production".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replaying migrations anywhere fresh.&lt;/strong&gt; A new project per customer, a staging environment, a preview branch, a teammate's &lt;code&gt;supabase db reset&lt;/code&gt;. Your old migrations never needed &lt;code&gt;GRANT&lt;/code&gt; statements, so on a project without the old defaults every table they create is unreachable. As one user put it in the thread: &lt;em&gt;"any new instance of that app that you deploy will fail to work because the old migration scripts didn't grant the required privileges."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local and cloud disagree.&lt;/strong&gt; CLI v2.102.0 added &lt;code&gt;[api] auto_expose_new_tables = false&lt;/code&gt; to &lt;code&gt;config.toml&lt;/code&gt; so &lt;code&gt;db reset&lt;/code&gt; mimics the new cloud behaviour, but it is a temporary aid scheduled for removal on 2026-10-30, and at least one user reported branches failing to clone with &lt;code&gt;'api' has invalid keys: auto_expose_new_tables&lt;/code&gt;. If you leave it unset, local keeps auto-exposing and hides the problem until production.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The fix that reopens everything
&lt;/h2&gt;

&lt;p&gt;When a table suddenly answers &lt;code&gt;42501&lt;/code&gt;, the fastest thing that makes the error go away is a bulk grant. It is literally what the changelog FAQ gives as the rollback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="n"&gt;sequences&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the discussion, a Supabase engineer suggested the same bulk grant as a one-off migration for existing tables, plus &lt;code&gt;grant execute on all functions in schema public to anon, authenticated, service_role&lt;/code&gt;. To be fair, that reply also says per-object grants are "the better answer for most projects". But the bulk version is the one that gets copy-pasted, and the email template's example grants &lt;code&gt;select&lt;/code&gt; to &lt;code&gt;anon&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;The bulk grant is only safe if &lt;strong&gt;every&lt;/strong&gt; table in &lt;code&gt;public&lt;/code&gt; has RLS enabled and correct policies. On any table where RLS is off, it means this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secrets&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secrets&lt;/span&gt; &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a@b.c'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;--  email&lt;/span&gt;
&lt;span class="c1"&gt;-- -------&lt;/span&gt;
&lt;span class="c1"&gt;--  a@b.c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anyone holding the anon key (it's in your JS bundle, by design) can read, insert, update and delete every row through &lt;code&gt;/rest/v1/secrets&lt;/code&gt;. RLS off plus a grant is exactly the pattern behind the &lt;a href="https://techcrunch.com/2026/09/25/some-supabase-customers-are-publicly-exposing-reams-of-peoples-data-to-the-web/" rel="noopener noreferrer"&gt;TechCrunch report from September 25&lt;/a&gt;: UpGuard found around &lt;strong&gt;16,000 Supabase databases&lt;/strong&gt; exposing personal data. The October 30 change is Supabase trying to make that harder by default. A bulk grant written to silence an error undoes it.&lt;/p&gt;

&lt;p&gt;The same goes for the "restore the old defaults" option (&lt;code&gt;alter default privileges ... grant ... to anon, authenticated, service_role&lt;/code&gt;): it puts every future table back on the API before anyone has written a policy for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing migrations that survive Oct 30
&lt;/h2&gt;

&lt;p&gt;The rule the changelog itself states is &lt;em&gt;"Treat these three steps as a unit"&lt;/em&gt;: grant, enable RLS, add policies, &lt;strong&gt;in the same migration that creates the table&lt;/strong&gt;. The part I'd add: &lt;strong&gt;grant each role only what its policies actually use.&lt;/strong&gt; A grant to a role with no matching policy just widens the surface for the day someone disables RLS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tables
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"own orders: read"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"own orders: create"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- grants mirror the policies: authenticated can select/insert, anon gets nothing&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- backend / Edge Functions using the service key&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few decisions this forces you to make explicitly, which is the point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;anon&lt;/code&gt; only where a policy is written &lt;code&gt;to anon&lt;/code&gt;&lt;/strong&gt; (a public catalogue, a waitlist insert). If no policy mentions &lt;code&gt;anon&lt;/code&gt;, don't grant it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;service_role&lt;/code&gt; only on tables your server code touches through the Data API.&lt;/strong&gt; A table only reached through a direct connection doesn't need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tables that should never be on the API&lt;/strong&gt; (audit logs, internal queues): no grants at all, or an explicit &lt;code&gt;revoke all on table public.x from anon, authenticated, service_role;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Sequences: &lt;code&gt;serial&lt;/code&gt; vs &lt;code&gt;identity&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This one catches people because it only fails on &lt;strong&gt;insert&lt;/strong&gt;, and only for the API roles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;serial&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR: permission denied for sequence s_id_seq&lt;/span&gt;
&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- ok, id = 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;serial&lt;/code&gt; column is a plain sequence plus a &lt;code&gt;nextval()&lt;/code&gt; default, so the inserting role needs &lt;code&gt;usage&lt;/code&gt; on the sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;sequence&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s_id_seq&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;identity&lt;/code&gt; column's sequence is internal to the table and doesn't need a separate grant. For new tables, &lt;code&gt;generated always as identity&lt;/code&gt; is one less thing to forget. For existing &lt;code&gt;serial&lt;/code&gt; tables that you replay from migrations, add the sequence grant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Views
&lt;/h3&gt;

&lt;p&gt;Views are born without grants too, and they have a second trap: by default a view runs with its &lt;strong&gt;owner's&lt;/strong&gt; privileges, so it bypasses the RLS of the tables it reads. If you grant a view to &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt;, make it respect the caller's RLS (Postgres 15+):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_totals&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;total_cents&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_totals&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Materialized views can't have RLS at all: keep them off &lt;code&gt;anon&lt;/code&gt;/&lt;code&gt;authenticated&lt;/code&gt; and expose them through a function or a server route instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Functions: the part Oct 30 doesn't touch
&lt;/h2&gt;

&lt;p&gt;Postgres grants &lt;code&gt;EXECUTE&lt;/code&gt; on every new function to &lt;code&gt;PUBLIC&lt;/code&gt;, and &lt;code&gt;anon&lt;/code&gt;/&lt;code&gt;authenticated&lt;/code&gt; are members of &lt;code&gt;PUBLIC&lt;/code&gt;. So every function you create in &lt;code&gt;public&lt;/code&gt; is callable as &lt;code&gt;/rest/v1/rpc/&amp;lt;name&amp;gt;&lt;/code&gt; with the anon key unless you revoke it. With &lt;code&gt;security definer&lt;/code&gt;, it runs with its owner's rights and &lt;strong&gt;skips RLS entirely&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The Securing your API docs suggest &lt;code&gt;alter default privileges for role postgres in schema public revoke execute on functions from public;&lt;/code&gt;. That statement doesn't have the documented effect (&lt;a href="https://github.com/supabase/supabase/issues/49338" rel="noopener noreferrer"&gt;supabase#49338&lt;/a&gt;, and a reproduction in the discussion). The reason is a Postgres rule: per-schema default privileges can only &lt;strong&gt;add&lt;/strong&gt; to the global defaults, never remove from them. The built-in &lt;code&gt;PUBLIC&lt;/code&gt; execute is a global default, so a revoke scoped &lt;code&gt;in schema public&lt;/code&gt; changes nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;f1&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="s1"&gt;'select 1'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;has_function_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public.f1()'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- true&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                          &lt;span class="c1"&gt;-- no "in schema"&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;f2&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="s1"&gt;'select 2'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;has_function_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public.f2()'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The global form works on plain Postgres, but it applies to every schema, and I haven't verified it against the extra default ACLs a hosted Supabase project carries. The approach that works everywhere is explicit and per function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;admin_stats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;json_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'users'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;admin_stats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;admin_stats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;revoke ... from anon&lt;/code&gt; alone is not enough: &lt;code&gt;anon&lt;/code&gt; still gets it through &lt;code&gt;PUBLIC&lt;/code&gt;. Revoke from &lt;strong&gt;both&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Helper functions used inside RLS policies (&lt;code&gt;is_org_member(org_id)&lt;/code&gt; and the like) need &lt;code&gt;execute&lt;/code&gt; for the role the policy applies to, usually &lt;code&gt;authenticated&lt;/code&gt;. Revoke &lt;code&gt;anon&lt;/code&gt;, keep &lt;code&gt;authenticated&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And always pin &lt;code&gt;search_path&lt;/code&gt; on &lt;code&gt;security definer&lt;/code&gt; functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit what's already exposed
&lt;/h2&gt;

&lt;p&gt;Since existing tables keep their grants, run these once in the SQL editor to see what is on the API today.&lt;/p&gt;

&lt;p&gt;Tables reachable by &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; with &lt;strong&gt;RLS off&lt;/strong&gt; (this is the dangerous list):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;relation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'select,insert,update,delete'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'select,insert,update,delete'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'p'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'select,insert,update,delete'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'select,insert,update,delete'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Functions &lt;code&gt;anon&lt;/code&gt; can call (look hard at every &lt;code&gt;security_definer = true&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regprocedure&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;security_definer&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_proc&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pronamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;has_function_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prosecdef&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Views granted to &lt;code&gt;anon&lt;/code&gt; that don't enforce the caller's RLS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'v'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;array_to_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reloptions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!~&lt;/span&gt; &lt;span class="s1"&gt;'security_invoker=(on|true|1|yes)'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'select'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Security Advisor and the "Data API exposure" badge in the Table Editor cover part of this too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make local match production now
&lt;/h2&gt;

&lt;p&gt;Don't wait for October 30 to discover which migrations lack grants. Either set this in &lt;code&gt;supabase/config.toml&lt;/code&gt; (CLI ≥ 2.102.0, remember it's temporary):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[api]&lt;/span&gt;
&lt;span class="py"&gt;auto_expose_new_tables&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or, more durable, put the same revoke the dashboard runs into an early migration so every environment behaves like post-Oct-30 production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;sequences&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;supabase db reset&lt;/code&gt; and click through the app. Every &lt;code&gt;42501&lt;/code&gt; is a grant you were silently relying on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catch it in CI
&lt;/h2&gt;

&lt;p&gt;Remembering all of the above on every PR is the part that doesn't scale, especially when an agent writes the migration. I wrote an open-source (MIT) linter that replays your &lt;code&gt;supabase/migrations&lt;/code&gt; SQL in order, with no database and no credentials, and flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tables/views with &lt;strong&gt;no grant&lt;/strong&gt; (unreachable after Oct 30, and already on any fresh project or branch);&lt;/li&gt;
&lt;li&gt;tables granted to &lt;code&gt;anon&lt;/code&gt;/&lt;code&gt;authenticated&lt;/code&gt; with &lt;strong&gt;RLS off&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;bulk &lt;code&gt;grant ... on all tables ... to anon&lt;/code&gt; and &lt;code&gt;alter default privileges ... to anon&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;security definer&lt;/code&gt; functions callable by &lt;code&gt;anon&lt;/code&gt;, including through &lt;code&gt;PUBLIC&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;views without &lt;code&gt;security_invoker&lt;/code&gt;, &lt;code&gt;serial&lt;/code&gt; sequences missing &lt;code&gt;usage&lt;/code&gt;, &lt;code&gt;security definer&lt;/code&gt; without &lt;code&gt;search_path&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For each finding it proposes the least-privilege grant based on your policies. Drop this into &lt;code&gt;.github/workflows/supabase-grants.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Supabase grants lint&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;supabase/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Perufitlife/supabase-security-skill@main&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;migrations&lt;/span&gt;   &lt;span class="c1"&gt;# reads supabase/migrations, no project ref, no token&lt;/span&gt;
          &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;      &lt;span class="c1"&gt;# or: critical&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get inline annotations on the offending migration lines, a job summary, and a proposed migration uploaded as an artifact. To run it locally first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; github:Perufitlife/supabase-security-skill migrations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Honest limits: it's a replay of your SQL, not a Postgres, so it skips &lt;code&gt;DO&lt;/code&gt; blocks, dynamic &lt;code&gt;EXECUTE&lt;/code&gt; and anything created from the dashboard. It also can't know which tables you &lt;em&gt;meant&lt;/em&gt; to be public: when it proposes a grant, read it. Mark intentional exceptions with &lt;code&gt;-- supabase-security: ignore&lt;/code&gt; above the statement. Issues with the SQL that fools it are welcome: &lt;a href="https://github.com/Perufitlife/supabase-security-skill" rel="noopener noreferrer"&gt;github.com/Perufitlife/supabase-security-skill&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before October 30
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run the three audit queries above. Fix anything with RLS off that &lt;code&gt;anon&lt;/code&gt; can reach &lt;strong&gt;today&lt;/strong&gt;. That exposure doesn't depend on the date.&lt;/li&gt;
&lt;li&gt;Make local behave like post-Oct-30 production (&lt;code&gt;config.toml&lt;/code&gt; or an early revoke migration) and &lt;code&gt;db reset&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a migration with &lt;strong&gt;per-table&lt;/strong&gt; grants for everything your app uses, mirroring your policies. Don't use &lt;code&gt;on all tables&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;usage&lt;/code&gt; on sequences behind &lt;code&gt;serial&lt;/code&gt; columns the API inserts into.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;revoke execute ... from public, anon&lt;/code&gt; on every function that isn't meant to be a public endpoint.&lt;/li&gt;
&lt;li&gt;Put a check in CI so the next migration doesn't regress.&lt;/li&gt;
&lt;li&gt;Update your AI agent's instructions (or the &lt;a href="https://supabase.com/blog/supabase-agent-skills" rel="noopener noreferrer"&gt;Supabase agent skill&lt;/a&gt;): grants + RLS + policies in the same migration, never a bulk grant.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;If you'd rather have someone do this for you: I apply least-privilege grants, fix the RLS gaps and verify them on a branch, delivered as a pull request. There's also a free check that takes a public repo URL and emails you the report: &lt;a href="https://perufitlife.github.io/supabase-security-skill/oct30/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=oct30#check" rel="noopener noreferrer"&gt;supabase-security Oct 30&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Posting to TikTok from n8n: the App Review wall, and the way around it</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Mon, 31 Aug 2026 10:00:59 +0000</pubDate>
      <link>https://dev.to/perufitlife/posting-to-tiktok-from-n8n-the-app-review-wall-and-the-way-around-it-1kla</link>
      <guid>https://dev.to/perufitlife/posting-to-tiktok-from-n8n-the-app-review-wall-and-the-way-around-it-1kla</guid>
      <description>&lt;p&gt;If you are trying to post to TikTok from n8n, Make, or your own code, you will hit this wall in roughly this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The credential connects fine.&lt;/li&gt;
&lt;li&gt;The upload call fails.&lt;/li&gt;
&lt;li&gt;You start TikTok's App Review.&lt;/li&gt;
&lt;li&gt;App Review asks for a demo video of the working integration you cannot build yet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last one is the part that feels impossible. It isn't, but the way out is not obvious and the error messages actively point the wrong way. Here is what actually happens, from having gone through the audit and shipped against this API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sandbox first — you do not need App Review to get a token
&lt;/h2&gt;

&lt;p&gt;You can get a real, working access token today. Add your own TikTok account as a &lt;strong&gt;target user&lt;/strong&gt; in your app's sandbox, and the OAuth flow completes normally.&lt;/p&gt;

&lt;p&gt;That is also the answer to the chicken-and-egg problem: &lt;strong&gt;you record the demo video in sandbox&lt;/strong&gt;. Reviewers expect that. You are not required to have a live public integration before review — you are required to show the flow working, including the consent screen, which sandbox does.&lt;/p&gt;

&lt;p&gt;If you are seeing &lt;code&gt;Unable to sign without access token&lt;/code&gt;, that is not an App Review problem at all. It means no user access token is stored: either the authorization code flow never finished, or the token is not being attached to the request. Starting App Review will not fix it, and it costs you days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The restriction nobody documents clearly
&lt;/h2&gt;

&lt;p&gt;Here is the one that catches everybody, because the error names the symptom instead of the cause.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;unaudited app can only post as private&lt;/strong&gt;. TikTok's enum for that is &lt;code&gt;SELF_ONLY&lt;/code&gt;. If you send anything else — &lt;code&gt;PUBLIC_TO_EVERYONE&lt;/code&gt;, or a friendly-looking &lt;code&gt;"public"&lt;/code&gt; — the call fails with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unaudited_client_can_only_post_to_private_accounts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which reads like something is wrong with the account. Nothing is wrong with the account. The app has not passed audit yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And it is per creator, not just per app.&lt;/strong&gt; Even after your app is audited, an individual creator's allowed levels depend on their own settings — a private account will not accept &lt;code&gt;PUBLIC_TO_EVERYONE&lt;/code&gt; no matter what your app is allowed to do. So you cannot hardcode a privacy level and hope.&lt;/p&gt;

&lt;p&gt;The API tells you, if you ask first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;POST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/v&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="err"&gt;/post/publish/creator_info/query/&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"privacy_level_options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PUBLIC_TO_EVERYONE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MUTUAL_FOLLOW_FRIENDS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SELF_ONLY"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call that &lt;strong&gt;before&lt;/strong&gt; every publish, and pick a level from what comes back. That single call turns a confusing rejection into a decision you can make.&lt;/p&gt;

&lt;p&gt;One more, related: &lt;strong&gt;branded content cannot be posted as &lt;code&gt;SELF_ONLY&lt;/code&gt;&lt;/strong&gt;. If you set &lt;code&gt;branded_content_toggle&lt;/code&gt;, you must also choose a non-private level. Two rules that individually make sense and together produce a contradiction you can only hit at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chunk rules will bite you at specific file sizes
&lt;/h2&gt;

&lt;p&gt;For &lt;code&gt;FILE_UPLOAD&lt;/code&gt;, TikTok wants chunks between &lt;strong&gt;5MB and 64MB&lt;/strong&gt;, and the &lt;strong&gt;final&lt;/strong&gt; chunk may exceed &lt;code&gt;chunk_size&lt;/code&gt; (up to 128MB). Maximum total: 4GB.&lt;/p&gt;

&lt;p&gt;The trap is arithmetic. If you compute &lt;code&gt;total_chunk_count = ceil(size / chunk_size)&lt;/code&gt;, you will eventually produce a final chunk under 5MB, and TikTok rejects it. It only happens at certain file sizes, which is a miserable way to find a bug.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;floor&lt;/code&gt;, and let the last chunk absorb the remainder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// inside 5–64MB&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// last chunk = size - (total - 1) * chunk, which lands between 32MB and 64MB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified this against every size from 3MB to 1GB with a plain loop asserting each chunk against the documented bounds. Thirty seconds of test code for a bug that would otherwise look random.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the demo video needs to show
&lt;/h2&gt;

&lt;p&gt;Reviewers are strict about one thing in particular: the use case "auto-posting to my own account" gets scrutinised, because it is what spam looks like from the outside. Show the consent flow, show the user choosing what goes out, show the post appearing. Do not show a script publishing unattended — that is the shape they reject.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not do any of this
&lt;/h2&gt;

&lt;p&gt;A pre-audited service posts through its own approved app, so you skip review entirely. There are several — Blotato, Upload-Post, and &lt;a href="https://postwire.io" rel="noopener noreferrer"&gt;PostWire&lt;/a&gt;, which is mine. I mention it because the alternative is the four-step wall at the top of this post, and it is fair to know that skipping it is an option.&lt;/p&gt;

&lt;p&gt;What I would keep from this either way: &lt;strong&gt;call &lt;code&gt;creator_info&lt;/code&gt; before you publish&lt;/strong&gt;. Whether you go direct or through a service, the allowed privacy levels are a property of the creator at that moment, and asking is the only reliable way to know.&lt;/p&gt;

</description>
      <category>api</category>
      <category>automation</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A user spent a day confirming an email that was already confirmed</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Thu, 27 Aug 2026 12:02:25 +0000</pubDate>
      <link>https://dev.to/perufitlife/a-user-spent-a-day-confirming-an-email-that-was-already-confirmed-pea</link>
      <guid>https://dev.to/perufitlife/a-user-spent-a-day-confirming-an-email-that-was-already-confirmed-pea</guid>
      <description>&lt;p&gt;A user spent a day confirming an email address that was already confirmed, because my API kept telling him to.&lt;/p&gt;

&lt;p&gt;He was right and my error message was wrong. Here is the shape of the bug, because I think it is common and almost invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The report
&lt;/h2&gt;

&lt;p&gt;He found me on LinkedIn — not through support, because my support address bounced, which is a second bug I will get to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;My account is connected, but the API keeps returning: "Confirm your email first". I've confirmed the email multiple times, but the verification state never updates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My first instinct was that verification was broken. It wasn't. His account showed &lt;code&gt;verified_at&lt;/code&gt; set to &lt;strong&gt;one minute after signup&lt;/strong&gt;. He had confirmed it correctly, the first time, and everything since had been theatre.&lt;/p&gt;

&lt;h2&gt;
  
  
  The line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;verified_at&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key_scope&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;full&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Confirm your email first — we sent you a link.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two conditions, one message.&lt;/p&gt;

&lt;p&gt;The first is "your mailbox is unproven". The second is "this key is a preview key and cannot publish". They are unrelated failures with completely different fixes, and for months every caller hitting either one got told to go check their email.&lt;/p&gt;

&lt;p&gt;He had hit the second. At signup the dashboard hands you a preview key so you can look around before verifying, and he had — reasonably — copied that key straight into his n8n workflow. Verifying his email did not upgrade it. So the key stayed limited, the API kept refusing, and the message kept pointing at the one thing that was already fine.&lt;/p&gt;

&lt;p&gt;You can watch him try in the database: &lt;strong&gt;seven login tokens between 18:08 and 18:51&lt;/strong&gt;. That is somebody confirming an email over and over because a computer told him to, each time getting a fresh working key he had no reason to know he needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two fixes, and the second is the one that matters
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Say which thing is wrong.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;unverifiedError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;verified_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This is a preview key, which cannot publish. Your email is already confirmed — &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
             &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;open API &amp;amp; MCP in the dashboard and use the key shown there.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preview_key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Confirm your email first — we sent you a link.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;email_unverified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Then remove the situation entirely.&lt;/strong&gt; A better message would still have left him editing config. The preview key is limited for exactly one reason: we do not know the mailbox is his. The moment he proves it, that reason is gone — and the key he already pasted into n8n is the one that should start working.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;markVerified&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;db&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pw_accounts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;verified_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;verified_at&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;promotePreviewKeys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// the reason for the restriction just expired&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;His existing setup now works without him touching anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I find uncomfortable
&lt;/h2&gt;

&lt;p&gt;This bug was invisible from the inside. No exception, no 500, no alert. The API answered 403 with a clear, well-written, grammatically perfect sentence — that happened to be about the wrong problem. Every dashboard I had was green.&lt;/p&gt;

&lt;p&gt;It surfaced because one user went out of his way to find me on a social network after his email to &lt;code&gt;support@&lt;/code&gt; bounced. That address appeared in eight places on my site and had never existed: the domain forwards mail, and nobody ever created the alias. So the one person motivated enough to report it had to work for the privilege.&lt;/p&gt;

&lt;p&gt;I have since checked, and his was the only preview key in that state in the entire database. The bug found the one user it could hurt, and he still had to fight through a broken support channel to tell me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An error that covers two causes will send someone to fix the wrong one.&lt;/strong&gt; They will repeat it, because nothing changes, and conclude your product is broken. Name the specific cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A restriction should expire when its reason does.&lt;/strong&gt; If you gate something on an unproven condition, lift the gate the instant the condition is proven — including on credentials already issued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the address on your own contact page.&lt;/strong&gt; Mine bounced for months while I printed it in eight places.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I write &lt;a href="https://postwire.io" rel="noopener noreferrer"&gt;PostWire&lt;/a&gt;, which publishes one draft natively to every social network. He was trying to post TikTok videos from n8n, which is exactly what it is for, and it told him to check his email instead.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>devjournal</category>
      <category>node</category>
    </item>
    <item>
      <title>A 100MB video used 460MB of RAM in my serverless function</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Wed, 26 Aug 2026 13:52:32 +0000</pubDate>
      <link>https://dev.to/perufitlife/a-100mb-video-used-460mb-of-ram-in-my-serverless-function-1pb0</link>
      <guid>https://dev.to/perufitlife/a-100mb-video-used-460mb-of-ram-in-my-serverless-function-1pb0</guid>
      <description>&lt;p&gt;A 100MB video took 460MB of RAM in my serverless function. A 1GB one killed it outright. The fix was not more memory — it was never holding the file at all.&lt;/p&gt;

&lt;p&gt;Here are the actual numbers, because I assumed the wrong bottleneck for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;My app forwards videos to TikTok and YouTube on a user's behalf. It had a hard 80MB limit on incoming video URLs, and the error message said so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;media too large (max 80MB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TikTok accepts &lt;strong&gt;4GB&lt;/strong&gt;. YouTube accepts far more. So the ceiling was mine, and someone was going to ask why — which is exactly what happened.&lt;/p&gt;

&lt;p&gt;My assumption: the function has a 60-second window, and video is big, so 80MB must be roughly what fits in the time. Reasonable. Wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring instead of guessing
&lt;/h2&gt;

&lt;p&gt;I put a temporary probe in the function that downloads a URL and reports size, wall time and memory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;mb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;rss_mb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memoryUsage&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;rss&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three runs, three surprises:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;test&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;function that sleeps 70s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;dies at 60.5s&lt;/strong&gt; — so the platform limit was real and exact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;download 100MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.3 seconds — about 77 MB/s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;memory while holding that 100MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;460 MB RSS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;download 1GB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FUNCTION_INVOCATION_FAILED&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bandwidth number is the one that reframed everything. &lt;strong&gt;100MB arrives in 1.3 seconds.&lt;/strong&gt; Even a full gigabyte is about 13 seconds of transfer inside a 60-second budget. Time was never the constraint.&lt;/p&gt;

&lt;p&gt;Memory was. And notice the ratio: a 100MB file cost 460MB of resident memory — roughly 4.5×. That is because &lt;code&gt;Buffer.from(await r.arrayBuffer())&lt;/code&gt; materialises the body &lt;strong&gt;twice&lt;/strong&gt;: once as an ArrayBuffer, once as a copy in a Buffer. Add the runtime's own overhead and a 1GB file blows through a 1GB memory limit long before it finishes.&lt;/p&gt;

&lt;p&gt;So the 80MB ceiling was not protecting me from the clock. It was protecting me from my own allocation, and it was set conservatively on top of that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: never hold the file
&lt;/h2&gt;

&lt;p&gt;Both APIs accept chunked uploads. If you read from the source stream and push each chunk as soon as it is complete, peak memory is &lt;strong&gt;one chunk&lt;/strong&gt;, regardless of whether the video is 50MB or 900MB.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getReader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;pendingLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;pendingLen&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingLen&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;CHUNK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pendingLen&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// PUT with a Content-Range&lt;/span&gt;
    &lt;span class="nx"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt; &lt;span class="nx"&gt;pendingLen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingLen&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;uploadChunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pendingLen&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same probe, after the change:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before (buffered)&lt;/th&gt;
&lt;th&gt;after (streamed)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;td&gt;460 MB RSS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;132 MB RSS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 GB&lt;/td&gt;
&lt;td&gt;function died&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;142 MB RSS, 11.5 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A full gigabyte now moves through in under twelve seconds while memory stays flat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three details that will bite you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Both APIs demand the exact byte count before you send a single byte.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;TikTok's &lt;code&gt;init&lt;/code&gt; call needs &lt;code&gt;video_size&lt;/code&gt;. YouTube's resumable init needs &lt;code&gt;X-Upload-Content-Length&lt;/code&gt;. You cannot discover the size while streaming — you need it up front, from the source's &lt;code&gt;Content-Length&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;If the host does not send one, you cannot stream at all, and you are back to buffering. I kept that path and capped it explicitly, with an error that says &lt;em&gt;why&lt;/em&gt; rather than just refusing:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;that host does not report a file size, so the video has to be held in memory and cannot exceed 80MB&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;2. The chunk rules differ, and the last chunk is where it breaks.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;TikTok: each chunk must be 5–64MB, and the &lt;strong&gt;final&lt;/strong&gt; chunk may exceed &lt;code&gt;chunk_size&lt;/code&gt; (up to 128MB). So the count is &lt;code&gt;floor(size / chunk)&lt;/code&gt;, and the last chunk absorbs the remainder.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;ceil&lt;/code&gt; and you will eventually produce a final chunk under 5MB, which TikTok rejects. That bug only appears at certain file sizes, which is a miserable way to find it in production. I caught it with a plain loop over sizes from 3MB to 1GB, asserting each chunk against the documented bounds — thirty seconds of test code for a bug that would have looked random.&lt;/p&gt;

&lt;p&gt;YouTube: every chunk except the last must be a &lt;strong&gt;multiple of 256KB&lt;/strong&gt;. I use 32MB, which is 128 × 256KB. Because network reads arrive in irregular sizes (64KB, 17KB, sometimes 900 bytes), you have to accumulate to an aligned boundary and carry the remainder into the next chunk rather than sending whatever happens to have arrived.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. YouTube answers &lt;code&gt;308&lt;/code&gt; for every chunk but the last.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;308 Resume Incomplete&lt;/code&gt; means &lt;em&gt;success, keep going&lt;/em&gt;. But &lt;code&gt;fetch&lt;/code&gt; reports &lt;code&gt;response.ok === false&lt;/code&gt; for it. Treat it as an error and you abort the upload precisely when it is working:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;put&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;308&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// more to send — this is success&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I would take from this
&lt;/h2&gt;

&lt;p&gt;The instinct to blame the clock in a serverless function is strong, and it sent me down the wrong path for weeks. One probe with &lt;code&gt;process.memoryUsage()&lt;/code&gt; and a timer settled it in ten minutes and pointed at a completely different fix.&lt;/p&gt;

&lt;p&gt;Also: I removed that probe the moment it gave me the numbers. It did &lt;code&gt;fetch(url)&lt;/code&gt; on any URL passed to it, which is an open SSRF — my server would fetch anything anyone asked it to. If you build one, validate the host or delete it the same day.&lt;/p&gt;

&lt;p&gt;The limit went from 80MB to 1GB on both networks. I write &lt;a href="https://postwire.io" rel="noopener noreferrer"&gt;PostWire&lt;/a&gt;, which publishes one draft natively to every social network, so a customer hitting an invented size cap was a promise I did not want to keep making.&lt;/p&gt;

</description>
      <category>serverless</category>
      <category>node</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>6 social media API behaviours that are not in the docs</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Sun, 23 Aug 2026 11:36:48 +0000</pubDate>
      <link>https://dev.to/perufitlife/6-social-media-api-behaviours-that-are-not-in-the-docs-243d</link>
      <guid>https://dev.to/perufitlife/6-social-media-api-behaviours-that-are-not-in-the-docs-243d</guid>
      <description>&lt;p&gt;Every social platform has a handful of API behaviours that are not in the quickstart. You meet them in production, usually at the worst moment, and the error message rarely tells you what is actually wrong.&lt;/p&gt;

&lt;p&gt;These are the ones that cost us the most time building a multi-network publishing API. All of them are things we hit with real credentials against real accounts, not things we read.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. TikTok rejects photos, and blames your video
&lt;/h2&gt;

&lt;p&gt;Send a photo to TikTok's Content Posting API and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tiktok requires a video_url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TikTok's Direct Post endpoint publishes &lt;strong&gt;video&lt;/strong&gt;. Photo posts go through a separate flow (&lt;code&gt;PHOTO&lt;/code&gt; post mode) that has its own approval. Most integrations discover this after a user has already scheduled twenty image posts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do:&lt;/strong&gt; validate media type per platform &lt;em&gt;before&lt;/em&gt; you accept the post, not after. Our rule set is simply:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Network&lt;/th&gt;
&lt;th&gt;Needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TikTok&lt;/td&gt;
&lt;td&gt;video, always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube&lt;/td&gt;
&lt;td&gt;video, always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instagram&lt;/td&gt;
&lt;td&gt;photo or video — never text-only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn, X, Bluesky, Mastodon, Telegram&lt;/td&gt;
&lt;td&gt;text alone is fine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;privacy_level: "public"&lt;/code&gt; is not a thing
&lt;/h2&gt;

&lt;p&gt;This one produces the most misleading error in the entire category:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;privacy_level 'public' is not allowed for this creator
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that as a user. It sounds like &lt;em&gt;their account&lt;/em&gt; is restricted. It is not. The enum TikTok expects is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUBLIC_TO_EVERYONE
MUTUAL_FOLLOW_FRIENDS
FOLLOWER_OF_CREATOR
SELF_ONLY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;public&lt;/code&gt; simply is not a value. The API told the truth and communicated the opposite of it.&lt;/p&gt;

&lt;p&gt;There is a second trap behind it: &lt;strong&gt;an app that has not passed TikTok's audit can only post &lt;code&gt;SELF_ONLY&lt;/code&gt;&lt;/strong&gt;, no matter what the creator's own privacy options are. So during development every post is private, and the day your audit clears, the behaviour changes underneath you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do:&lt;/strong&gt; translate the obvious words (&lt;code&gt;public&lt;/code&gt;, &lt;code&gt;private&lt;/code&gt;, &lt;code&gt;friends&lt;/code&gt;) into the platform enum, and when a level genuinely is not allowed, say &lt;em&gt;why&lt;/em&gt; in your own error.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Access tokens die on very different clocks
&lt;/h2&gt;

&lt;p&gt;Measured on live connections, not from the docs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Network&lt;/th&gt;
&lt;th&gt;Access token lifetime&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TikTok&lt;/td&gt;
&lt;td&gt;~24 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube (Google OAuth)&lt;/td&gt;
&lt;td&gt;1 hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Meta Page tokens&lt;/td&gt;
&lt;td&gt;long-lived — until the user changes their password&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of them refreshes silently if you built for it, and dies silently if you did not. The failure mode is nasty: your UI shows a green "Connected" badge for an account that stopped working three days ago, and the user only finds out when a scheduled post does not appear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do:&lt;/strong&gt; a status badge should be a question you asked just now, not a row you wrote once. Health-check the credential and say plainly when it needs reconnecting.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. OAuth state cannot be raw base64
&lt;/h2&gt;

&lt;p&gt;We spent a day on connections that failed only sometimes. The cause: we packed encrypted OAuth state as standard base64, which contains &lt;code&gt;+&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt; and &lt;code&gt;=&lt;/code&gt;. Those get URL-encoded — and in some redirect chains, double-encoded — so the callback could not decrypt what came back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do:&lt;/strong&gt; &lt;code&gt;base64url&lt;/code&gt; for anything that travels in a query parameter. Node has it built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;base64url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Meta needs a Page, not a profile
&lt;/h2&gt;

&lt;p&gt;Instagram publishing through the Graph API requires an Instagram &lt;strong&gt;Business&lt;/strong&gt; account linked to a Facebook &lt;strong&gt;Page&lt;/strong&gt;, and your app needs &lt;code&gt;business_management&lt;/code&gt; to see the Page list. A personal Instagram account cannot be published to at all, and the error you get is about permissions rather than about account type — so people go hunting through their app review instead of their Instagram settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The approvals are the real work
&lt;/h2&gt;

&lt;p&gt;The code for posting to any of these is an afternoon. The paperwork is not:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TikTok Content Posting API&lt;/strong&gt;: rejected twice for us — once for an "Invalid Website URL" — approved seven days after the second fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta app review&lt;/strong&gt;: seven permissions, approved clean on the first submission, but the Data Use Checkup is a separate recurring task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reddit&lt;/strong&gt;: self-serve app creation for this use case is effectively gone; it is a Data Access Request and a wait.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are choosing between building this yourself and using something that already has the approvals, that list is the actual decision — not the HTTP calls.&lt;/p&gt;




&lt;p&gt;I write these down as we hit them while building &lt;a href="https://postwire.io/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=api_gotchas" rel="noopener noreferrer"&gt;PostWire&lt;/a&gt;, a publishing API and MCP server for TikTok, Instagram, YouTube, LinkedIn, X, Bluesky, Mastodon, Telegram, Discord and Reddit. If you have hit one that is not on this list, I would genuinely like to hear it — the undocumented ones are the expensive ones.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>tiktok</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Why I gave my income dashboard an MCP server instead of a mobile app</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Thu, 30 Jul 2026 00:21:32 +0000</pubDate>
      <link>https://dev.to/perufitlife/why-i-gave-my-income-dashboard-an-mcp-server-instead-of-a-mobile-app-4i24</link>
      <guid>https://dev.to/perufitlife/why-i-gave-my-income-dashboard-an-mcp-server-instead-of-a-mobile-app-4i24</guid>
      <description>&lt;p&gt;I earn money from 23 different places.&lt;/p&gt;

&lt;p&gt;Not big money. A few Stripe products, an Amazon affiliate link, some scrapers on a marketplace, an iOS app that makes $6.99 a month, the occasional PDF sale. The smallest one earned &lt;strong&gt;$0.01&lt;/strong&gt; last month. The biggest earned $224.&lt;/p&gt;

&lt;p&gt;Added together, that's most of my income. And until recently I had no idea what the number was on any given day.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem isn't a missing dashboard
&lt;/h2&gt;

&lt;p&gt;Revenue dashboards exist. Baremetrics, ChartMogul, ProfitWell — they're good products. They're also built for a completely different shape of business: &lt;strong&gt;one&lt;/strong&gt; Stripe account, analysed deeply, priced from around $129/month for companies with a single product and a growth team.&lt;/p&gt;

&lt;p&gt;My shape is the opposite. Many tiny streams, and — this is the part that matters — &lt;strong&gt;roughly half of them publish no earnings API at all.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I checked, carefully, because I wanted to automate it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Amazon Associates&lt;/strong&gt;: no OAuth, no public earnings API. The PA-API returns product data, not what you earned. There's an S3 data feed, granted at Amazon's discretion, and small publishers don't qualify.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most ad networks&lt;/strong&gt;: same story.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apify&lt;/strong&gt; (where I sell scrapers): earnings live in the console only. No API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So any tool built purely on integrations is &lt;em&gt;structurally&lt;/em&gt; blind to that money. Not "hasn't gotten around to it" — blind by architecture. You cannot integrate with an API that does not exist.&lt;/p&gt;

&lt;p&gt;That's the gap I built into: &lt;strong&gt;automate what has an API, and make manual entry take ten seconds for everything else.&lt;/strong&gt; One monthly total, typed once, joining the same charts and averages and goals as the automatic sources. Unglamorous. It's the whole product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built an MCP server instead of a mobile app
&lt;/h2&gt;

&lt;p&gt;Here's the decision I'm actually happy about.&lt;/p&gt;

&lt;p&gt;The obvious next step for a dashboard is a mobile app. I didn't build one, because I noticed something about how I use the thing: I never wanted to &lt;em&gt;look&lt;/em&gt; at a chart. I wanted to &lt;strong&gt;ask a question&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;"How much did I make this month across everything?"&lt;/p&gt;

&lt;p&gt;That's not a visual query. It's conversational. So instead of an app, I shipped an &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;MCP&lt;/a&gt; server. Now I ask Claude and get an answer built from my real numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;you ▸ how much did I make this month across everything?
ai  ▸ $412.30 this month (avg $14.22/day). Top: Amazon $109.18,
      Rotate First Officer $98.14, Checkride PDF $47.28.
      You're 94% to your monthly goal.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four tools: &lt;code&gt;get_income_summary&lt;/code&gt;, &lt;code&gt;get_sources&lt;/code&gt;, &lt;code&gt;get_month&lt;/code&gt;, &lt;code&gt;log_monthly_income&lt;/code&gt;. That last one means I can &lt;em&gt;log&lt;/em&gt; income by talking, which turns the most tedious part of the whole product into a sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The auth problem nobody warns you about
&lt;/h2&gt;

&lt;p&gt;This is where it got interesting technically.&lt;/p&gt;

&lt;p&gt;The hosted app authenticates with Supabase magic links, which produce a &lt;strong&gt;JWT that expires in an hour&lt;/strong&gt;. That's correct for a browser. It's useless for a CLI: an MCP server running under Claude Desktop cannot refresh a browser session.&lt;/p&gt;

&lt;p&gt;So for a while my MCP only worked against self-hosted instances, which meant anyone discovering it had to deploy their own Postgres to use it. Not exactly a funnel.&lt;/p&gt;

&lt;p&gt;The fix is personal API tokens, and the details are the whole point:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store a hash, never the token.&lt;/strong&gt; The plaintext is shown once, at creation. The database keeps a SHA-256 hash. A database leak yields nothing usable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iok_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;randomBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;hashToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Show a prefix and a last-used timestamp.&lt;/strong&gt; A credential you can't see is a credential you can't audit. If a token says "never used" six months in, you know to kill it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A token cannot mint another token.&lt;/strong&gt; This one I think is underrated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isBrowserSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iok_&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Creating and revoking tokens requires a real browser session. Otherwise one leaked token becomes permanent, self-renewing access — it can quietly issue siblings for itself faster than you can revoke them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And be honest that a token bypasses 2FA.&lt;/strong&gt; It does. That's what it &lt;em&gt;is&lt;/em&gt; — the machine's replacement for a second factor. GitHub makes the same trade with personal access tokens. The mitigation isn't pretending otherwise, it's making tokens named, scoped to one purpose, individually revocable, and visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake that cost me a month
&lt;/h2&gt;

&lt;p&gt;Now the part that's actually useful to you.&lt;/p&gt;

&lt;p&gt;I launched the hosted version and it made &lt;strong&gt;zero dollars for 26 days&lt;/strong&gt;. I assumed pricing, or onboarding, or that the market didn't want it.&lt;/p&gt;

&lt;p&gt;Then I read my own landing page.&lt;/p&gt;

&lt;p&gt;Every call-to-action pointed at the GitHub repo. And near the bottom, still live, in the present tense:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"IncomeOS Cloud **is coming&lt;/em&gt;* — join the waitlist and get early access."*&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The product had been live and charging for 26 days. My own website was telling every visitor it didn't exist yet, and offering them a waitlist. That waitlist had &lt;strong&gt;zero&lt;/strong&gt; signups, which in hindsight was the only honest metric on the page.&lt;/p&gt;

&lt;p&gt;It wasn't a conversion problem. &lt;strong&gt;Nobody had ever seen the product.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I have a rule now, and I'd offer it to anyone shipping alone: before you optimise anything, verify a stranger can physically travel from your homepage to your checkout. Click it yourself, in an incognito window, like you've never seen it before. I had built the cash register and forgotten to put a door on the building.&lt;/p&gt;

&lt;p&gt;The second lesson from that week: I had &lt;strong&gt;zero analytics&lt;/strong&gt;. No funnel, nothing. Which means every theory I had about why it wasn't converting was a story I told myself. If you're guessing about your funnel, you don't have a hypothesis — you have a feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stands
&lt;/h2&gt;

&lt;p&gt;MIT licensed, self-hostable on Supabase + Vercel in about five minutes. There's a hosted version because I got tired of maintaining my own deploy, and $9/month with a trial felt more honest than a donate button.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repo: &lt;a href="https://github.com/Perufitlife/incomeos" rel="noopener noreferrer"&gt;github.com/Perufitlife/incomeos&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MCP: &lt;code&gt;npx -y incomeos-mcp&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you also earn from a pile of small places, I'd genuinely like to know how you track it today — especially the sources with no API. That's the part I keep discovering I've underestimated.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>mcp</category>
      <category>ai</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Free open-source security auditors for Supabase, Strapi, Hasura, Convex, Ollama &amp; more</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Tue, 23 Jun 2026 01:14:20 +0000</pubDate>
      <link>https://dev.to/perufitlife/free-open-source-security-auditors-for-supabase-strapi-hasura-convex-ollama-more-4jbl</link>
      <guid>https://dev.to/perufitlife/free-open-source-security-auditors-for-supabase-strapi-hasura-convex-ollama-more-4jbl</guid>
      <description>&lt;p&gt;Most backend data leaks aren't clever hacks. They're a database, CMS or API left readable by the &lt;strong&gt;anonymous / public role&lt;/strong&gt; — a default someone forgot to lock down before going to production.&lt;/p&gt;

&lt;p&gt;So I built a family of open-source auditors (MIT, zero dependencies) that check for exactly that, and &lt;strong&gt;confirm each leak with a read-only anonymous probe&lt;/strong&gt; — the same request any visitor's browser makes. Nothing is downloaded, nothing is changed. You get the bytes that are actually exposed, not a guess from a config file.&lt;/p&gt;

&lt;p&gt;One command each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx strapi-security   &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-strapi.example.com
npx directus-security &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-directus.example.com
npx hasura-security   &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-hasura.example.com
npx convex-security   &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-app.convex.cloud
npx ollama-security   &lt;span class="nt"&gt;--url&lt;/span&gt; http://your-host:11434
npx payload-security  &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-payload.example.com
npx n8n-security      &lt;span class="nt"&gt;--url&lt;/span&gt; https://your-n8n.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus auditors for Supabase, Firebase, PocketBase, Appwrite and Nhost, and tools for served secret files (.env, .git, source maps) and Claude Code .claude/ config footguns.&lt;/p&gt;

&lt;p&gt;Full collection, all MIT:&lt;br&gt;
&lt;strong&gt;&lt;a href="https://github.com/Perufitlife/awesome-backend-security" rel="noopener noreferrer"&gt;https://github.com/Perufitlife/awesome-backend-security&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Want me to run one for you — free?
&lt;/h2&gt;

&lt;p&gt;If you'd rather not install anything, drop your backend URL and I'll run the matching auditor and post the findings + the exact fixes back to you, &lt;strong&gt;free&lt;/strong&gt;. Read-only, nothing downloaded.&lt;/p&gt;

&lt;p&gt;Request a free audit: &lt;a href="https://github.com/Perufitlife/awesome-backend-security/issues/new?template=free-audit.yml" rel="noopener noreferrer"&gt;https://github.com/Perufitlife/awesome-backend-security/issues/new?template=free-audit.yml&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If it turns up something and you'd like the fixes done for you, there's a fixed-scope $99 option — but the tools and the audit are free, and that's the point: most of these holes take five minutes to close once you know they're there.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devsecops</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I gave my AI agent live aviation weather — building a free Aviation MCP server</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Sun, 14 Jun 2026 02:26:02 +0000</pubDate>
      <link>https://dev.to/perufitlife/i-gave-my-ai-agent-live-aviation-weather-building-a-free-aviation-mcp-server-2cc8</link>
      <guid>https://dev.to/perufitlife/i-gave-my-ai-agent-live-aviation-weather-building-a-free-aviation-mcp-server-2cc8</guid>
      <description>&lt;p&gt;I'm a commercial pilot who builds software. Last week I noticed something: ask any AI assistant "what's the weather at JFK right now and is it VFR?" and it either guesses, hallucinates a METAR, or tells you to go check a website. LLMs have no live aviation data.&lt;/p&gt;

&lt;p&gt;So I built an MCP server that fixes that. It gives Claude, ChatGPT, Cursor — any MCP client — six aviation tools that return &lt;strong&gt;real&lt;/strong&gt; data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;get_metar&lt;/code&gt; — current decoded METAR for any ICAO airport (flight category, wind, visibility, temp, dewpoint), optional TAF&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_airport&lt;/code&gt; — airport info by ICAO (name, IATA, city, coordinates, elevation, runways)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_aircraft&lt;/code&gt; — aircraft specs by slug (engines, range, cruise, ceiling, MTOW, type rating)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get_glossary_term&lt;/code&gt; — definitions from an aviation glossary&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;practice_questions&lt;/code&gt; — FAA-style exam questions with answers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;quiz_of_the_day&lt;/code&gt; — a daily aviation question&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No API key. No signup. It's a thin MCP wrapper over a free aviation API I maintain (&lt;a href="https://rotatepilot.com/developers" rel="noopener noreferrer"&gt;Rotate Pilot&lt;/a&gt;), so the tools are just typed HTTP calls — the hard part is the data, not the protocol.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why MCP, and why this was easy
&lt;/h3&gt;

&lt;p&gt;The Model Context Protocol is becoming the default way to hand tools to LLMs in 2026 — Claude, Cursor, Cline, Continue and Windsurf all speak it. If you have any API, wrapping it as an MCP server drops it into every AI client at once. That's a free distribution channel most API owners are sleeping on.&lt;/p&gt;

&lt;p&gt;The server runs as an Apify Standby Actor (Streamable HTTP &lt;code&gt;/mcp&lt;/code&gt; + legacy SSE), using the official &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt;. Each tool definition is ~10 lines: a name, a JSON schema, and a &lt;code&gt;buildPath(args)&lt;/code&gt; that returns the API path. The server fetches it and returns the JSON. That's the whole thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connect it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"aviation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://renzomacar--aviation-mcp.apify.actor/mcp?token=YOUR_APIFY_TOKEN"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask your agent: &lt;em&gt;"Pull the METAR for KSFK and EGLL, tell me which is VFR, and compare a Cessna 172 to a Piper Warrior on cruise speed."&lt;/em&gt; It will call the tools and answer from real data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try it / source
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;GitHub (MIT): &lt;a href="https://github.com/Perufitlife/aviation-mcp" rel="noopener noreferrer"&gt;https://github.com/Perufitlife/aviation-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Apify Store: &lt;a href="https://apify.com/renzomacar/aviation-mcp" rel="noopener noreferrer"&gt;https://apify.com/renzomacar/aviation-mcp&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The underlying free API + OpenAPI spec: &lt;a href="https://rotatepilot.com/developers" rel="noopener noreferrer"&gt;https://rotatepilot.com/developers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Curious what other niche data verticals are still missing from the MCP ecosystem — aviation felt like an obvious gap for a pilot. What's yours?&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>aviation</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Build a Lead-Gen Automation in n8n: Scrape, Enrich, Export</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:45:42 +0000</pubDate>
      <link>https://dev.to/perufitlife/build-a-lead-gen-automation-in-n8n-scrape-enrich-export-1l51</link>
      <guid>https://dev.to/perufitlife/build-a-lead-gen-automation-in-n8n-scrape-enrich-export-1l51</guid>
      <description>&lt;p&gt;Most "lead generation" tutorials stop at scraping a list of business names. But a name and a phone number isn't a lead - a name, a decision-maker email, and a verified contact channel is. In this tutorial we'll build a complete lead-gen pipeline in &lt;a href="https://n8n.io" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; that does all three stages: &lt;strong&gt;scrape&lt;/strong&gt; businesses from Google Maps, &lt;strong&gt;enrich&lt;/strong&gt; them with emails crawled straight off their websites, and &lt;strong&gt;export&lt;/strong&gt; a clean list to Google Sheets.&lt;/p&gt;

&lt;p&gt;No code, no scraper maintenance. We lean on n8n's generic &lt;strong&gt;Apify&lt;/strong&gt; node to run two ready-made actors and chain them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline at a glance
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Trigger -&amp;gt; Scrape (Google Maps) -&amp;gt; Enrich (Website Contact Finder) -&amp;gt; Export (Sheets)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scrape&lt;/strong&gt; - pull businesses for a niche + city from Google Maps (name, phone, website, rating).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enrich&lt;/strong&gt; - take each business website and crawl it for emails, phones, and social links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; - write the merged record to Google Sheets as one row per lead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Stage 1 gives you reach. Stage 2 gives you the email that actually makes the lead actionable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A running n8n instance (cloud or self-hosted).&lt;/li&gt;
&lt;li&gt;A free &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; account. Grab your &lt;strong&gt;Personal API token&lt;/strong&gt; from &lt;strong&gt;Settings -&amp;gt; Integrations&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. Both actors we use are on the Apify Store and run through the same generic Apify node, so you only configure one credential.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 - Add the Apify credential
&lt;/h2&gt;

&lt;p&gt;Add a node in n8n, search &lt;strong&gt;Apify&lt;/strong&gt;, choose the generic Apify node. When it asks for credentials, select &lt;strong&gt;Apify API&lt;/strong&gt; and paste your token. Save. You'll reuse this credential for both the scrape and enrich steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 - Scrape businesses from Google Maps
&lt;/h2&gt;

&lt;p&gt;Add your first Apify node. Configure it to run the &lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt; actor (&lt;code&gt;renzomacar/google-maps-businesses&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation&lt;/strong&gt;: &lt;code&gt;Run actor and get dataset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actor&lt;/strong&gt;: &lt;code&gt;renzomacar/google-maps-businesses&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input (JSON)&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchQueries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"marketing agencies in Denver CO"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxResultsPerQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeWebsite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;includeWebsite&lt;/code&gt; is &lt;strong&gt;false&lt;/strong&gt; here. We deliberately keep this stage fast and cheap - we just want the business list and their website URLs. The deep email crawl happens in the next step with a dedicated tool that does it better.&lt;/p&gt;

&lt;p&gt;Each output item looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summit Digital"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 720-555-0142"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"website"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://summitdigital.co"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;96&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 - Enrich each business with emails
&lt;/h2&gt;

&lt;p&gt;Now the enrichment step. Add a second Apify node, this time running the &lt;a href="https://apify.com/renzomacar/website-contact-finder" rel="noopener noreferrer"&gt;Email &amp;amp; Contact Finder&lt;/a&gt; actor (&lt;code&gt;renzomacar/website-contact-finder&lt;/code&gt;). This actor crawls a website's contact, about, and team pages and extracts emails, phone numbers, social profiles, and even the tech stack.&lt;/p&gt;

&lt;p&gt;The trick is feeding it the website URLs from Step 2. The actor takes a &lt;code&gt;domains&lt;/code&gt; array, so collect the websites from the previous node and pass them in. A simple way: drop a &lt;strong&gt;Code&lt;/strong&gt; node between the two Apify nodes to gather the URLs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Code node: collect website URLs from the Google Maps results&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;domains&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then configure the second Apify node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation&lt;/strong&gt;: &lt;code&gt;Run actor and get dataset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actor&lt;/strong&gt;: &lt;code&gt;renzomacar/website-contact-finder&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input (JSON)&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domains"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;$json.domains&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPagesPerDomain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeGenericEmails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detectTechStack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;domains&lt;/code&gt; value is mapped from the Code node, so it scales automatically with however many businesses you scraped. Each enriched result comes back like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"domain"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"summitdigital.co"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hello@summitdigital.co"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jobs@summitdigital.co"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phones"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"+1 720-555-0142"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"socialLinks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"linkedin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://linkedin.com/company/summit-digital"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"instagram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://instagram.com/summitdigital"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"techStack"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"WordPress"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HubSpot"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now you have an email and a social channel for outreach - the difference between a raw list and a usable pipeline. Bonus: the detected tech stack lets you segment ("everyone on HubSpot," "everyone still on WordPress") for sharper messaging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 - Merge and export to Google Sheets
&lt;/h2&gt;

&lt;p&gt;You've got two datasets: businesses (Step 2) and contacts (Step 3). Use n8n's &lt;strong&gt;Merge&lt;/strong&gt; node set to &lt;strong&gt;Combine -&amp;gt; Merge By Key&lt;/strong&gt;, keying on the website/domain so each business lines up with its scraped emails. Then add a &lt;strong&gt;Google Sheets&lt;/strong&gt; node with operation &lt;strong&gt;Append Row&lt;/strong&gt; and map the columns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sheet column&lt;/th&gt;
&lt;th&gt;Expression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.name }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.emails[0] }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.phone }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.website }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.socialLinks.linkedin }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tech&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.techStack.join(", ") }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Run it. Each row is now a real, enriched, ready-to-contact lead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 - Put it on a schedule
&lt;/h2&gt;

&lt;p&gt;Swap the manual trigger for a &lt;strong&gt;Schedule Trigger&lt;/strong&gt; and rotate your &lt;code&gt;searchQueries&lt;/code&gt; across cities and niches. Every run adds fresh, enriched leads to the sheet with no manual effort. Add a dedupe step (key on email or domain) if you run overlapping searches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why split scrape and enrich into two actors?
&lt;/h2&gt;

&lt;p&gt;You could ask the Google Maps actor to visit websites itself (&lt;code&gt;includeWebsite: true&lt;/code&gt;) and call it a day. That's fine for small jobs. But splitting the stages gives you two wins: the &lt;strong&gt;scrape stays fast and cheap&lt;/strong&gt;, and the &lt;strong&gt;enrichment is far more thorough&lt;/strong&gt; - the contact-finder actor crawls multiple pages per domain (contact, about, team) rather than just the homepage, so it surfaces emails the quick pass misses. For serious outreach lists, the two-stage pipeline pulls noticeably more verified emails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;That's a full lead-gen machine in n8n with zero scraping code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scrape&lt;/strong&gt; with &lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enrich&lt;/strong&gt; with &lt;a href="https://apify.com/renzomacar/website-contact-finder" rel="noopener noreferrer"&gt;Email &amp;amp; Contact Finder&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Export&lt;/strong&gt; to Sheets, on a schedule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both actors run through the same generic Apify node, so once your token is in, you can recombine them into any pipeline you like - reviews monitoring, competitor research, recruiting, you name it.&lt;/p&gt;

&lt;p&gt;Now go fill that sheet.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>leadgeneration</category>
      <category>automation</category>
      <category>nocode</category>
    </item>
    <item>
      <title>How to Scrape Google Maps Leads in n8n Without Code (Emails + Phones)</title>
      <dc:creator>Perufitlife</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:44:28 +0000</pubDate>
      <link>https://dev.to/perufitlife/how-to-scrape-google-maps-leads-in-n8n-without-code-emails-phones-1j1g</link>
      <guid>https://dev.to/perufitlife/how-to-scrape-google-maps-leads-in-n8n-without-code-emails-phones-1j1g</guid>
      <description>&lt;p&gt;Lead generation usually means one of two painful things: paying for an expensive SaaS seat, or hand-copying business names and phone numbers off Google Maps one card at a time. If you already run &lt;a href="https://n8n.io" rel="noopener noreferrer"&gt;n8n&lt;/a&gt; for your automations, there is a third option that takes about ten minutes to set up and needs zero code.&lt;/p&gt;

&lt;p&gt;n8n ships with a generic &lt;strong&gt;Apify&lt;/strong&gt; node. That node can run any actor on the Apify Store and hand you back structured JSON you can pipe into Sheets, a CRM, or an email step. So instead of building a scraper, you point the node at a ready-made one. In this tutorial we will use a Google Maps scraper that pulls business &lt;strong&gt;names, phone numbers, websites, ratings, and emails&lt;/strong&gt;, then drop the results into Google Sheets.&lt;/p&gt;

&lt;p&gt;No browser automation to maintain, no proxies to rotate, no captcha headaches. Let's build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;

&lt;p&gt;A 3-node n8n workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Manual / Schedule trigger&lt;/strong&gt; - kick the run off on demand or nightly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apify node&lt;/strong&gt; - run a Google Maps scraper actor with your search terms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Sheets node&lt;/strong&gt; - append every business as a new row&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The output is a clean lead list: business name, address, phone, website, rating, review count, and (optionally) the email scraped from the business website.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 - Get an Apify token
&lt;/h2&gt;

&lt;p&gt;You need a free &lt;a href="https://apify.com" rel="noopener noreferrer"&gt;Apify&lt;/a&gt; account. After signing up, go to &lt;strong&gt;Settings -&amp;gt; Integrations&lt;/strong&gt; and copy your &lt;strong&gt;Personal API token&lt;/strong&gt;. The free tier comes with monthly platform credit, which is plenty for testing and small lead lists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 - Add the Apify credential in n8n
&lt;/h2&gt;

&lt;p&gt;In n8n, open any workflow and add a new node. Search for &lt;strong&gt;Apify&lt;/strong&gt; and pick the generic Apify node (it talks to the Apify API directly - no custom community node required).&lt;/p&gt;

&lt;p&gt;When prompted for credentials, choose &lt;strong&gt;Apify API&lt;/strong&gt; and paste the token from Step 1. Save it. That credential is now reusable across every Apify-powered workflow you build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 - Point the node at the Google Maps actor
&lt;/h2&gt;

&lt;p&gt;The Apify node asks for an &lt;strong&gt;Actor&lt;/strong&gt; to run. We'll use the &lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt; actor (&lt;code&gt;renzomacar/google-maps-businesses&lt;/code&gt;). It scrapes Google Maps search results and returns structured business data, and it can optionally visit each business website to grab an email - which is exactly what makes it useful for outreach.&lt;/p&gt;

&lt;p&gt;In the node:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Operation&lt;/strong&gt;: &lt;code&gt;Run actor and get dataset&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Actor&lt;/strong&gt;: &lt;code&gt;renzomacar/google-maps-businesses&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input (JSON)&lt;/strong&gt;: paste the config below
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"searchQueries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"dentists in Miami FL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"coffee shops in Austin TX"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxResultsPerQuery"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"includeWebsite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few notes on these fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;searchQueries&lt;/code&gt; is an array, so you can batch several searches in one run. Use the same phrasing you would type into Google Maps: &lt;code&gt;"&amp;lt;business type&amp;gt; in &amp;lt;city&amp;gt;"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maxResultsPerQuery&lt;/code&gt; caps how many businesses per query. Google Maps tops out around 120 per search, so anything up to that is realistic.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;includeWebsite: true&lt;/code&gt; tells the actor to open each business website and extract emails and social links. This is the magic toggle for outreach - leave it off if you only need phone numbers and want a faster, cheaper run.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the node once. You should get an array of business objects back, each looking roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bright Smile Dental"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"123 Biscayne Blvd, Miami, FL 33132"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"phone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"+1 305-555-0199"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"website"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://brightsmilemiami.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hello@brightsmilemiami.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rating"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewsCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;214&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dental clinic"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4 - Send the leads to Google Sheets
&lt;/h2&gt;

&lt;p&gt;Add a &lt;strong&gt;Google Sheets&lt;/strong&gt; node after the Apify node. Authenticate with your Google account, pick (or create) a spreadsheet, and set the operation to &lt;strong&gt;Append Row&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Map the columns to the fields coming out of the Apify node:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sheet column&lt;/th&gt;
&lt;th&gt;n8n expression&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Business&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.name }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.phone }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.email }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Website&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.website }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rating&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.rating }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;{{ $json.address }}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because the Apify node outputs one item per business, n8n loops automatically - every business becomes its own row. Run the workflow and watch the sheet fill up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 (optional) - Email the list to yourself
&lt;/h2&gt;

&lt;p&gt;Want the lead list in your inbox instead? Swap (or add) an &lt;strong&gt;email / Gmail&lt;/strong&gt; node at the end. Pipe the dataset through a small &lt;strong&gt;Code&lt;/strong&gt; or &lt;strong&gt;Set&lt;/strong&gt; node to format it as an HTML table, then send. Now you have a nightly "fresh leads" email with zero manual work.&lt;/p&gt;

&lt;p&gt;You can also flip the Apify actor's &lt;code&gt;outputFormat&lt;/code&gt; to &lt;code&gt;html-report&lt;/code&gt; and it returns a polished, scored lead-list report you can attach directly - handy if you're delivering these to a client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it run on autopilot
&lt;/h2&gt;

&lt;p&gt;Replace the manual trigger with a &lt;strong&gt;Schedule Trigger&lt;/strong&gt; (say, every Monday at 8am) and rotate your &lt;code&gt;searchQueries&lt;/code&gt; - different cities, different niches - so each run brings in net-new leads. That's a self-refilling pipeline without writing a single line of scraping code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the node-based approach beats DIY scraping
&lt;/h2&gt;

&lt;p&gt;If you tried to scrape Google Maps yourself inside n8n with an HTTP Request node, you'd immediately hit dynamic JS rendering, rate limits, and layout changes that break your selectors every few weeks. Offloading that to a maintained actor means the brittle part is someone else's problem - you just consume clean JSON. That's the whole point of the Apify node: scraping becomes a single configured step in your automation, not a project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;In a handful of nodes you've got a no-code lead-gen pipeline: search Google Maps -&amp;gt; extract names, phones, websites and emails -&amp;gt; append to Sheets or email yourself -&amp;gt; schedule it. The same pattern works for any niche and any city.&lt;/p&gt;

&lt;p&gt;If you want to go deeper on the contact-enrichment side, the &lt;a href="https://apify.com/renzomacar/google-maps-businesses" rel="noopener noreferrer"&gt;Google Maps Email Extractor&lt;/a&gt; actor and its companion contact-finder are both on the Apify Store and run inside n8n exactly the way shown here.&lt;/p&gt;

&lt;p&gt;Happy automating.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>nocode</category>
      <category>webscraping</category>
    </item>
  </channel>
</rss>
