How to replace ManyChat
in one weekend.
The exact system I built for my own IG. Keyword triggers. Follow gate. Auto-retrigger when they DM back. Every fire logged in a dashboard I own. No SaaS bill. No rate limits. No "upgrade to unlock" walls. Here is every step.
01.What you are building
Someone comments a keyword on your IG post. Say SCORE or SYSTEMS.
- Meta fires a webhook at your endpoint within 1–3 seconds
- Your bot checks if the commenter follows you
- Following → public comment reply + DM with your link
- Not following → DM: "follow me first so I can send you the link"
- When they follow OR DM back "done" / "ok" / "followed" → fires the real link automatically
- Every step logs to your database so you can see who hit what
02.The stack (two things)
- Vercel — hosts one serverless function that receives Meta's webhooks. Free tier.
- Supabase — holds your automations, follower cache, and every log row. Free tier.
No persistent server. No scaling config. You will never get a bill.
03.Step 1 — Meta app setup
- Go to
developers.facebook.com→ Create App → Business type. - Add the Instagram product to the app.
- Connect your IG account (must be a Business or Creator account, linked to a Facebook Page).
- Generate a long-lived access token. Scopes you need:
instagram_basic,instagram_manage_comments,instagram_manage_messages,pages_messaging. - Under Webhooks → Instagram, subscribe to these fields:
comments,messages,follows.
follows webhook is unreliable. Meta drops some events silently.
Your code must have a fallback or real followers will hit the gate and never get the link.
More on that in step 6.
04.Step 2 — Supabase schema
Three tables. That's it.
-- Your automations (one row per keyword campaign) create table keyword_automations ( id uuid primary key default gen_random_uuid(), ig_user_id text, keyword text not null, match_type text default 'contains', -- exact / starts_with / contains public_reply text, -- what to comment back publicly dm_message text not null, -- use {link} as placeholder dm_link text, require_follow boolean default true, not_following_message text, active boolean default true, fire_count int default 0, last_fired_at timestamptz, created_at timestamptz default now() ); -- Cache of your followers (populated by the follows webhook) create table ig_followers ( ig_user_id text not null, follower_ig_id text not null, follower_username text, followed_at timestamptz, unfollowed_at timestamptz, primary key (ig_user_id, follower_ig_id) ); -- Every fire, every gate, every retrigger — all logged create table automation_logs ( id uuid primary key default gen_random_uuid(), automation_id uuid references keyword_automations(id), ig_user_id text, commenter_ig_id text, commenter_username text, trigger_type text, -- comment / dm / follow_retrigger / dm_retrigger trigger_text text, matched_keyword text, was_following boolean, dm_sent boolean, error_message text, raw_payload jsonb, created_at timestamptz default now() );
05.Step 3 — The webhook handler
One file at api/meta-webhook.ts. Three jobs: verify the webhook on GET, route incoming events on POST, send DMs through the Graph API.
export default async function handler(req, res) { // Meta verifies the URL with a GET challenge first if (req.method === 'GET') { const token = req.query['hub.verify_token'] if (token === process.env.VERIFY_TOKEN) { return res.status(200).send(req.query['hub.challenge']) } return res.status(403).end() } // POST = real event for (const entry of req.body.entry || []) { for (const change of entry.changes || []) { if (change.field === 'comments') await handleComment(entry.id, change.value) if (change.field === 'follows') await handleFollow(entry.id, change.value) } for (const msg of entry.messaging || []) { await handleMessage(entry.id, msg) } } res.status(200).json({ ok: true }) }
Comment handler pseudocode (the core logic):
async function handleComment(igUserId, v) { const auto = await findMatchingAutomation(v.text) if (!auto) return const following = await isFollower(igUserId, v.from.id) if (auto.public_reply) replyPublicly(v.id, auto.public_reply) if (auto.require_follow && !following) { await sendPrivateReply(v.id, auto.not_following_message) // IMPORTANT: log this as "gated" so we can retrigger later await log({ commenter_ig_id: v.from.id, was_following: false }) } else { await sendPrivateReply(v.id, auto.dm_message.replace('{link}', auto.dm_link)) await log({ commenter_ig_id: v.from.id, was_following: true, dm_sent: true }) } }
graph.facebook.com. Newer Instagram-Login tokens
(they start with IGAA) use graph.instagram.com. Your code needs to pick the right base URL
per token or you'll get 400s that make no sense.
06.Step 4 — The follow gate retrigger (the secret sauce)
This is where most DIY builds fall apart. They ship the gate, someone follows, and the link never arrives.
Two recovery paths. Both required.
Path A — the follow webhook fires
When Meta sends you a follows event, look up any gated automation_logs row for that follower from the last 24 hours. If you find one, fire the real DM now and mark the log fulfilled.
Path B — they DM back instead
If Meta drops the follow event (it happens — trust me), the user often DMs back "done" or "ok" or "followed". When you receive a DM, check intent (either keywords or a quick LLM call) and do the same gated-log lookup. If there's a gated row, send the link.
ig_followers table didn't know. The gate fired. The user got stuck. Without Path B,
that lead is gone. With it, they DM "done", the bot catches the intent, sends the link, and the gate resolves.
07.Step 5 — Sending the actual DM
The Graph API has two surface endpoints depending on how you're replying:
- Reply to a comment (private DM) — POST to
/{ig_user_id}/messageswithrecipient: {comment_id: "..."} - Reply to a DM (by sender) — POST to
/{ig_user_id}/messageswithrecipient: {id: "..."}
await fetch(`${BASE}/${igUserId}/messages?access_token=${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipient: { id: senderIgId }, message: { text: dmText } }) })
08.Step 6 — Deploy
- Push to GitHub. Connect to Vercel. Deploy.
- Copy your Vercel URL (e.g.
https://yoursite.vercel.app/api/meta-webhook). - Paste it in Meta's webhook config. Set your
VERIFY_TOKENenv var on Vercel to match. - Hit "Verify and Save" in Meta. It fires a GET. You return the challenge. Green check.
- Create your first automation row in Supabase. Go comment the keyword on your own post. Watch it fire.
09.What this unlocks
- You own your funnel top to bottom — no rented infrastructure
- No SaaS can price-hike your acquisition system
- Your logs give you data ManyChat hides: exactly who hit which keyword, when, whether they followed, whether the DM landed
- You can build any automation: cold DMs from ad leads, tag sync, multi-step funnels, whatever your brain wants
- When something breaks you have the code open in your editor. Not a ticket queue.
Stuck halfway through? Meta webhook failing to verify? Want someone to just do it?
Book a 30-min call10.Where most people quit
Three failure points — in order of how many times I've seen someone get stuck:
- Meta app review. To go live to real accounts you need App Review for the IG scopes. Takes 3–10 days. Most people give up here.
- The ID mismatch. The commenter's IG ID from a comment event does not always equal the sender's IG ID from a DM event, depending on your token type. You have to handle both.
- Silent follow events. 5–15% of follows never fire a webhook. If you don't build Path B (DM intent fallback), those leads are gone.
If you get past all three, you have a system that will run for years.
I'll install this whole system for you.
Your account. Your Supabase. Your Vercel. You own everything. I get it live in a week. You never touch ManyChat again.
Book a 30-min call Or DM me on IG