Troubleshooting
Symptom first. Start here when a comment gets no DM, Connect fails, or the admin panel won't let you in.
Start here:
npm run doctorIt checks your live deployment (secrets, database tables, connected accounts, active rules, rejected webhooks, and whether the scheduled jobs are running) and names the fix for anything it finds.
Two things it can't see from outside: whether your Meta app is Live, and whether Instagram's Hidden Words filter is removing comments before Meta ever sees them. Check those first if doctor comes back clean and it still doesn't work.
The two delivery paths
Most confusing symptoms make sense once you know there are two.
| The webhook | The poll | |
|---|---|---|
| Speed | About two seconds, or a minute or more when queued | Up to five minutes, or longer when rules cover more than 3 posts (how it takes turns) |
| How it starts | Meta POSTs to /webhook | The Worker asks Instagram for recent comments |
| Needs the app to be Live | Yes | No |
| Sees Hidden Words comments | No | No |
| Covers old posts | Yes | Posts a rule is limited to, plus the 3 newest for rules on all posts. Last 72 hours only |
They share the same rule matching, the same send queue and the same duplicate protection, so a comment is never answered twice, whichever path finds it first.
That explains the two most common confusing reports:
- "It works, but it takes five minutes." The webhook isn't being delivered and the poll is doing all the work. The app is probably still in Development, or the per-account webhook subscription is off.
- "It worked for my new reel and not my old post." The poll only looks at recent posts. Again, the webhook isn't being delivered.
If DMs arrive but slowly, you don't have a matching problem. You have a webhook problem.
Nothing happens when someone comments
The most common report, with eight causes. Work down the list in order; they're sorted by how often they're the answer.
1. The Meta app is still in Development
By far the most common. Meta doesn't deliver real webhooks to an app in Development mode, only the dashboard's Test button.
Confirm: open your app in the Meta dashboard. The mode toggle is at the top.
Fix: Meta → Publish. Paste your three legal URLs, then click Publish.
https://ig-comment-dm.YOURNAME.workers.dev/privacy
https://ig-comment-dm.YOURNAME.workers.dev/terms
https://ig-comment-dm.YOURNAME.workers.dev/data-deletion
You don't need App Review while only your own tester accounts connect. App Review is for letting strangers connect their Instagram.
2. The per-account webhook subscription is off
Setting the callback URL isn't enough. Each connected account has its own toggle, and it defaults to off.
Fix: Meta → Instagram → API setup with Instagram login → Generate access tokens. For every account you connected, set Webhook subscription to On.
If Meta won't let you flip the switch until you generate a token, generate it, flip the switch, then ignore the token. Don't paste it into Cloudflare: Connect already stored the token the Worker uses.
3. FACEBOOK_APP_SECRET isn't set
Meta signs live comment notifications with the Facebook App Secret, not the Instagram one. Without it, every real webhook fails signature verification and is rejected before anything else happens.
Confirm: Home shows a Wrong secret row under "Did Instagram reach us?", or npm run doctor flags it.
Fix: Meta → App settings → Basic → App secret → Show, then:
npx wrangler secret put FACEBOOK_APP_SECRET
npx wrangler deploy4. Hidden Words hid the comment
If Instagram's own spam or keyword filter hides a comment, it never reaches the API at all, and nothing on your side can see it. That's a limitation, not a bug.
Fix: on the creator account, Instagram app → Settings → Hidden Words. Turn the filters off while testing.
5. The comment came from the account that owns the post
Meta notifies you about your own comments on your own posts. Acting on them would DM yourself and use up the one private reply that comment gets, so they're dropped deliberately.
Fix: comment from a different Instagram account. A friend's phone works. The friend doesn't need to be an Instagram Tester; only the account that owns the post does.
6. The comment is more than 7 days old
Instagram refuses a private reply to a comment older than seven days. Those are logged as skipped, with the reason.
7. No rule matched
A comment no rule matches isn't listed under Last 20 sends at all.
Confirm: admin → Test, paste the exact comment text, and see which rule lights up. This sends nothing.
Matching ignores case, emoji, punctuation and accents on Latin letters, and by default matches whole words: guide matches Guide! and guide 🙏 but not guides or #guide2026, and cafe matches café. The Test page shows the comment as it was matched, with all of that stripped out.
If the keyword sits inside a hashtag or a longer word, edit the rule and choose Anywhere in the comment under "How to look for them". That catches #launch2026 for launch, at the cost of art also matching start. How keywords match covers non-Latin scripts too.
A rule set to Reply to every comment on this post fires only on that post. On the Test page, pick the post under "Which posts" to see it match.
If Home says Skipped: matched exclusion “…”, the keyword did match, but the comment also contained one of that rule's exclusions. The Test page lists the rule, the keyword it found and the exclusion that stopped it.
8. The keyword is too short
Keywords under 3 characters are rejected when you save the rule, because they match inside other words: AI would fire on "again" and "email". The count is taken after emoji and punctuation are dropped, so AI!! is too short as well, and a keyword that is only emoji or punctuation (🔥🔥🔥) is refused because it could never match anything. Chinese and Japanese need only 2 characters, so 指南 is fine and 指 is refused.
"Did Instagram reach us?" is empty
The list covers the last 7 days. If it says Nothing in the last 7 days and gives the date of the last notification, Meta has reached your Worker before and just hasn't sent anything since. That's normal when nobody has commented. If someone has, work through the causes below: the app or the subscription may have been switched off since.
If it says No notification has arrived yet, Meta has never POSTed to your Worker. The problem is upstream of the Worker, so nothing in the admin panel will help.
In order of likelihood: the app is in Development (cause 1), the per-account webhook subscription is off (cause 2), or the webhook was never configured.
Check the webhook itself: in the Meta dashboard, next to the comments field, click Test, then Send to My Server. Clicking Test alone only previews the sample payload and sends nothing.
A row should appear under "Did Instagram reach us?" immediately. If it does, your webhook is set up correctly and the problem is delivery of real events, which is almost always cause 1 or 2.
If no row appears from the Test button either, re-check the callback URL:
https://ig-comment-dm.YOURNAME.workers.dev/webhook
and confirm the field is subscribed to comments with Include values on.
"Did Instagram reach us?" says Wrong secret
Something POSTed to your webhook, and the request was rejected because its HMAC signature matched neither app secret.
Anyone who knows the URL can send one of these, so a single row isn't proof of anything. To keep strangers from filling the database, a wrong signature is logged at most once a minute. Home's red banner ("the signing secret did not match") appears only when the last three signed deliveries were all wrong, which spans at least two minutes with nothing valid getting through. That's what a wrong secret looks like: Meta retries a rejected delivery, and every real comment fails the same way.
To check on demand, use the Meta dashboard's Test → Send to My Server button. A new Reached us row means the secret is right. Anything else, with Wrong secret rows listed, means it's wrong.
Requests with no signature at all never came from Meta. They're rejected and not logged.
This is nearly always a missing FACEBOOK_APP_SECRET: see cause 3.
If it's already set, the value is wrong. META_APP_SECRET and FACEBOOK_APP_SECRET come from two different screens and are two different values:
| Secret | Where |
|---|---|
META_APP_SECRET | Instagram → API setup with Instagram login → Business login settings → Instagram App Secret |
FACEBOOK_APP_SECRET | App settings → Basic → App secret |
Invalid redirect_uri
Instagram rejected the Connect attempt before showing the permission screen.
Cause A: the wrong App ID. The number at the top of the Meta dashboard is the Facebook App ID. You need the Instagram App ID, from Instagram → API setup with Instagram login → Business login settings. The Connect page (admin → Accounts → Connect a new account) prints the App ID the Worker is actually sending, so compare the two.
npx wrangler secret put META_APP_ID
npx wrangler deployCause B: the redirect URI isn't registered. Meta requires an exact match, and its dashboard sometimes appends a trailing slash. Register both:
https://ig-comment-dm.YOURNAME.workers.dev/connect/callback
https://ig-comment-dm.YOURNAME.workers.dev/connect/callback/
They go under Instagram → API setup with Instagram login → Business login settings → OAuth redirect URIs. If the screen you're on says Facebook Login → Valid OAuth Redirect URIs, that's the wrong product: stay on Instagram. The Connect page and the admin Accounts page both print the exact two lines.
Insufficient developer role
The Instagram account you're connecting hasn't accepted a tester invite. Being a Facebook Admin on the app isn't enough, and an invite for one account doesn't cover another.
For each Instagram account, both halves are required:
- Meta app → App roles → Roles → Add people → Instagram Tester. Enter the Instagram username, select it, and click Add.
- On a browser or phone logged into that same Instagram account, open instagram.com/accounts/manage_access → Tester invites → Accept.
Step 2 is the one people miss.
Also check which account the browser is logged into. When you click Connect, Instagram uses whatever account that browser is signed in as. One profile working and another failing is almost always this. Open a private window, log in to your admin page there, and click Connect from that window.
Deploy fails on the free plan
If the error mentions CPU limits, wrangler.toml has a [limits] / cpu_ms block. That setting is paid-plan only and the deploy fails outright on Free. Remove it.
If the error says the account doesn't have Workers enabled, open dash.cloudflare.com, click into Workers once to activate it, then deploy again.
The admin page shows 404
Every path that isn't /a/<ADMIN_URL_SECRET> returns 404 by design, so the panel can't be found by crawling. A 404 means the secret in your URL doesn't match the deployed one.
Usually it's a typo, a character lost on paste, a trailing slash, or a secret rotated by a later npm run setup -- --rotate. It can also be a URL printed by an npm run setup that stopped before the end: the next run replaces it and prints the new one.
Check what's deployed:
npx wrangler secret listThat confirms ADMIN_URL_SECRET exists, but not its value: Cloudflare never shows a secret again after it's set. If you've lost it, set a new one:
npm run keys # copy the ADMIN_URL_SECRET line
npx wrangler secret put ADMIN_URL_SECRET
npx wrangler deployYour password and all your data are unaffected. Only the URL changes.
Locked out of the admin page
Too many wrong passwords
Five wrong passwords are free. The sixth starts a one-minute wait, and each wrong password after a wait ends doubles the next one, up to an hour. Attempts made during a wait are refused without being checked, and don't make it longer. Waiting it out is the intended fix.
The wait covers the whole panel, not one browser or network, so a phone on mobile data is locked out too. If the wrong passwords weren't yours, someone else has your admin URL. Set a new ADMIN_URL_SECRET to shut them out, then clear the wait as below.
To clear it immediately:
npx wrangler d1 execute ig-comment-dm --remote --command \
"UPDATE system SET value = '0' WHERE key IN ('login_fail_count','login_locked_until');"Forgotten password
There's no password reset email. Anyone with access to the Cloudflare account can clear it:
npx wrangler d1 execute ig-comment-dm --remote --command \
"DELETE FROM system WHERE key IN ('admin_password_hash','admin_password_salt');"Then open your admin URL and create a new password. Connected accounts and rules are untouched. Any browser that was still logged in, yours or anyone else's, is logged out.
Creating the first password returns a 500
There are two causes, and the error page names which.
"A Worker secret is the wrong size." TOKEN_ENCRYPTION_KEY and SESSION_SIGNING_KEY must each decode to exactly 32 bytes. A truncated paste looks fine and fails here.
npm run keys
npx wrangler secret put TOKEN_ENCRYPTION_KEY
npx wrangler secret put SESSION_SIGNING_KEY
npx wrangler deploy"The database tables are missing." The migrations never ran.
npm run db:migrateErrors mention "no such column" after an update
The new code went live without its database migrations, usually because it was deployed with npx wrangler deploy instead of npm run deploy (which npm run update uses). Apply them:
npm run db:migrate:list # shows the ones that are missing
npm run db:migrateThe DM failed
The Last 20 sends row shows the exact error from Meta. When the DM fails, the public reply isn't posted either, because a reply like "Sent you a DM!" would be false; the row says so. A public reply can still fail on its own after a DM went through.
May have been delivered means Instagram got the request but its answer never arrived in a usable form: it didn't answer within 10 seconds, the connection dropped, or it answered with a server error. Instagram may have sent the DM anyway, so the Worker never sends it again by itself: a second try could reach the same person twice. Check the conversation in the Instagram app, or ask the commenter. If it didn't arrive, press Send again on that row, and it goes out within a minute or two, with four fresh tries. A public reply in the same situation says "so it may have been posted", and isn't posted again; look under the comment.
Retrying means Instagram turned the DM down for now: the account was sending too fast (other apps on the same account count towards the same limit), or Instagram had a temporary problem. It's tried again after 1, 5 and 20 minutes, and the row says which try is next and when. After a fourth refusal it's Failed, "Instagram turned it down 4 times". When Instagram says the account is sending too fast, Home also notes that its DMs wait 15 minutes. A DM refused because the token stopped working stays Queued and doesn't use up a try: reconnect the account, and it goes out.
A comment from a webhook has 25 seconds for its DM and public reply, so when Instagram is slow its timeout can be shorter than 10 seconds. If the DM got through but used up that time, the public reply is recorded as failed with "no time was left to post it".
Common DM failures:
| Error mentions | What it means |
|---|---|
| a closed messaging window | The comment is older than 7 days |
| the reply was already sent | Instagram allows one private reply per comment, ever |
| permissions or scope | The token predates a scope change: click Reconnect |
| rate limit | Meta's cap on private replies. The Worker keeps under it and retries, so this means another app is sending from the same account too |
If the DM says it succeeded but the recipient sees nothing, have them check Message requests. Instagram files DMs there when the recipient doesn't follow you.
Sends say Queued
The account has made its 12 DM calls for this minute, Instagram's hourly limit spread evenly, and the comment is waiting its turn. Nothing needs doing: every minute, the Worker sends the oldest waiting comments first, about 700 an hour for one account, fewer each when several have DMs waiting. If it has said Queued for more than a few minutes on a quiet account, check that the minute trigger is registered (see the daily check hasn't run) and whether Home says Instagram asked the account to slow down. A queued row that says it's waiting for Instagram to be reconnected goes out once you reconnect the account. Pausing an account doesn't leave DMs queued: it skips them.
Expired means a comment waited until an hour before Instagram's 7-day limit and was given up on, without calling Instagram.
"The Worker stopped before this send finished, so it may have been delivered" means a send was cut off, by a deploy or Cloudflare stopping the Worker. Within 2 minutes it's recorded as May have been delivered and never sent again by itself, since the DM may already have arrived. Send again is there if you've checked it didn't.
The Sending table on Home shows how many wait, how old the oldest is, roughly how long they'll take, and how many DMs went out in the last hour; Instagram allows about 750. The time is rough: one Worker run a minute shares its sends between the accounts with DMs waiting.
Red banner: an account needs reconnecting
Instagram tokens last 60 days. A nightly job refreshes them, but a token that goes too long without a refresh dies permanently.
Fix: admin → Accounts → Reconnect. It takes about 20 seconds and nothing is lost: rules and history stay.
If this keeps happening, the nightly job isn't running. See the next section.
Red banner: the daily check hasn't run
The nightly token refresh hasn't succeeded in over 3 days, or a new deployment's first run, due at 03:00 UTC, is more than a day late. Left alone, tokens eventually expire and every account needs a manual reconnect.
Until that first run is due, Home shows a plain note instead, saying the check hasn't run yet and when to expect it. That needs nothing from you.
Confirm the cron triggers are registered:
npx wrangler deployments listwrangler.toml should contain:
[triggers]
crons = ["0 3 * * *", "* * * * *"]If it does, redeploy. Cron triggers are registered at deploy time, and a deploy from an older config can drop them.
npx wrangler deployTo run it right now, click Check connections now on the admin Home page, or wait for the next scheduled run.
/health answers 503
/health is the URL to give an uptime monitor (Using it → Get told when it stops working). It needs no login and returns only counts and timestamps, never a username, an account ID or a secret:
{
"ok": true,
"database": "ok",
"accounts": { "active": 2, "needs_reconnect": 0 },
"cron": "ok",
"poll": "ok",
"last_cron_ok_at": 1759320000,
"last_poll_ok_at": 1759406280
}200 means healthy and 503 means something needs fixing, so the monitor only has to check the status code. Open the URL in a browser to see which part failed:
| The body says | What it means | Fix |
|---|---|---|
"database": "error" | The Worker couldn't read D1, or the tables are missing. | Run npm run db:migrate. If the tables exist, check Cloudflare's status page and npx wrangler tail. |
"cron": "stale" | The nightly token refresh hasn't succeeded in over 3 days, or hasn't run at all in the 26 hours since /health was first checked. | See the daily check hasn't run. |
"poll": "stale" | The comment poll, which runs every fifth minute, hasn't finished in over an hour. | Usually the cron triggers were dropped. Redeploy with npx wrangler deploy. |
"starting" means a job hasn't run yet on a new deploy. It counts as healthy, but only until the job is overdue: a day and two hours for the nightly refresh, an hour for the poll. Press Check connections now on Home to run the nightly job straight away.
accounts.needs_reconnect above 0 doesn't make /health fail, because a monitor can't fix it. You fix it in the admin panel: see an account needs reconnecting.
Home shows a table of secrets needing attention
The Worker checks the shape of all eight secrets every time you open Home. The table names the secret, what's wrong with it, and the command that fixes it. It catches:
- a secret that isn't set at all
- a key that doesn't decode to 32 bytes
TOKEN_ENCRYPTION_KEYandSESSION_SIGNING_KEYset to the same value, which turns one leak into two- an
ADMIN_URL_SECRETshorter than 24 characters - a
PUBLIC_BASE_URLwith a trailing slash or withouthttps:// - a missing
FACEBOOK_APP_SECRET
Each of these would otherwise fail later, somewhere unrelated. Fix them, run npx wrangler deploy, and reload.
Reading the logs
npx wrangler tail shows the Worker's live logs, and two SQL queries show what Instagram sent and why a comment was skipped. They're in Using it → Logs.
Still stuck
Open an issue. The bug template asks for what makes a report solvable.
Redact before you paste. Replace your admin secret with /a/REDACTED, and never include an access token, an app secret, or the contents of .dev.vars. Worker log output and the SQL queries are safe.
Found a security problem? Don't open a public issue: see Security.