Skip to content

instagram-dm

Small building blocks for Instagram DM bots, built on Meta’s Instagram API with Instagram Login. It covers the plumbing, not the product: raw-body HMAC checks, typed event parsing, OAuth and token refresh, a Send API client that respects the 24-hour window, and a fake Graph API so you can test all of it without touching Meta. It contains no LLM, no prompts, no storage and no framework; you plug a ConversationHandler into it.

The package was extracted from a private multi-tenant product (tenancy, prompts and anything vertical were left behind). It has been exercised against its own test suite and a fake Graph API; the Meta-facing endpoints and behaviors it encodes come from Meta’s public docs and manual testing, and Meta can change them. Re-check the current docs.

Terminal window
npm install @27colab/instagram-dm
You supplyUsed for
Meta app ID and App SecretWebhook signature checks, OAuth
A verify token you chooseThe webhook subscription handshake
A per-account long-lived access tokenThe Send API (you store it; the package never persists anything)

Meta setup, in order:

  1. Create an app at developers.facebook.com, type Business. Note the App ID and App Secret.
  2. Add the Instagram product and open “API setup with Instagram login”.
  3. Add the OAuth redirect URI you will use for buildAuthorizeUrl / exchangeCode.
  4. Under Webhooks, set the callback URL to your handler and the verify token to your verifyToken; subscribe to the messages field.
  5. Connect an account through your OAuth flow, then call subscribeToWebhooks. With Instagram Login the app-level callback alone delivers nothing; each account must be subscribed individually.
  6. Default permissions requested: instagram_business_basic, instagram_business_manage_messages.

While your app is in development mode you can only message accounts that have a role on the app (add them as Instagram Testers and accept the invite). Serving arbitrary businesses requires App Review for Advanced Access to those permissions and, typically, business verification.

Needs keys to run for real (typechecked here).

samples/src/instagram-webhook.ts
import { createWebhookHandler, InstagramClient } from '@27colab/instagram-dm';
// IG account id -> client. Load the access tokens from YOUR store.
const clients = new Map<string, InstagramClient>();
export const webhook = createWebhookHandler({
appSecret: process.env.META_APP_SECRET!,
verifyToken: process.env.META_VERIFY_TOKEN!,
resolveClient: (accountId) => clients.get(accountId),
conversation: {
async onMessage({ event }) {
if (event.storyReply) return 'Thanks for replying to our story!';
if (!event.text) return 'I can only read text for now.';
return `You said: ${event.text}`; // your logic (or an LLM call) goes here
},
},
});
// Next.js route handler (app/api/instagram/route.ts):
export const GET = webhook.fetch;
export const POST = webhook.fetch;

For Fastify or Express, pass the raw body, not re-serialised JSON:

// Express: app.post('/hook', express.raw({ type: '*/*' }), async (req, res) => {
const r = await webhook.handle({ method: req.method, query: req.query, headers: req.headers, rawBody: req.body });
res.status(r.status).set(r.headers).send(r.body);

The fake Graph API validates tokens, enforces the 24-hour window and can inject failures. This sample sends a signed webhook through the real handler, replays the same bytes, then tampers with the body.

samples/src/instagram-fake.ts
import { createWebhookHandler, InstagramClient } from '@27colab/instagram-dm';
import { FakeGraphApi, textEvent } from '@27colab/instagram-dm/testing';
const accountId = '1000000000001';
const userId = '2000000000002';
const fake = new FakeGraphApi({ appSecret: 'test' });
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}` },
});
// A user messaged us "just now", so the 24-hour window is open.
fake.recordInbound(accountId, userId);
const { rawBody, headers } = fake.buildWebhook(
[textEvent({ accountId, userId, mid: 'm1', text: 'hi' })],
accountId,
);
const ok = await webhook.handle({ method: 'POST', headers, rawBody });
console.log(ok.status, fake.sent.map((m) => m.text));
// Same bytes again (a redelivery): acknowledged, not answered twice.
await webhook.handle({ method: 'POST', headers, rawBody });
console.log('replies after redelivery:', fake.sent.filter((m) => m.text).length);
// Tampered body: rejected before your code runs.
const bad = await webhook.handle({ method: 'POST', headers, rawBody: rawBody.replace('hi', 'yo') });
console.log(bad.status);
output
200 [ 'You said: hi' ]
replies after redelivery: 1
401

The redelivery is acknowledged but not answered twice, and the tampered body is rejected with 401 before your handler runs.

AreaExports
Webhook securityverifySignature, verifyChallenge, signBody, InMemoryDedupe / DedupeStore
EventsparseWebhook → message (text, attachments, story reply, reply-to), echo, read, delivery, reaction, postback
WindowsevaluateWindow, WindowTracker; 24-hour standard and 7-day HUMAN_AGENT rules
OAuthbuildAuthorizeUrl, signState / verifyState, exchangeCode, exchangeLongLived, refreshLongLived, shouldRefresh, subscribeToWebhooks, fetchProfile
Send APIInstagramClient.sendText (chunking, window guard, retry), sendAction, InstagramApiError, WindowClosedError
HandlercreateWebhookHandler (handle plus a Web fetch adapter), ConversationHandler
Testing@27colab/instagram-dm/testing: FakeGraphApi, textEvent
  • Signature: constant-time check of X-Hub-Signature-256 over the raw bytes. Invalid → 401; your code never runs.
  • Replay: an HMAC cannot tell a fresh delivery from a redelivered one, so message ids are deduped (in memory by default; supply a shared DedupeStore for multiple instances).
  • Echo loops: messages your own account sent arrive as echo events and never reach onMessage.
  • Window: the client can refuse to send after 24 hours (WindowClosedError) before Meta does. humanAgent: true uses the 7-day HUMAN_AGENT tag, which is for humans replying, not bots, and needs that feature approved for your app.
  • Retry: network errors, 5xx and throttling back off exponentially and honor Retry-After. Client errors fail immediately. A retried send can duplicate a DM if the first attempt reached Meta; set retry: { maxRetries: 0 } for at-most-once.
  • Handler errors: logged via onError and still answered 200 so Meta does not retry-storm you.
  • Serverless: pass waitUntil to reply 200 immediately and process in the background.

Long-lived tokens last about 60 days. Refresh needs a token at least 24 hours old that has not expired; an expired token requires the user to re-authorize. Run this from a daily job:

if (shouldRefresh({ expiresAt: row.expiresAt })) {
const fresh = await refreshLongLived(oauthCfg, row.token);
await save(fresh.accessToken, fresh.expiresAt);
}
  • The account is not subscribed (step 5), or the messages field is not toggled on in the dashboard.
  • The account has “connected tools” access to messages switched off in Instagram’s message controls.
  • Your app is in development mode and the sender has no role on it.
  • Wrong appSecret (every event 401s) or unset verifyToken (the handshake 403s).
  • Only reply to people who messaged you, inside the window. Disclose automation where Meta’s terms or local law require it.
  • Delivery of the real endpoints is not tested in CI; the fake imitates only what this package uses.
  • Dedupe is in-memory by default and tokens are never stored by the package.