Reading the repo
Opening the box…
Reading the repo
Opening the box…
How to install on Linux
The open-source Manychat alternative
Fresh setup, read just now
Will it work on Linux?
Heads-upBuilding it yourself? It also needs Redis, PostgreSQL and 13 settings in .env (7 secret).
docs/setup.mdGet OpenReply running end to end: choose your Instagram connection, deploy the web app and worker, configure the databases, and test a campaign. OpenReply is self-hosted with either provider.
If you use a coding assistant, start with Set it up with an AI assistant. Its first decision is your provider, before any Meta app secrets.
| Connection | What you configure | Costs | | --- | --- | --- | | Zernio, recommended for simpler connection setup | A Zernio API key in Settings, a profile, and an Instagram account. OpenReply registers the webhook. No own Meta app or Meta secrets required. | Optional paid provider, plus your hosting. Zernio sponsors OpenReply. | | Your own Meta app | Your Meta app, Instagram Login, app secrets, webhook, and App Review where required. | Your hosting and any other services you use. No Zernio subscription. |
Both use the official Instagram API and remain subject to Instagram’s policies, account requirements, permissions, rate limits, and messaging windows. Both need PostgreSQL, Redis, email delivery, and a running worker. Existing connections are not migrated automatically.
Learn about the optional sponsor at Zernio. Check the provider guide and feature limits before choosing.
OpenReply is two processes and two datastores.
npm run worker) that consumes the send queue and runs the polling reconciler. It cannot run on Vercel, because serverless functions are short-lived and a queue consumer has to stay up. Railway, Render, Fly, or any always-on box works.The web app and the worker must share the same DATABASE_URL, the same REDIS_URL, and the same ENCRYPTION_KEY. The web app writes an encrypted Instagram token; the worker decrypts it to send. Different keys mean every send fails to decrypt.
EMAIL_SERVER at it instead and skip Resend entirely — see the environment variables table.You do not need to buy a domain. Deploying the web app to Vercel gives you a free public URL like your-app.vercel.app, and that URL is what everything else points at: NEXTAUTH_URL, provider callbacks and incoming webhooks use it. If you want a custom domain later you can add one, but it is optional and you can launch without it.
Recommended split:
your-app.vercel.app for free on deploy.Do Railway first, because Vercel needs the database URLs from it.
Build Command: npm run db:generate
Start Command: npm run worker
The worker only needs the generated Prisma client, not next build. Do not leave the build as the default npm run build: it runs next build needlessly, and any build step that reaches the database (like prisma migrate deploy) fails here, because the worker cannot connect to Postgres at build time. Migrations are applied by the web app's vercel-build (Step 3) and by the manual db:migrate below, never by the worker.postgres.railway.internal and redis.railway.internal); inside Railway's network they are faster and free of egress. NEXTAUTH_URL is your Vercel domain. ENCRYPTION_KEY must be the exact same value you will use on Vercel.Getting the connection URLs. Open the Postgres service, then its Variables or Connect tab. You will see two URLs:
| Variable | Host | Use it for |
| --- | --- | --- |
| DATABASE_URL | postgres.railway.internal | the Railway worker only |
| DATABASE_PUBLIC_URL | *.proxy.rlwy.net | Vercel, and running migrations from your machine |
Redis is the same: REDIS_URL (internal) for the worker, REDIS_PUBLIC_URL (public proxy) for Vercel.
Vercel runs outside Railway's private network, so if you give Vercel an internal *.railway.internal URL it will hang and time out. Always give Vercel the public URLs.
Run once from your machine, using the public Postgres URL:
DATABASE_URL="postgresql://...proxy.rlwy.net.../railway" npm run db:migrate
NEXTAUTH_URL: your Vercel domain, for example https://your-app.vercel.app. This is the free domain Vercel assigns on deploy.DATABASE_URL and REDIS_URL: the public Railway URLs (DATABASE_PUBLIC_URL and REDIS_PUBLIC_URL from Railway).ENCRYPTION_KEY: the exact same value as on the worker.prisma generate before next build, so the Prisma client is generated even though it is gitignored.vercel.json.Note on crons: Vercel's free plan allows each cron to run at most once per day. The repo's crons are set to daily for that reason. The comment polling reconciler does not use a Vercel cron; it runs inside the Railway worker on its own interval, so the free plan is not a constraint there.
Optional custom domain: if you want openreply.yoursite.com instead of the Vercel URL, add it in Vercel under Domains and make it primary. Then update NEXTAUTH_URL and the two Meta URLs (Step 7 and Step 8 below) to the new domain, and update the worker's NEXTAUTH_URL too, or tracked links in DMs will point at the old domain.
Copy .env.example to .env for local work, or set these in Vercel and Railway for hosting. Zernio credentials are saved in Settings, not environment variables. The Meta variables in the second table are only for direct Meta connections.
| Variable | What it is |
| --- | --- |
| NEXTAUTH_URL | Your public URL. Your Vercel domain in production, your tunnel URL locally. |
| NEXTAUTH_SECRET | Random secret. openssl rand -base64 32 |
| CRON_SECRET | Random secret protecting the token-refresh cron. |
| ENCRYPTION_KEY | 32-byte hex. openssl rand -hex 32. Encrypts provider credentials and Instagram tokens. Identical across web and worker. |
| DATABASE_URL | PostgreSQL connection string. Public Railway URL on Vercel; internal on the worker. |
| REDIS_URL | Redis connection string. Must support blocking commands, so an HTTP-only Redis will not work with BullMQ. |
| RESEND_API_KEY | Resend key. Login is email magic links only, so without this nobody can sign in. |
| EMAIL_FROM | A sender on a domain you verified in Resend. The placeholder will not deliver. |
| ALLOWED_EMAILS | Optional. Comma-separated allowlist of addresses that may sign in, case insensitive. Unset, anyone who reaches your public URL can request a magic link and gets their own workspace, which is worth closing on an instance you run for yourself. |
| EMAIL_SERVER | Optional. An SMTP URL, for example smtps://login%40example.com:password@mail.example.com:465. Set it to send magic links through your own mail server instead of Resend; then RESEND_API_KEY is not needed. URL-encode special characters in the user and password (@ becomes %40). Port 465 with smtps:// is implicit TLS, port 587 with smtp:// is STARTTLS. |
Direct Meta only. Leave these unset if all accounts use Zernio:
| Variable | What it is |
| --- | --- |
| META_GRAPH_API_VERSION | Graph API version, for example v25.0. |
| INSTAGRAM_APP_ID | From the Meta app, see Step 6. |
| INSTAGRAM_APP_SECRET | From the Meta app. |
| FACEBOOK_APP_SECRET | From the Meta app. |
| WEBHOOK_VERIFY_TOKEN | Any random string. You paste the same value into Meta's webhook config. |
ENCRYPTION_KEY must be exactly 64 hex characters or the app throws on boot.
Optional, for tuning the polling reconciler (defaults are fine to start):
| Variable | Default | What it does |
| --- | --- | --- |
| COMMENT_POLL_INTERVAL_MS | 300000 | How often the worker sweeps for missed comments (5 min). |
| COMMENT_POLL_MAX_PER_SWEEP | 30 | Max new comments each campaign acts on per sweep. Keep it conservative; higher gets closer to Instagram's rate limits. |
| COMMENT_POLL_LOOKBACK_HOURS | 72 | How far back a sweep considers comments. |
After deployment, sign in as a workspace owner or admin and follow docs/zernio.md. Save an unrestricted read/write API key with Inbox access, select your existing Zernio profile, then import an Instagram account or connect a new one through Zernio. OpenReply creates its webhook automatically.
You can skip the entire Meta app section below and continue at Test it end to end. No INSTAGRAM_APP_ID, INSTAGRAM_APP_SECRET, FACEBOOK_APP_SECRET, or WEBHOOK_VERIFY_TOKEN is needed for this path.
Direct Meta connections only. Zernio users skip this section.
This is the slow part. The code works out of the box; getting Meta to send you comment events is where people lose an afternoon. Every step here exists because skipping it breaks something later. Have your Vercel domain from Step 3 ready, you will paste it in a few times.
Go to developers.facebook.com/apps and create an app.
When it asks you to add a use case, filter to All, then choose Manage messaging and content on Instagram. Do not pick "Create and manage ads with Marketing API", and do not pick "Authenticate with Facebook Login". OpenReply uses Instagram Login. Picking the Facebook Login variant makes the OAuth flow fail later with a mismatched client error.
If you accidentally added the Marketing API use case, remove it. It has its own heavy review requirements and can block publishing.
There are two app secrets and two app IDs, which is confusing. Here is what maps to what.
| Environment variable | Where it lives |
| --- | --- |
| INSTAGRAM_APP_ID | Instagram, API setup with Instagram login. A number like 2036... |
| 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 shown on the Basic settings page. Use the one under the Instagram product.
OpenReply verifies webhook signatures against both FACEBOOK_APP_SECRET and INSTAGRAM_APP_SECRET, so you do not have to guess which one Meta signs with. Set both.
This is the step people miss, and it produces the error "Insufficient Developer Role" on the Instagram login screen. In development, only accounts that have a role on your app can connect. Even your own account has to be added and accept.
There are two halves. Both are required.
Half one, on the Meta side. In the app dashboard, open App roles, then Roles (in the newer console this is also reachable from the Instagram product under "Generate access tokens"). Find the section for Instagram testers, click add, and enter the exact Instagram username of the account you want to connect. Send the invite.
Half two, on the Instagram side. This is the part that gets skipped. Open Instagram as that account (the phone app is easiest):
Until you accept here, the account is not really a tester and the login will keep failing. If you do not see the invite, double-check you sent it to the exact username and that the account is a Business or Creator account.
In the Instagram product, open Set up Instagram business login, then Business login settings. In the OAuth redirect URIs field, add exactly, using your Vercel domain:
https://your-app.vercel.app/api/instagram/callback
No trailing slash. If this is missing or wrong, connecting an account fails with a redirect_uri mismatch. You can register more than one, which is useful if you change domains later; keep the old and new both listed.
You do not need the "Embed URL" that Meta shows here. OpenReply builds its own login URL. Users connect by opening your app, going to Settings, and clicking Connect Instagram.
Still in the Instagram product, find the Configure webhooks step.
https://your-app.vercel.app/api/webhookWEBHOOK_VERIFY_TOKEN from your environmentcomments field, and to messages as well.Both fields matter. comments carries comment-to-DM, which is what most people come here for. messages carries inbound DMs and Story replies, which is what a campaign's "also reply when someone DMs these words" toggle runs on. Subscribe to comments alone and that toggle looks enabled but never fires, because the events it needs are never delivered.
To test delivery without a real comment, click Test next to comments, then click Send to My Server. This is a two-step control. Clicking Test only previews the sample payload; the second button is what actually POSTs it to your endpoint. After sending, a row should appear in your WebhookEvent table.
If your primary domain ever changes, update this callback URL to the new domain. A non-primary domain will 307-redirect the POST, and Meta does not reliably follow redirects, so webhooks silently stop.
Real comment webhooks are only delivered when the app is in Live state. In Development mode, only the console Test button delivers events. This is the single most common reason for "I set everything up and nothing happens."
Go to the Publish item in the left sidebar. Set the privacy policy, terms of service, and data deletion URLs first, or it will not let you publish. OpenReply ships these pages, on your Vercel domain:
https://your-app.vercel.app/privacy
https://your-app.vercel.app/data-deletion
https://your-app.vercel.app/terms
Then publish. Depending on your access level, Meta may let you go live for your own tester accounts immediately, or it may require App Review first (see the last section).
This one costs an afternoon because the symptom points nowhere near the cause.
A published app still holds Standard Access to instagram_business_basic, instagram_business_manage_comments, and instagram_business_manage_messages. Standard Access only covers Instagram accounts that have a role on your app — admins, developers, and Instagram testers. Publishing makes the app live; it does not widen who the permissions apply to. Advanced Access, which covers everyone else, comes only from App Review.
So connecting a second account fails even though the first one works, on the same app, with the same code.
The symptom: Instagram's consent screen appears and the login succeeds, the code exchange at api.instagram.com/oauth/access_token returns a normal IGAA… token with all the requested permissions — and then every single call against graph.instagram.com is refused:
Unsupported request - method type: get [code=100, type=IGApiException]
/access_token, /refresh_access_token, /me — all of them, identically. Nothing about the message suggests a missing role, and the token itself looks fine.
The fix for your own accounts is the same two-part dance as Step 6, once per account: invite the Instagram username under App roles, Roles, Instagram testers, then accept the invite inside Instagram under Edit profile, Apps and websites, Tester invites. For accounts you do not control, you need App Review — see META_APP_REVIEW.md.
Do this whenever the Instagram account was ever connected to ManyChat or another comment-to-DM tool, even if you have cancelled it. Skip it and comments look fine, but every DM button tap fails.
The symptom: comments work. The public reply posts and the first DM arrives. But when the commenter taps the DM button, nothing comes back, and DM Logs shows the button tap as failed with:
The action is invalid since it's not the thread owner. [code=100 sub=2534037 type=IGApiException]
Instagram lets any connected app send the one private reply to a comment, which is why the first DM gets through. Every message after that, including the reply to a button tap, has to come from the app that owns the conversation. If another tool is still connected to the Instagram account, it can stay the owner even after you cancel or turn it off.
Fix it in Meta Business Suite, under Settings, Integrations, Conversation routing:
Tap the button again on a fresh comment. The follow-up DM should now send.
You do not have to do anything here; OpenReply handles it. It is worth understanding because it is invisible when it goes wrong.
Meta's /me returns two IDs. The id field is app-scoped. The user_id field is the In
Then run bash setup.sh. It stops at the first error and is safe to run again. Servers and tests aren't started; it lists them at the end. Read it first.
git clone https://github.com/diwenne/openreply.git cd openreply
Node.js 20, Docker
# needs nvm: https://github.com/nvm-sh/nvm nvm install 20 # needs Docker Engine: https://docs.docker.com/engine/install/
Fill in secrets: NEXTAUTH_SECRET, CRON_SECRET, ENCRYPTION_KEY, RESEND_API_KEY, INSTAGRAM_APP_SECRET, FACEBOOK_APP_SECRET, WEBHOOK_VERIFY_TOKEN
cp .env.example .env.local
Needs: Redis, PostgreSQL
# needs Docker Engine: https://docs.docker.com/engine/install/ docker compose -f docker-compose.yml up -d
npm ci
npx prisma migrate dev
npm run dev
npm test
Read docs/setup.md for the complete walkthrough, including a provider-aware AI assistant prompt. Existing accounts are never automatically migrated. Check Zernio’s feature limits before choosing.
Moving from ManyChat or another DM tool? Disconnect it from the Instagram account and give OpenReply control of conversations in Meta Business Suite, or button taps in DMs will fail with "not the thread owner". See Migrating from ManyChat.
The button creates your web deployment. You still need to configure the database, Redis, email delivery, and a separate always-on worker.
git clone https://github.com/diwenne/openreply.git
cd openreply
npm install
cp .env.example .env # then fill in the values, see docs/setup.md
docker-compose up -d # starts Postgres and Redis
npm run db:generate
npm run db:migrate
npm run dev # web app on http://localhost:3000
npm run worker # in a second terminal, this sends the DMs
Two processes, always. npm run dev serves the app and receives webhooks. npm run worker is what actually sends the messages. If comments come in and no DM ever arrives, the worker is the first thing to check.
Full environment variables and the production layout are in docs/setup.md.
No reports for Linux yet.
GitManual AI Agent reads the 9 key files and writes a step-by-step guide for Linux. Once written, it's saved for everyone.