All guides

Self-Host Instagram Comment-to-DM Automation With OpenReply

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.

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, and fine for a single account. The catch is the public address. Meta has to reach your machine, so you need a tunnel, and a free tunnel hands you a new hostname every time it restarts. Each new hostname means redoing three Meta screens.

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.

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

bash
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:migrate

Generate the secrets it needs and put them in .env:

bash
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_TOKEN

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:

bash
npm run dev      # web app on http://localhost:3000
npm run worker   # this is what actually sends DMs

The worker does not load .env by 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:

code
https://myaccount.google.com/apppasswords

Type any name, click Create, and copy the sixteen characters. Delete the spaces. Then set this in .env, replacing the Resend option:

code
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 .env breaks on this file, because EMAIL_FROM contains 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. Start a tunnel:

bash
cloudflared tunnel --url http://localhost:3000

It prints a public address. Put that 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_basic
  • instagram_business_manage_comments
  • instagram_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 asWhere it lives
INSTAGRAM_APP_IDInstagram app ID, top of the API setup with Instagram login page
INSTAGRAM_APP_SECRETInstagram app secret, same page, click Show
FACEBOOK_APP_SECRETApp 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.com in 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:

code
https://YOUR-URL/api/instagram/callback

You 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:

code
https://YOUR-URL/api/webhook

Verify 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:

code
https://YOUR-URL/privacy
https://YOUR-URL/terms
https://YOUR-URL/data-deletion

For 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:

code
Here you go {username}: https://your-link.com

Leave 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. On macOS, note that it uses wget, which is not installed by default.

If a token does lapse, recovery is clicking Connect Instagram once. The Meta app, permissions, tester role and webhook are permanent.

The address is the fragile part, not the token. A free tunnel hostname changes on every restart, and each change means redoing Steps 7, 8 and 9. If you are past experimenting, fix it properly with a named Cloudflare tunnel on a domain you own, or move to Vercel and Railway.

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

SymptomCause
No DM, webhooks arrivingThe worker is not running, or has no environment loaded
No webhooks at allThe app is not published, or it is subscribed to the wrong fields
Insufficient Developer RoleThe tester invite was never accepted inside Instagram
Redirect URI mismatchTrailing slash, or a stale address after a tunnel restart
Signature verification failedOnly one of the two app secrets is set, or one is wrong
Login email never arrivesPlaceholder sender still in EMAIL_FROM, or the app password has spaces in it
Everything worked, now nothing doesThe tunnel restarted and the address changed

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.

code
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
   tunnel. Hosted means Vercel for the web app and Railway for the worker, Postgres
   and Redis. Tell me plainly that a free tunnel hostname changes on every restart and
   that each change forces me to redo three Meta screens.
2. Get Postgres and Redis running, then run the Prisma migration.
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. Get a public URL, set NEXTAUTH_URL to it, restart, and confirm the public
   /api/health returns 200.
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. Use scripts/cron.sh as an always-on process. It uses wget, which is
   not present on macOS.

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, it keeps running. The token renews itself and the machine restarts itself. The only thing that will break it is the address changing, so spend the extra twenty minutes making that permanent.

Sources

Learn this inside the community

The full course, templates, and the people building this, free in the Skool community.

Join the community