Skip to content

All posts

Build an AI front desk, part 4 · 6 min read

Everything was green, and Instagram sent me nothing

Building a DM bot against Meta's API, three gates between a message and your server, why a valid signature isn't proof of anything fresh, and two clocks that run out quietly.

By Jesus Vasquez · Updated

Source code: github.com/27colab/instagram-dm · Documentation

On this page

Key takeaways

  • The app-level webhook URL is not enough. Each connected account has to be subscribed to the app with its own call, or it is quietly deaf.
  • Verify the signature over the exact bytes Meta sent, in constant time, with the Instagram app's own secret. Re-serialised JSON fails every time.
  • A valid signature proves who sent a body, not that it is new. Deduplicate by message id and drop your own echoes before the handler runs.
  • Two clocks fail silently, the 24-hour messaging window and the long-lived token's refresh window. Encode both so they fail locally, before Meta.
  • Use only the official API. Driving an account with unofficial libraries is how businesses get banned.

I was adding an Instagram channel to a multi-tenant AI receptionist platform I'm building, Andii, so a business's assistant could answer DMs the way it answers phone calls. By the end of one evening I had done everything right. The app existed. The webhook URL was saved, and Meta's handshake against it succeeded. The business account was connected through the sign-in flow. The tester invites were accepted.

I sent a DM from a second account. Nothing. I asked how long a reply should take, and sent another. Still nothing. My server's logs were empty: no bad signature, no unknown account, no error of any kind. I could see network traffic reaching the gateway, but none of it was a message. It wasn't failing. It was silent.

  1. Someone sends a DM to the business account on Instagram.
  2. The app's webhook URL. Saved in the Meta dashboard and verified with the handshake.Verified
  3. This account is subscribed to the app. A separate API call per connected account: POST /me/subscribed_apps.Missing: my connect flow never made this call
  4. Development-mode permissions. Both accounts have a role on the app, and the account allows connected tools to receive messages.Fine here, but easy to miss
  5. Your server receives the signed webhook.
Every setting I could see was green, the sign-in worked, the handshake worked, and still no DM ever arrived. The failure produced no error anywhere.

The cause was a second gate I didn't know existed. With Instagram Login, saving a callback URL on the app isn't enough: Meta delivers message webhooks only for accounts that have been individually subscribed to the app, through a separate call per account. My connect flow completed the sign-in and never made that call, so every account I connected was quietly deaf.

The fix was one extra request at connect time, and a decision about what to do when it fails. A connect that half-works is worse than one that fails, so if the subscription call doesn't succeed, the flow now says exactly that, "connected, but enabling DM webhooks failed, retry", instead of reporting success. A daily job re-asserts the subscription for every account, so one that fell out of it heals itself. In the library this became a single idempotent function whose documentation opens with the lesson: the app-level URL is not enough.

Trust the bytes, not the JSON

Every webhook Meta sends is signed. The header carries a hash of the request body computed with your app secret, and your job is to recompute it and compare. The catch is the word body. It has to be the exact bytes Meta sent. If your framework parses the JSON and you stringify it again, whitespace and escaping change, and every signature fails. My gateway needed explicit raw-body capture before a single message verified.

The comparison itself is done in constant time, after checking the header has the right shape, so a forged signature can't be guessed a character at a time. Here is a second trap that costs an afternoon: the Instagram app has its own id and secret, separate from the main Meta app's. Use the wrong pair and every event is rejected as a bad signature, which looks exactly like an attack.

A valid signature isn't a fresh message

A signature proves who sent a body. It doesn't prove the body is new. Meta can redeliver the same event, and anyone who captures a signed body can replay it. So each message is deduplicated by its message id before your code sees it: a repeat is acknowledged, so Meta stops retrying, but never answered twice.

The other loop to close is your own voice. Messages your account sends come back to you as events marked as echoes. A bot that answers every inbound message will, left alone, answer itself until something breaks. In the library, echoes are parsed as their own kind of event and never reach the conversation handler.

  1. Receive the raw bytes. Exactly what Meta sent. Re-serializing parsed JSON changes whitespace and breaks the signature.
  2. Verify X-Hub-Signature-256. An HMAC of the raw body with your app secret, compared in constant time.wrong or missing: 401, your code never runs
  3. Parse the JSON. Into typed events: message, echo, read, delivery, reaction, postback.malformed: 400
  4. Drop echoes. Messages your own account sent come back as events. Answering them makes the bot talk to itself forever.
  5. Deduplicate by message id. A valid signature proves who sent it, not that it is fresh. Meta redelivers, and anyone can replay a captured body.seen before: acknowledged, not answered twice
  6. Your conversation handler. Plain code or a language model. The library has no opinion.
  7. Check the 24-hour window. Before any network call, so a closed window is a local error, not a rejected request.closed: WindowClosedError
  8. Send, in chunks of 900 characters or fewer. POST /me/messages, retried with backoff on throttling and server errors, honoring Retry-After.
  9. Answer 200. Or, in serverless, answer immediately and finish the work in the background.
One inbound webhook, top to bottom. The dashed notes are the ways it can stop early.

Two clocks you don't control

Two timers run against a bot like this, and both fail silently if you ignore them.

The first is the messaging window. As Meta documented it when I wrote the library, a business may message someone for 24 hours after that person's last message. A real human following up can extend that to seven days with a special tag, which is not for bots. The library tracks the last inbound message and refuses to send outside the window before making any network call, so a closed window is a clear local error instead of a rejected request.

The second is your access to the account. The sign-in flow gives you a short-lived token, good for about an hour, which you exchange for a long-lived one that lasts about 60 days. The long-lived token can be refreshed, but only once it is a day old and before it expires. Miss the window and the owner has to sign in again. The library encodes those rules, so refreshing too early fails before it reaches Meta.

Clock 1: how long you may message someoneopen: 24 hours from their last messageup to 7 days: a real person onlyrefused locallytheir last message▲Clock 2: how long your access to the account lasts~1 hourshort-lived tokenlong-lived token: about 60 daysrefresh window: after day 1, inside the last 14 daysexpired: the owner must sign in againNot to scale. The library encodes both clocks, so a closed window or a too-early refresh fails before it reaches Meta.
Illustration of rules as documented by Meta when the library was written. Re-check Meta's current documentation before relying on the numbers.

Retries, and the duplicate you can't prevent

Sends are retried on network errors, on server errors, and on the throttling responses Meta uses, with exponential backoff, jitter, and respect for a Retry-After header. Authorization errors are not retried, because retrying can't fix them. There is one honest caveat: if the first attempt reached Meta and only the response was lost, a retry can send the same DM twice. If you would rather risk a missed message than a duplicate, turn retries off.

Testing without Meta

You can't unit-test against a platform that needs an approved app and a tester account, so the library ships a fake Graph API. It enforces the same rules the real one does: invalid or expired tokens, the 24-hour and 7-day windows, the refresh-age rule, the length limit. It can inject failures, such as a throttle with a Retry-After, and it signs the webhooks it builds with the same hash. That is how the whole loop below runs offline, including the part where a tampered body is rejected before your code runs.

import { createWebhookHandler, InstagramClient } from "@27colab/instagram-dm";
import { FakeGraphApi, textEvent } from "@27colab/instagram-dm/testing";

const fake = new FakeGraphApi({ appSecret: "test" }); // stands in for Meta's Graph API
const client = new InstagramClient({ accessToken: fake.issueToken(accountId), fetch: fake.fetch });
const webhook = createWebhookHandler({
  appSecret: "test",
  verifyToken: "verify-me",
  resolveClient: () => client,
  conversation: { onMessage: ({ event }) => `You said: ${event.text}` }, // your logic or LLM
});

const { rawBody, headers } = fake.buildWebhook(
  [textEvent({ accountId, userId, mid: "m1", text: "hi" })],
  accountId,
);
await webhook.handle({ method: "POST", headers, rawBody }); // 200, one reply
await webhook.handle({ method: "POST", headers, rawBody }); // redelivery: acknowledged, not answered twice
await webhook.handle({ method: "POST", headers, rawBody: rawBody.replace("hi", "yo") }); // 401: bad signature
From the repository quickstart (trimmed): a signed webhook is answered, a redelivery is not, a tampered body is rejected.

What I have and haven't proven

  • The only live evidence is a single end-to-end conversation in development mode, with a test account and tester roles. That covers the handshake, a signed delivery and a send.
  • Retries on real throttling, token refresh in production and the seven-day tag have not been exercised against the live platform. The fake encodes my understanding of Meta's rules, so re-check Meta's current documentation.
  • The app hasn't been through Meta's review for advanced access, and this project makes no claim of approval. It is not affiliated with or endorsed by Meta.
  • It uses only the official API. Driving an account with unofficial libraries is how businesses get banned, and I wouldn't put a customer's account at that risk.
  • It is a set of building blocks, not a chatbot framework or a hosted service. The conversation logic, storage and per-account tokens are yours.

I built this with Claude as my pair, in the browser chat and later in Claude Code. I wrote the rules, tested against a real account, and decided what shipped.

What's in the library

Webhook verification over the raw body, event parsing with dedupe and echo filtering, window evaluation, a send client with retries, token helpers, and a fake Graph API for tests. Nothing here needs a Meta app to run the tests.

import { evaluateWindow } from "@27colab/instagram-dm";

const w = evaluateWindow(lastInboundAt, { humanAgent: false });
if (!w.open) {
  // refuse to send; w.expiresAt says when it closed
}

You bring a Meta developer app with the Instagram product, business verification, and app review for the messaging permissions (weeks, not hours: start first); the per-account webhook subscription call at connect time; raw-body capture in your web framework; storage for per-account tokens and a job that refreshes them inside Meta's window; and the conversation logic.

Get it

MIT-licensed, early (v0.1), written with Claude Code and directed and reviewed by me.

git clone https://github.com/27colab/instagram-dm.git
cd instagram-dm && npm ci && npm test

Who wrote this

  • Jesus Vasquez · Engineering · fractional CTO

    Builds it: WordPress and Next.js, integrations and AI agents, with the tests, analytics and accessibility that keep it honest.

Sources

  1. github.com/27colab/instagram-dm
  2. docs.27colab.com/packages/instagram-dm/
  3. theuxui.dev/open-source/instagram-dm/
  4. developers.facebook.com/docs/messenger-platform/instagram/features/webhook
  5. developers.facebook.com/docs/messenger-platform/policy/policy-overview

Want this done for your company?

Tell us what’s going on. You get a written brief back, and both of us read it.

Talk to us about an AI front desk

We’d like to measure how people use this site with Google Analytics, Microsoft Clarity, and our own visit log, and whether our ads lead to calls. Nothing loads unless you allow it. Privacy policy