Someone comments a keyword on your reel. A second later they get a DM with your link. ManyChat charges a monthly fee for that. OpenReply is an open source version you run yourself, and the running cost can be nothing if you already own a domain and have a Mac or server that stays on. The domain registration itself is still a paid item unless you already have one.
This guide is written from a real end to end setup, including every wrong turn. The code takes about ten minutes. The Meta app configuration is the slow part, and it is where almost everyone gets stuck.
There are two ways to follow along. Work through the steps yourself, or paste the prompt at the bottom into Claude Code or Codex and let it drive. Both end in the same place.
What you get
- Comment a keyword, get an automatic DM, sent through Meta's official API.
- Optional public reply under the comment.
- Story reply and inbound DM triggers on the same keywords.
- Tracked links with click counts, a follow gate, an inbox, and per account rate limiting.
- No monthly fee, no seat limits, no plan caps.
It does not scrape, it does not automate a browser, and it never asks for your Instagram password. That matters, because password based automation tools are what get accounts flagged.
What you need before you start
- An Instagram Business or Creator account. A personal account cannot connect at all. Switch it in the Instagram app under Settings, Account type and tools.
- A Facebook account. Meta developer registration is built on it. There is no Instagram only path.
- A second Instagram account to test with. Your own comments are deliberately ignored, so testing from the same account looks broken when it is working correctly.
- Node.js 20 or newer, and either Docker or a local Postgres and Redis.
- A way to send login emails. A Gmail app password is the fastest.
Use whichever Facebook account is older and that you can reliably get back into. It only owns the developer app and never touches your Instagram content. Meta sometimes flags brand new accounts that create developer apps, and if that account is ever locked, the app goes with it.
Decide where to run it first
OpenReply is two processes and two datastores. A web app that receives the webhook and serves the dashboard, and a worker that actually sends the messages. The worker has to stay running, so it cannot live on serverless hosting.
Run it on your own machine. Free beyond the domain, and fine for a single account. The catch is that this machine is now your server. The web app, worker, Postgres, Redis, tunnel and token-refresh scheduler all need to stay running. A Mac mini that stays powered, awake and connected is suitable. A laptop that sleeps, leaves Wi-Fi or gets closed is not.
For a permanent local setup, use a named Cloudflare tunnel on a domain you own and run the processes under a supervisor such as macOS launchd. That gives you a stable hostname and automatic restarts after a process crash or Mac reboot. A free quick tunnel is for testing only: its hostname changes on restart and forces you to redo the Meta URLs.
Run it on Vercel and Railway. The web app on Vercel, the worker plus Postgres and Redis on Railway. You get a permanent address and it keeps working when your computer is off. Free tiers cover a single account to start, and a fully self managed stack settles around 26 to 40 US dollars a month.
If you want both, run it locally on a named Cloudflare tunnel pointed at a domain you already own. The hostname becomes permanent and everything stays on your machine, but it is only reachable while that machine and its internet connection are up.
Pick one now. The address you choose gets baked into the Meta app, and changing it later means repeating three steps.
Step 1: Get the code running
git clone https://github.com/diwenne/openreply.git
cd openreply
npm install
cp .env.example .env
docker compose up -d
npm run db:generate
npm run db:migrateGenerate the secrets it needs and put them in .env:
openssl rand -base64 32 # NEXTAUTH_SECRET
openssl rand -base64 32 # CRON_SECRET
openssl rand -hex 32 # ENCRYPTION_KEY, must be exactly 64 characters
openssl rand -hex 24 # WEBHOOK_VERIFY_TOKENThe repository's
docker-compose.ymlsets no restart policy. When Docker Desktop restarts, updates or the Mac reboots, Postgres stays stopped, and the app fails in a confusing way: you cannot log in and every page errors. Set it once, from the repository folder, so the database comes back on its own:
docker update --restart unless-stopped $(docker compose ps -q)ENCRYPTION_KEY encrypts your Instagram token. It must be identical on the web app and the worker, and if you ever change it every connected account has to reconnect.
Add your own email to ALLOWED_EMAILS. Without it, anyone who finds your public URL can request a login link and get their own workspace.
Then start both processes, in two terminals:
npm run dev # web app on http://localhost:3000
npm run worker # this is what actually sends DMsThe worker does not load
.envby itself. Only the web app does. Started plainly it will report healthy while every database call inside it silently fails, because the health check only tests Redis. Export the variables before starting it, or run it through a wrapper script that loads them.
Visit http://localhost:3000/api/health. You want status: ok and worker.healthy: true.
Step 2: Login email
Login is email magic links only, so nothing works until this does.
Google removed the App passwords entry from the 2-Step Verification page, which is where most people go looking. Open this address directly instead:
https://myaccount.google.com/apppasswordsType any name, click Create, and copy the sixteen characters. Delete the spaces. Then set this in .env, replacing the Resend option:
EMAIL_SERVER=smtp://you%40gmail.com:yourapppassword@smtp.gmail.com:587
EMAIL_FROM=OpenReply <you@gmail.com>The @ in your address must be written as %40, because the whole thing is a URL.
Test the credentials before going further. Twenty seconds here saves an hour of debugging a silent login page later.
A plain
source .envbreaks on this file, becauseEMAIL_FROMcontains an unquoted angle bracket that the shell reads as a redirect. Load the file line by line instead if you need the values in a shell.
Step 3: Make it reachable
Meta will not send webhooks to localhost. There are two tunnel modes. Use the quick tunnel below only to prove the app works during setup:
cloudflared tunnel --url http://localhost:3000It prints a temporary trycloudflare.com address. Put that address in .env as NEXTAUTH_URL and restart the web app. Confirm the public /api/health returns 200 before touching Meta. Do not use this address for a permanent deployment.
For the permanent local setup, buy or use a domain, put its DNS under the Cloudflare account that owns the zone, and create a named tunnel:
cloudflared tunnel login
cloudflared tunnel create openreply
cloudflared tunnel route dns openreply openreply.your-domain.comCreate a cloudflared config using the tunnel UUID and credential file it printed:
tunnel: YOUR-TUNNEL-UUID
credentials-file: /Users/YOU/.cloudflared/YOUR-TUNNEL-UUID.json
ingress:
- hostname: openreply.your-domain.com
service: http://localhost:3000
- service: http_status:404Run it as a named tunnel, not as a quick tunnel:
cloudflared tunnel --config ~/.cloudflared/openreply.yml run openreplyRun that process, the web app, the worker and the token-refresh job under launchd, systemd, Docker Compose or another supervisor with automatic restart. On macOS, set the machine not to sleep when the display is off and enable automatic restart after a power failure. The hostname is permanent, but the service is not available while the Mac is asleep, powered off or disconnected.
Set the stable address in .env as NEXTAUTH_URL and restart the web app. Confirm the public /api/health returns 200 before touching Meta.
Everything from here uses that address. Wherever this guide says YOUR-URL, paste yours.
Step 4: Create the Meta app
Go to developers.facebook.com. If the top right says Get Started, click it and register: accept the terms, verify by phone, choose Developer. If it already says My Apps, you are registered.
Create an app. When it asks for a use case, choose Manage messaging and content on Instagram. Filter to All if you do not see it.
Do not choose Authenticate with Facebook Login, and do not add the Marketing API. The Marketing API carries its own heavy review requirements and can block publishing.
After creating the app, the left sidebar will probably show "Facebook Login for Business" and no Instagram entry. That is normal and not a mistake. The Instagram setup lives under Use cases in the sidebar, then API setup with Instagram login. An app can only use one of the two login methods, and OpenReply needs the Instagram one, which does not require a Facebook Page linked to the account.
Step 5: Permissions and the two secrets
On the API setup with Instagram login page, section 1 lists three permissions. Click Add all required permissions. They are exactly what OpenReply uses:
instagram_business_basicinstagram_business_manage_commentsinstagram_business_manage_messages
Now collect three values. There are two different app secrets, which is the single most confusing part of the whole setup.
| Goes in .env as | Where it lives |
|---|---|
INSTAGRAM_APP_ID | Instagram app ID, top of the API setup with Instagram login page |
INSTAGRAM_APP_SECRET | Instagram app secret, same page, click Show |
FACEBOOK_APP_SECRET | App settings, Basic, App secret, click Show |
The Instagram app ID is not the same number as the Facebook App ID on the Basic page. Use the one under the Instagram section. Set both secrets. OpenReply checks webhook signatures against either, so you never have to work out which one Meta signed with.
Restart the web app and worker so they pick up the new values.
Skip section 2, Generate access tokens. OpenReply runs its own login flow and subscribes the account to webhooks itself. Generating a token by hand here just creates an unused credential.
Step 6: The tester role, and actually accepting it
In development, only accounts with a role on your app can connect. Your own account included. This has two halves and both are required.
In the Meta console, go to App roles, then Roles, then Add People. Choose Instagram Tester at the bottom, under "Additional roles for this app". Do not choose "Tester" above it, which is a Facebook role and does nothing here. Type the Instagram handle without the @, wait for the autocomplete to offer the account, select it, and add.
Then accept, as that Instagram account. Go to Settings, then Apps and websites, then the Tester invites tab.
The Tester invites tab only exists while an invite is pending, and the screen is per account. If you see only Active, Expired and Removed, either the invite was never actually sent or you are looking at a different account. Check the Roles page first: the handle should be listed with status Pending. If the mobile app will not show the tab,
instagram.comin a desktop browser works.
Go back to the Roles page and confirm the status is no longer Pending. Nothing will connect until it changes.
Step 7: OAuth redirect
Back on the API setup with Instagram login page, scroll to section 4, Set up Instagram business login, and click Set up. In the OAuth redirect URIs field, add this exactly, with no trailing slash:
https://YOUR-URL/api/instagram/callbackYou can register more than one, which is worth doing when your address changes. Keep the old and the new both listed.
Step 8: Webhook
Section 3, Configure webhooks. Callback URL:
https://YOUR-URL/api/webhookVerify token: the WEBHOOK_VERIFY_TOKEN value from your .env. Click Verify and save. It should succeed immediately, because your app answers the challenge automatically. If the button is greyed out, click back into the verify token field and paste it again. Editing the callback URL often clears it.
Then subscribe to both comments and messages. Comments carries the comment to DM flow, which is the whole point. Messages carries inbound DMs and Story replies, which is what the Story reply feature runs on. Subscribe to comments alone and that feature looks enabled but never fires.
Leave the other fields off. OpenReply ignores them, but each one still creates a stored event row and clutters your logs.
To prove the connection without waiting for a real comment, use the Test link on the comments row, then click Send to My Server. That second button is the one that actually posts. A row should appear in your WebhookEvent table.
Do not panic if your server logs look empty. A production Next.js build does not log incoming requests at all. Check the database instead. Meta also refuses to save a callback URL unless verification succeeded, so a saved URL is itself proof it worked.
Step 9: Publish the app
Real comment webhooks are only delivered when the app is Live. In development mode, only the console Test button delivers anything. This is the most common reason for "I set everything up and nothing happens".
Publishing needs three URLs, which OpenReply already serves. Set them in App settings, then Basic:
https://YOUR-URL/privacy
https://YOUR-URL/terms
https://YOUR-URL/data-deletionFor user data deletion, switch the dropdown to Data deletion instructions URL. Save, then go to Publish in the sidebar and set the app Live.
Publishing is not the same as Advanced Access. A published app still only works for accounts that hold a role on it. That is fine for running your own accounts, and it is why you did Step 6.
Step 10: Connect and test
Open your dashboard, sign in with the magic link, go to Settings and click Connect Instagram. You should reach Instagram's consent screen and come back connected.
Two failures, each with one cause:
- Redirect URI mismatch. Step 7 was not saved, or has a trailing slash.
- Insufficient Developer Role. The tester invite in Step 6 was never accepted.
Then create a campaign. Pick a recent post, set the keyword to LINK, and write the message. {username} is replaced with the commenter's handle:
Here you go {username}: https://your-link.comLeave the follow gate, public reply and opening DM off for the first test. Get the plain path working, then add them.
Now comment LINK on that post from your second account. The DM should arrive within a second or two. If it does not, the DM Logs page tells you where it stopped, and /api/health tells you whether the worker is alive.
Keeping it running
The token renews itself. Instagram long lived tokens last 60 days. OpenReply's daily cron refreshes any token due to expire within ten days, and each refresh extends it another 60. That rolls indefinitely as long as the scheduler runs.
On Vercel that cron is wired up already. Self hosted it is not: nothing reads the schedule file, so the job silently never runs, the token quietly expires, and every automation stops without an error. The repository ships scripts/cron.sh for exactly this. Run it as its own always-on process under the same supervisor as the worker. On macOS, note that the repository script uses wget, which is not installed by default. Install wget, or use a curl-based macOS wrapper that calls the same refresh endpoints. A healthy Redis heartbeat alone does not prove that the worker can reach Postgres or that the cron is running.
If a token does lapse, recovery is clicking Connect Instagram once. The Meta app, permissions, tester role and webhook are permanent.
The database must restart with Docker. Postgres and Redis run in Docker, and your supervisor only restarts the web app, worker, tunnel and cron. If you skipped the restart policy in Step 1, any Docker restart leaves Postgres down while everything else keeps running. The fix at that point is docker compose up -d from the repository folder, then the docker update command from Step 1 so it does not happen again.
The address is permanent only when the tunnel is named. A free quick-tunnel hostname changes on every restart, and each change means redoing Steps 7, 8 and 9. A named Cloudflare tunnel keeps the hostname stable, but it still depends on the Mac, the tunnel process, the domain renewal and the DNS account remaining available. If you do not want to operate an always-on Mac, use Vercel and Railway instead.
Know the real limits. Meta allows about 750 private replies per hour per account, and you can only message someone within 24 hours of their comment. OpenReply queues overflow rather than dropping it.
When it breaks
| Symptom | Cause |
|---|---|
| No DM, webhooks arriving | The worker is not running, or has no environment loaded |
| No webhooks at all | The app is not published, or it is subscribed to the wrong fields |
| Insufficient Developer Role | The tester invite was never accepted inside Instagram |
| Redirect URI mismatch | Trailing slash, or a stale address after a tunnel restart |
| Signature verification failed | Only one of the two app secrets is set, or one is wrong |
Cannot log in, pages error, health shows database: error | Postgres stopped after a Docker restart. Run docker compose up -d, then set the restart policy from Step 1 |
| Login email never arrives | Placeholder sender still in EMAIL_FROM, or the app password has spaces in it |
| Everything worked, now nothing does | The Mac slept, a launch agent is stopped, the token-refresh job is not running, or a quick tunnel restarted and changed the address |
Query the database directly when diagnosing. WebhookEvent shows delivery, DmLog shows send status and errors, OperationalEvent shows worker crashes. That is faster than reading logs.
Letting other people use it
Everything above runs your own accounts, or a handful you add as testers, with no App Review.
For a stranger to connect their own Instagram to your instance, Meta requires App Review granting Advanced Access, a screencast of the full flow on real accounts, written justification per permission, and business verification with documents proving a legal entity. Meta scrutinises automated DM apps and often rejects the first submission. If you do not have a registered business you cannot complete it, and the practical answer is to run your own instance for your own account.
The one shot prompt
If you would rather have an AI agent drive the whole thing, open a terminal in a clone of the repository, start Claude Code or Codex, and paste this. It carries the knowledge that makes the difference, so the agent does not rediscover the traps at your expense.
You are setting up OpenReply, an open source Instagram comment-to-DM tool, in this
repository. Read README.md and docs/setup.md before doing anything.
My goal: run it for my own Instagram account only.
Work through this in order. Stop and ask whenever you need a value or an action only
I can perform. Never invent Meta dashboard steps; if a screen does not match, ask me
to describe or screenshot it.
1. Ask me whether I want to run locally or host it. Local means Docker Compose plus a
named Cloudflare tunnel on a domain I own, with the Mac kept awake and online.
Hosted means Vercel for the web app and Railway for the worker, Postgres and Redis.
Tell me plainly that a free quick-tunnel hostname changes on every restart and that
each change forces me to redo three Meta screens. A named tunnel avoids that, but
the local Mac still has to stay on.
2. Get Postgres and Redis running, then run the Prisma migration. The repository's
docker-compose.yml has no restart policy, so set `unless-stopped` on both
containers. Otherwise any Docker restart leaves Postgres down and login fails.
3. Generate NEXTAUTH_SECRET, CRON_SECRET, ENCRYPTION_KEY and WEBHOOK_VERIFY_TOKEN for
me and write them to .env. Set ALLOWED_EMAILS to my address. Ask me for my email
sender. If I use Gmail, tell me App passwords is no longer on the 2-Step
Verification page and give me
https://myaccount.google.com/apppasswords directly. URL-encode the @ as %40 in
EMAIL_SERVER. Test SMTP authentication before moving on.
4. Start the web app and the worker. Important: the worker does not load .env by
itself, only Next does, and /api/health reports the worker healthy even when it has
no database connection, because the heartbeat only checks Redis. Start it with the
environment explicitly loaded and verify no database errors appear. Note that a
plain `source .env` fails on the unquoted EMAIL_FROM line.
5. Create the named tunnel and DNS route, set NEXTAUTH_URL to its stable hostname,
restart, and confirm the public /api/health returns 200. Do not use a quick tunnel
for the permanent setup.
6. Walk me through the Meta app one screen at a time, giving me exact values to paste:
- Business app, use case "Manage messaging and content on Instagram". Not Facebook
Login, not Marketing API.
- The sidebar will show "Facebook Login for Business" and no Instagram entry. That
is expected. The Instagram setup is under Use cases, then API setup with
Instagram login.
- Section 1: add all required permissions.
- Collect three values: INSTAGRAM_APP_ID and INSTAGRAM_APP_SECRET from the
Instagram page, and FACEBOOK_APP_SECRET from App settings, Basic. These are two
genuinely different secrets. Set both. Restart both processes afterwards.
- Skip section 2, Generate access tokens. The app subscribes the account itself
during its own OAuth callback.
- App roles, Roles, Add People, choose "Instagram Tester", not "Tester". Then make
me accept the invite inside Instagram under Settings, Apps and websites, Tester
invites. That tab only appears while an invite is pending and is per account.
Desktop instagram.com works if the mobile app will not show it. Confirm the Roles
page no longer says Pending before continuing.
- Section 4, Set up Instagram business login: OAuth redirect URI is
<public-url>/api/instagram/callback with no trailing slash.
- Section 3, Configure webhooks: callback <public-url>/api/webhook, verify token
from .env, subscribe to comments AND messages, nothing else.
- Publish: set privacy, terms and data-deletion URLs in App settings, Basic, then
go Live. Real webhooks only arrive when published.
7. Verify by querying Postgres directly, not by reading logs. A production Next build
logs no requests at all, so empty logs prove nothing. WebhookEvent shows delivery,
DmLog shows send status, OperationalEvent shows worker errors.
8. Have me create a keyword campaign and comment from a second account. The app
ignores my own comments on purpose. Confirm a SENT row in DmLog.
9. Set up the token refresh scheduler. On Vercel it is wired already. Self hosted,
nothing reads the schedule, so the token expires after 60 days and everything stops
with no error. Run scripts/cron.sh, or a curl-based macOS wrapper, as an always-on
supervised process. The repository script uses wget, which is not present on
macOS by default. Keep the Mac awake and online, and configure automatic restart
for the web app, worker, tunnel and scheduler.
Remind me to rotate any secret I paste to you before real use. Start by reading the
docs, then ask me question 1.The point
The feature is one webhook, one keyword match, and one API call. It does not need a subscription. What it does need is patience with Meta's console, which is why this guide spends most of its length there and not on the code.
Once it runs, the token renews itself and a supervised setup restarts crashed processes. A named tunnel keeps the Meta address stable. The local machine still has to stay powered, awake and online; if you want the automation available while your computer is off, host the web app and worker instead.