Instagram Messaging API: Receive Webhooks and Reply with Node.js and Python


The Instagram messaging API lets your server receive every new Instagram DM as a webhook and answer it with one POST request. This tutorial verifies signed webhook events, lists conversations and sends replies in Node.js and Python through bundle.social, whose conversation endpoints work the same way for Facebook Messenger on Pages.

Meta's Instagram Messaging API or bundle.social?

Meta's own Instagram Messaging API means running a Meta app on the Graph API, the same layer under Instagram publishing that our Instagram Graph API guide walks through. As of November 2026, an app that serves businesses you do not own needs Advanced Access for instagram_business_manage_messages or instagram_manage_messages, which requires Meta's App Review. You also store access tokens, subscribe each account to webhook fields, verify Meta's signatures and keep every event yourself.

With bundle.social, your customers connect accounts through its connect flow, and enabling messaging subscribes the account to Meta's webhooks. bundle.social checks the signature of each incoming Meta event and drops duplicate retries, then stores the event as a conversation tied to the account and its team. Your backend reads one normalized message shape for Instagram and Messenger. Meta's messaging rules still apply; bundle.social only takes over running the reviewed app.

Before you start

  • An Instagram Business or Creator account in a bundle.social team, with Allow access to messages turned on in the Instagram app. If you are not sure which type you have, Business vs Creator accounts covers which one fits.
  • A paid plan. Pro includes 200 sent DMs per connected account per month and Business 1,000; Free includes none.
  • Messaging access for your organization. Request it from the dashboard or at contact@bundle.social.
  • A reconnect of the account with messaging permissions (withMessagingScope: true on the connect request).
  • Environment variables BUNDLE_API_KEY, BUNDLE_TEAM_ID and BUNDLE_WEBHOOK_SECRET. The webhook secret is separate from the API key.

1. Enable messaging on the connected account

Connecting an account never starts DM processing on its own. Call:

POST https://api.bundle.social/api/v1/social-account/messaging/enable x-api-key: YOUR_API_KEY Content-Type: application/json 
{ "teamId": "YOUR_TEAM_ID", "type": "INSTAGRAM" } 

The response returns socialAccountId and messagingStatus: "ENABLED". A 403 means your organization does not have messaging access yet, a 400 means the account lacks messaging permissions and has to reconnect, and a 502 means Meta rejected the subscription.

Conversations are built from the DM webhooks bundle.social receives after this call. Messages already in the Instagram inbox are not imported, so the first thread appears with the next incoming DM. Send a test message from a second account to create it.

2. List conversations and find the right thread

curl --fail-with-body --get \ "https://api.bundle.social/api/v1/conversations/" \ -H "x-api-key: ${BUNDLE_API_KEY}" \ --data-urlencode "teamId=${BUNDLE_TEAM_ID}" \ --data-urlencode "platform=INSTAGRAM" \ --data-urlencode "limit=50" 

The response has items, a total on the first page only, and nextCursor. Pass nextCursor back as cursor to load the next page; limit defaults to 10 and goes up to 500. Use the conversation id, not a username, in every later request.

GET /api/v1/conversations/{id}/messages returns the history. Pages walk backwards in time while the messages inside each page are ordered oldest first, ready to render. Before you show a thread to an end user, check that the user may access the conversation's teamId.

White envelope closed with a red wax seal, a brass seal stamp with a dark wooden handle lying beside it on a light wooden table

Check the signature on the raw body before you trust anything inside it.

3. Receive and verify webhook events

Add a public HTTPS endpoint on the organization's Webhooks page in the bundle.social dashboard. Every delivery is a JSON body { "type": "...", "data": { ... } } with an x-signature header: the hex-encoded HMAC-SHA256 of the raw request body, keyed with your webhook secret. Verify it on the raw bytes, before parsing JSON, with a constant-time comparison. Answer with a 2xx within 15 seconds and do slow work afterwards.

Node.js, standard library only:

import http from "node:http"; import crypto from "node:crypto"; const secret = process.env.BUNDLE_WEBHOOK_SECRET; export function verifySignature(rawBody, signature, secret) { if (!/^[a-f0-9]{64}$/i.test(signature ?? "")) return false; const expected = crypto.createHmac("sha256", secret).update(rawBody).digest(); return crypto.timingSafeEqual(expected, Buffer.from(signature, "hex")); } http.createServer(async (req, res) => { if (req.method !== "POST" || req.url !== "/webhooks/bundle") return res.writeHead(404).end(); const chunks = []; for await (const chunk of req) chunks.push(chunk); const rawBody = Buffer.concat(chunks); if (!verifySignature(rawBody, String(req.headers["x-signature"] ?? ""), secret)) { return res.writeHead(401).end(); } const event = JSON.parse(rawBody.toString("utf8")); if (event.type === "conversation.message.created" && event.data?.direction === "INBOUND") { // Persist or enqueue the event here, then acknowledge. console.log("New DM", event.data.conversationId, event.data.text); } res.writeHead(200).end(); }).listen(3001, "127.0.0.1"); 

Python, standard library only:

import hashlib import hmac import json import os import re from http.server import BaseHTTPRequestHandler, HTTPServer SECRET = os.environ["BUNDLE_WEBHOOK_SECRET"].encode() def verify_signature(raw_body: bytes, signature: str) -> bool: if not re.fullmatch(r"[0-9a-fA-F]{64}", signature or ""): return False expected = hmac.new(SECRET, raw_body, hashlib.sha256).digest() return hmac.compare_digest(expected, bytes.fromhex(signature)) class Handler(BaseHTTPRequestHandler): def do_POST(self): raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0"))) if not verify_signature(raw_body, self.headers.get("x-signature", "")): self.send_response(401) self.end_headers() return event = json.loads(raw_body) data = event.get("data") or {} if event.get("type") == "conversation.message.created" and data.get("direction") == "INBOUND": # Persist or enqueue the event here, then acknowledge.  print("New DM", data.get("conversationId"), data.get("text")) self.send_response(200) self.end_headers() HTTPServer(("127.0.0.1", 3001), Handler).serve_forever() 

The messaging events are conversation.created, conversation.updated, conversation.read.updated, conversation.message.created and conversation.message.updated. A new-message event fires for outbound messages too, with source set to API for your sends, PRIVATE_REPLY for replies to comments and ECHO for replies typed in the Instagram app, so the receivers above filter on INBOUND.

In production, persist the event or a durable job before you acknowledge, and deduplicate on event type, data.id and data.updatedAt. A status change arrives as conversation.message.updated and should update the stored message, never trigger another reply. The downloadable messaging examples add a CLI, an event log and local test instructions.

4. Send a reply with Node.js

Read the conversation first and check its team. Set BUNDLE_CONVERSATION_ID to the thread you intend to answer:

const base = "https://api.bundle.social/api/v1"; const headers = { "x-api-key": process.env.BUNDLE_API_KEY }; const id = process.env.BUNDLE_CONVERSATION_ID; const threadResponse = await fetch( `${base}/conversations/${encodeURIComponent(id)}`, { headers } ); if (!threadResponse.ok) throw new Error(`Read failed: ${threadResponse.status}`); const thread = await threadResponse.json(); if (thread.teamId !== process.env.BUNDLE_TEAM_ID) throw new Error("Wrong team"); const response = await fetch( `${base}/conversations/${encodeURIComponent(id)}/messages`, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ content: { text: "Thanks for your question. How can we help?" } }) } ); const result = await response.json(); if (!response.ok) throw new Error(result.message ?? `HTTP ${response.status}`); console.log(result.message?.id, result.message?.status); 

To thread the reply under a specific incoming message, add replyToMessageId with that message's ID. The same body also accepts quick replies, buttons, attachments or a carousel.

Request messaging access for your organization before the first send. The API key in these examples also publishes posts and reads comments, so DMs do not need a second integration.

Verify one signature, call one endpoint, and skip App Review for Instagram and Messenger DMs.

One conversation model, message webhooks and a single reply endpoint for Instagram and Facebook Pages.

See the DM API

5. Send the same reply with Python

import json import os from urllib.request import Request, urlopen from urllib.parse import quote base = "https://api.bundle.social/api/v1" conversation_id = quote(os.environ["BUNDLE_CONVERSATION_ID"], safe="") headers = {"x-api-key": os.environ["BUNDLE_API_KEY"]} with urlopen(Request(f"{base}/conversations/{conversation_id}", headers=headers), timeout=20) as r: thread = json.load(r) if thread["teamId"] != os.environ["BUNDLE_TEAM_ID"]: raise ValueError("Wrong team") body = json.dumps({"content": {"text": "Thanks for your question. How can we help?"}}).encode() request = Request( f"{base}/conversations/{conversation_id}/messages", data=body, headers={**headers, "Content-Type": "application/json"}, method="POST", ) with urlopen(request, timeout=20) as r: result = json.load(r) print(result["message"]["id"], result["message"]["status"]) 

Wrap the send in a try block for HTTPError and show the API's message field to the operator instead of retrying a failed send in a loop.

Glass hourglass on a dark surface, a thin stream of pale sand falling from the upper bulb onto a small pile in the lower bulb

Meta accepts a free-form reply only within 24 hours of the participant's last message.

6. Handle the reply window and errors

Meta accepts a free-form reply only within 24 hours of the participant's last message, and platformData.lastInboundAt lets your interface show the time left. platformData.canReply: false marks a conversation opened by a private reply to a comment, where the participant has not written yet; how to automate comment-to-DM shows how those private replies get sent. The server checks both again at send time:

Situation Status API message
More than 24 hours since the last inbound message 400 "Meta only allows replies within 24 hours of the participant's last message"
Private reply sent, participant has not answered 400 "This conversation is waiting for the participant to reply first"
Messaging not enabled for the account 403 "Messaging is not enabled for this social account. Enable it first."
Monthly DM allowance used up 403 "Monthly message limit reached..."
Sending temporarily paused on bundle.social's side 503 "Messaging is temporarily disabled. Please try again in a few minutes."

Outbound messages move through PENDING, SENT, DELIVERED, SEEN and FAILED, and later changes arrive as conversation.message.updated. A SENT status is not a read receipt. Regular sends are not deduplicated, so if a request times out, list the conversation's messages before you retry. bundle.social keeps messages for 30 days; store anything you need longer in your own database.

To put a shared agent interface on top of these endpoints, continue with social media inbox management. To automate the first answer, read Instagram DM automation; the Instagram auto reply API page lists what can start a flow: comments, incoming DMs and taps on quick replies or buttons.

For the product overview, permissions and the full webhook payload, see the Instagram DM API and social media DM API pages and the DM webhook reference.

Frequently asked questions

Can I send the first DM to someone through the Instagram messaging API?

No. Meta lets a business message a person only after that person writes to the account, and bundle.social enforces the same rule. A comment allows one private reply instead. After the person answers, you can reply freely for 24 hours from their latest message, and every new message from them restarts that window.

Do I need my own Meta app and App Review?

Not with bundle.social. Your customers authorize their accounts through bundle.social's connect flow, so you skip registering a Meta app, App Review for messaging permissions and webhook subscription management. You still follow Meta's messaging rules, including the 24-hour reply window and the requirement that people message the account first.

Are past Instagram DMs imported when I enable messaging?

No. bundle.social builds conversations from the webhooks it receives after messaging is enabled for the account, so older inbox history stays in Instagram. Stored messages are kept for 30 days. If your product needs a longer history, save the webhook events in your own database as they arrive.

Does the same code work for Facebook Messenger?

Yes. Facebook Page conversations use the same endpoints, webhook events and message shape, with platform set to FACEBOOK. Enable messaging with type: "FACEBOOK" for the Page. Some formats differ per platform: phone buttons work on Messenger and are rejected on Instagram, and Messenger has no API for sending reactions.