I build Reachlee, a tool that sends someone a DM when they comment a keyword on an Instagram post. It handles thousands of comments a day across a few hundred Instagram accounts.

Most of our worst bugs came from Meta's side of the API: rules that get one line in the docs, error codes that aren't in the error reference at all, and an in-app browser with opinions. These are the nine I wish someone had told me before I started. Each one comes with the code or the fix we ended up with.

1. You get exactly one message per comment

When someone comments on a post, you can send them a private reply: a DM addressed to the comment instead of the person.

POST https://graph.instagram.com/v21.0/me/messages { "recipient": { "comment_id": "<COMMENT_ID>" }, "message": { "text": "Here's the link you asked for 👇" } } 

The rules:

  • One private reply per comment. Ever.
  • It has to go out within 7 days of the comment.
  • You can't message anyone first. A comment, a DM, a story reply or a story mention from them is what lets you write back.

"One ever" sounds easy until you have retries, two automations matching the same comment, or a worker that crashes halfway through a send. We claim the comment in the database before calling Meta, so only one code path can ever use it:

CREATE TABLE replied_comments ( comment_id text PRIMARY KEY, claimed_at timestamptz NOT NULL DEFAULT now() ); 
const { rowCount } = await db.query( `INSERT INTO replied_comments (comment_id) VALUES ($1) ON CONFLICT DO NOTHING`, [commentId], ); if (rowCount === 0) return; // this comment's one reply is already taken await sendPrivateReply(commentId, message); 

The corollary nobody mentions: a failed first attempt can burn the comment. "Send the fancy version, fall back to plain text if it fails" looks like good engineering. When we audited a day of sends, every plain-text fallback that followed a rejected private reply also failed. Decide what the one message is before you send it.

2. The private reply doesn't keep the conversation open

After the private reply, you can't keep messaging that person until they message you back: a reply, or a tap on a button you sent. That opens Meta's standard 24-hour window, and every message after that has to land inside it.

So the useful pattern is to make the first message ask for something: "Tap below and I'll send it" or "Reply YES". Their tap is what lets you send the actual content.

Outside the 24-hour window you need a message tag, and the one that applies to Instagram (HUMAN_AGENT) needs Meta's approval and is meant for humans, not automations.

3. Your own comments never fire the webhook

The most common "it doesn't work" ticket we get has nothing to do with code. The person tests their automation by commenting on their own post from their own account.

Instagram doesn't send a comments webhook for the account owner's comments on their own media, so nothing happens and it looks broken. Test from a second account.

Close behind: comments that Instagram hides as spam don't fire the webhook either. New accounts that post the same keyword over and over get filtered, and no tool ever sees those comments.

4. Error 2534037: "not the thread owner"

This one isn't in Meta's error reference, and it caused us the most grief.

The symptom is very specific: the first private reply lands, and every DM after it fails, even a harmless mark_seen:

{ "error": { "message": "The action is invalid since it's not the thread owner.", "error_subcode": 2534037 } } 

The cause: when an Instagram account is linked to a Facebook Page, the Page's inbox owns the conversations by default. Every third-party app is refused until the Page hands it control. It hits any tool built on the API, not only yours.

The fix is a toggle on the customer's side:

Facebook → the linked Page → Settings → Advanced messaging → Connected apps → your app → Take control of conversations → On

It takes effect within minutes, with no code change. (I wrote up the full walkthrough here, since we now send it to every customer who hits this.)

One more trap. If they also enable "Access standby channel", Meta starts sending events for threads your app doesn't own under entry[].standby instead of entry[].messaging. Same shape, different key. We ignored that array, and for a while button taps on those threads silently did nothing:

for (const entry of body.entry) { const events = [...(entry.messaging ?? []), ...(entry.standby ?? [])]; for (const event of events) handleMessagingEvent(entry.id, event); } 

5. Not every error is a rate limit

Meta reuses top-level error codes for very different situations. Code 10 might mean "this one person can't be messaged" or something about your whole account. You have to look at error_subcode.

We learned this the expensive way. Our circuit breaker paused an account after 5 errors in 10 minutes, and it counted error code 2 / subcode 1545133 ("You cannot send media to unless they follow you") as a throttle. That error is about one recipient. New accounts with lots of non-follower commenters tripped the breaker within minutes, and we paused their entire account over a handful of individual refusals.

This is roughly the split we use now:

// One person can't be messaged: skip them, carry on for everyone else. const RECIPIENT_REFUSALS = new Set([ 1545133, // "You cannot send media to <user> unless they follow you" 2534022, // "outside of allowed window" on a FIRST reply = their privacy settings block message requests 2534023, // this comment already got its one reply 2534025, // comment can't be replied to (older than 7 days, deleted, …) 2534001, // they archived or deleted the thread 2534014, // user not found ]); // The account itself is being limited: back off the whole account. const ACCOUNT_THROTTLES = new Set([4, 17, 32, 613, 80002, 80006]); export function classify(err: { code: number; error_subcode?: number }) { if (err.error_subcode === 2534037) return "thread_owned_by_page_inbox"; if (err.error_subcode && RECIPIENT_REFUSALS.has(err.error_subcode)) return "skip_recipient"; if (err.code === 368) return "policy_block"; // no retry-after; pause for hours, not minutes if (ACCOUNT_THROTTLES.has(err.code)) return "back_off_account"; return "unknown_alert_me"; } 

Record the per-recipient ones as skipped, not failed. Your dashboards and your customers will both thank you.

One related signal: a single 2534022 is one person with locked-down privacy settings. If most of an account's sends suddenly return 2534022, it's Instagram restricting that account's DMs. Across our accounts the normal refusal rate is around 12%, and restricted accounts sit at 60–90%.

6. Instagram's in-app browser hates redirects

A customer's link worked in Safari and Chrome. Tapped from a DM, it opened Instagram's in-app browser and showed:

There was a problem loading this website

The URL was reachlee.co, which 308-redirects to www.reachlee.co. Instagram's in-app browser often fails on that kind of first-hop redirect (apex → www, http → https) on a cold load. Meta also wants absolute https:// URLs for web_url buttons.

So we resolve every link server-side before it goes into a message:

export async function resolveFinalUrl(raw: string): Promise<string> { const url = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; const res = await fetch(url, { method: "HEAD", redirect: "follow" }); return res.url; // the address after every redirect } 

People paste whatever they paste, and it just works.

7. Verify the webhook signature against the raw bytes

Meta signs every webhook with X-Hub-Signature-256, an HMAC-SHA256 of the exact request body. If you let your framework parse JSON first and then re-serialize it, the bytes differ and every signature check fails.

In Express, keep the raw buffer while parsing:

import crypto from "node:crypto"; import express from "express"; const app = express(); app.use(express.json({ verify: (req, _res, buf) => { (req as any).rawBody = buf; }, })); app.post("/webhooks/instagram", (req, res) => { const received = req.get("x-hub-signature-256") ?? ""; const expected = "sha256=" + crypto .createHmac("sha256", process.env.IG_APP_SECRET!) .update((req as any).rawBody) .digest("hex"); const valid = received.length === expected.length && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected)); if (!valid) return res.sendStatus(401); queue.add("instagram-event", req.body); // the real work happens in a worker res.sendStatus(200); }); 

Two details that cost us time:

  • Answer fast. Verify, enqueue, return 200. Do the Graph API calls in a worker. Slow responses get retried and you process events twice.
  • Use the right secret. With Instagram Login, the webhook signature uses the Instagram app secret. Other flows on the Facebook side (like signed_request for data-deletion callbacks) use the Facebook app secret. Same algorithm, different keys.

8. Read the usage headers, not just the errors

Meta tells you how close you are to being throttled on every response, in a header:

const header = res.headers.get("x-business-use-case-usage"); const usage = header ? JSON.parse(header) : {}; for (const buckets of Object.values(usage) as any[]) { for (const b of buckets) { // call_count, total_time, total_cputime are percentages of your allowance if (b.estimated_time_to_regain_access > 0) { pauseAccount(b.estimated_time_to_regain_access); // in MINUTES } } } 

We start deferring an account's sends once any of those percentages reaches 80%, so we slow down before Meta makes us stop. Private replies have their own cap too: 750 per hour per Instagram account. A viral reel with thousands of comments will hit it, so queue the overflow instead of dropping it.

9. Long-lived tokens die quietly after 60 days

Instagram long-lived tokens last 60 days. If you don't refresh them, they expire and every call for that account starts failing, with no warning beforehand.

Refresh on a schedule well before the deadline (we aim for day 50), and treat a dead token as a state the customer can see ("reconnect your account"), not just a log line. We once had an account whose token had been dead for 11 days. We kept trying to send for it about 189 times a day, because nothing stopped sending once the account was flagged.

The short version

  1. One private reply per comment, within 7 days. Claim it before you send.
  2. The conversation only opens when they reply or tap.
  3. Test from a second account.
  4. 2534037 means the Facebook Page inbox owns the thread. Flip the toggle.
  5. Classify errors by subcode. Per-recipient refusals aren't rate limits.
  6. Resolve redirects server-side before sending links.
  7. Verify signatures on the raw body, answer in milliseconds, use the right secret.
  8. Watch X-Business-Use-Case-Usage and back off before you're blocked.
  9. Refresh tokens before day 60, and show it when they die.

If you're building on Meta's messaging APIs, which of these have you run into? I'd especially like to hear from anyone who's found a cleaner way to handle thread ownership.

I'm building Reachlee: social media automation for creators and businesses, on Instagram and Facebook, as a Meta-verified Tech Provider. Everything above came out of running it in production.