There are exactly two ways an Instagram conversation begins. The contact messages the account, or you answer a comment of theirs with a private reply. There is no third way, there is no "message this username" call, and no vendor can sell you one. Once a thread exists it stays open for 24 hours after the contact last wrote. Everything else about building on the Instagram DM API is downstream of those two facts.
If you have built against a publishing API before, this inverts the model: mostly you do not decide when to send, the contact does, and you get a clock. Design for the clock and the rest is small - seven endpoints and five webhooks behind one permission that is not granted by default. This is the manual; for the short version of what shipped, read Instagram DMs are now in the Outstand API.
What the surface is, and what it is not
The whole Conversations API is seven endpoints:
Endpoint | What it does |
|---|---|
| List conversations across your connected accounts |
| Answer a comment with a private reply, starting a thread |
| One conversation, with the contact and unread count |
| The messages in a thread, newest first |
| Send a reply, now or scheduled |
| Cancel a scheduled reply |
| Mark read, and send a read receipt |
POST /v1/conversations is the newest of those and the only one that does not need an existing thread. It answers a comment with a direct message to its author, which is Meta's private replies feature, and it is covered in full its own section below. It is narrow on purpose: one message per comment, within seven days of the comment, and nothing further until the contact answers.
What is still absent is the general case. There is no call that messages an arbitrary Instagram user, because Instagram does not allow a business to open a thread with someone who has neither written to the account nor commented on its posts. That is Meta's rule, and it is the rule the rest of this page is shaped around.
There is also no history import: only messages arriving after the account is connected with messaging permission exist, so your inbox is empty on day one and fills as people write in. And messaging is Instagram only today; support for publishing on another Outstand network does not imply support for DMs on it.
Connecting an account, and the two traps
Messaging needs the instagram_business_manage_messages permission, and Outstand does not request it by default. You ask for it by passing scopes to the authentication URL call:
1curl -X POST https://api.outstand.so/v1/social-networks/instagram/auth-url \
2 -H "Authorization: Bearer YOUR_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "redirect_uri": "https://your-app.example.com/social/callback",
6 "scopes": "instagram_business_basic,instagram_business_content_publish,instagram_business_manage_comments,instagram_business_manage_insights,instagram_business_manage_messages"
7 }'Trap one: scopes replaces, it does not add
That list looks redundant. It is not. scopes replaces Instagram's defaults rather than extending them, so every permission the account still needs has to be in the string you send.
Scope | Needed for |
|---|---|
| Profile access. Always required. |
| Posts, Reels, carousels, Stories |
| Reading and replying to comments |
| Post and account analytics |
| Direct Messages. Not requested by default. |
Send instagram_business_basic,instagram_business_manage_messages and you have built a messaging-only connection. Publishing, comments and insights stop working on that account, and nothing tells you: the next publish just fails. Copy the five-scope string, do not hand-assemble it.
Trap two: reconnecting from the dashboard removes messaging
Every account connected before messaging existed, and every account connected from the Outstand dashboard, has to be reconnected once through the auth-url call above. Reconnecting updates the social account in place, so its ID, scheduled posts and history survive.
Until it is reconnected, Outstand is not subscribed to that account's messages. GET /v1/conversations returns nothing for it, and no conversation.started or message.received fires. There is no error. The account is simply silent, which is the hardest failure here to diagnose, because a silent inbox looks exactly like an inbox nobody has written to.
The dashboard requests the default scopes only, so reconnecting there does not enable messaging, and reconnecting a messaging-enabled account there takes messaging away again, as does any scopes string omitting the permission. Once messaging is on, the dashboard reconnect button is a loaded gun.
Two account-side requirements finish the checklist, neither fixable from code: the account must be Business or Creator, because Meta's messaging API has no access to personal accounts at all, and the owner must turn on Settings, Messages and story replies, Message controls, Connected tools, Allow access to messages in the Instagram app. Put both in your onboarding copy; the Instagram configuration guide has the rest, and long-lived Instagram page access tokens covers how the credential underneath all of this is issued and refreshed.
Receiving an inbound message
Have someone DM the connected account, then list:
1curl 'https://api.outstand.so/v1/conversations?social_account_id=YOUR_SOCIAL_ACCOUNT_ID&limit=25' \
2 -H "Authorization: Bearer YOUR_API_KEY"1{
2 "success": true,
3 "data": [
4 {
5 "id": "9dyJS",
6 "socialAccountId": "YOUR_SOCIAL_ACCOUNT_ID",
7 "network": "instagram",
8 "participantId": "17841400000000001",
9 "participantDisplayName": "Jamie",
10 "lastMessageAt": "2026-09-21T10:00:00.000Z",
11 "lastInboundAt": "2026-09-21T10:00:00.000Z",
12 "unreadCount": 1,
13 "status": "active"
14 }
15 ],
16 "pagination": { "hasMore": false, "limit": 25 }
17}lastInboundAt is the field your product revolves around. It starts the clock, and it is the only input to whether your next send is legal.
An empty data array on an account you believe is connected means trap two. Check the scopes, reconnect, send a fresh DM.
Reading a thread, and the pagination contract
1curl 'https://api.outstand.so/v1/conversations/9dyJS/messages?limit=50&direction=inbound' \
2 -H "Authorization: Bearer YOUR_API_KEY"Both list endpoints return data plus pagination. While pagination.hasMore is true, pass pagination.nextCursor back as cursor and keep your filters. Treat the cursor as opaque.
Default | Maximum | Order | Filters | |
|---|---|---|---|---|
Conversations | 25 | 100 | Most recent activity |
|
Messages | 50 | 200 | Newest first |
|
Messages come back newest first, the opposite of how you will render them, so reverse on the client and remember that paging "forward" walks backwards in time.
Fetching messages does not mark them read. That is a separate call, and it sends a real read receipt to the contact on Instagram, so only make it when a human has actually looked:
1curl -X POST https://api.outstand.so/v1/conversations/9dyJS/read \
2 -H "Authorization: Bearer YOUR_API_KEY"It returns {"success": true, "id": "9dyJS", "unreadCount": 0}.
Sending a reply, and what 202 actually means
1curl -X POST https://api.outstand.so/v1/conversations/9dyJS/messages \
2 -H "Authorization: Bearer YOUR_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{"content": "Hello! How can I help you today?"}'The response is 202 Accepted, not 200 OK, and the distinction is the whole design:
1{
2 "success": true,
3 "message": {
4 "id": "3kPqR",
5 "conversationId": "9dyJS",
6 "direction": "outbound",
7 "source": "api",
8 "content": "Hello! How can I help you today?",
9 "status": "pending",
10 "scheduledAt": null,
11 "createdAt": "2026-09-21T10:02:00.000Z"
12 }
13}202 means Outstand accepted the reply and validated it against the messaging window. It does not mean Instagram took it. The outcome arrives later on a message.sent or message.failed webhook, or by polling the message list for that id. So do not resend while a message is pending: it is in flight, and the retry you fire because nothing happened in two seconds gets delivered as a second message.
Outbound state machine:
State | Meaning |
|---|---|
| Queued, including scheduled replies |
| Handed to the platform successfully |
| Instagram reported the contact read it |
| Delivery failed, read the message's |
Inbound messages arrive received and become read when you mark the conversation read.
For media, send media_urls, content, or both. At least one is required, and the URLs must be fetchable by the delivery service:
1{
2 "content": "Here is the image you requested.",
3 "media_urls": ["https://your-cdn.example.com/product.jpg"]
4}Watch the casing: requests use snake case (media_urls, scheduled_at), responses use camel case (mediaUrls, scheduledAt). One message carries up to ten images, text and attachments go to Instagram as separate calls, and the stored platformMessageId is the last one sent, so do not treat it as the text's identifier.
The Instagram messaging API 24-hour window policy
Free-form replies are allowed for 24 hours after lastInboundAt. That is Meta's rule, not Outstand's. Three consequences people get wrong:
- Your replies do not extend the window. Only the contact writing again restarts the clock. A thread where you sent six helpful messages and they said nothing is a closed thread.
- The check is synchronous. You get a
422on the request, not amessage.failedtwenty minutes later. - A missing
lastInboundAtmeans no window at all, and it has its own error. A thread created by a private reply has never had an inbound message, so it is rejected withinstagram_no_inbound_messagerather than the expiry error. Different cause, different fix: one waits for a reply, the other has already had one.
The 422 body is specific enough to show a support agent:
1{
2 "success": false,
3 "error": "instagram_24h_window_expired: The 24-hour messaging window has expired. Supply `instagram.tag` as HUMAN_AGENT to reply for up to 7 days after the contact's last message."
4}Do not retry an expired-window send unchanged. It will never succeed. Either the contact writes again, or the conversation moves to email.
HUMAN_AGENT: available on request, per organization
The API accepts a tag that widens the window to seven days:
1{
2 "content": "I have checked your support request.",
3 "instagram": { "tag": "HUMAN_AGENT" }
4}Since the per-organization rollout, that is a capability rather than a field, with two gates in front of it. The first is ours. Outstand enables the tag per organization, by hand, because abuse on a shared Meta app is paid for by every account connected through it. Ask support to turn it on; until they do, a send carrying instagram.tag is rejected at the request:
1403 The HUMAN_AGENT tag is not enabled for this organization. Contact support to request access.The second gate is Meta's. The Meta app that owns the connection has to be reviewed and approved for Human Agent, which means App Review plus business verification. If your organization is enabled and the app is not, Outstand's window check widens to seven days and accepts the reply with a 202. Instagram then refuses it, the message goes to failed, and message.failed carries:
1Instagram send failed: 403 To use 'Human Agent', your use of this endpoint must be reviewed and approved by Facebook.Keep those two apart in your logs, because they land on different desks. A 403 on the request means your organization is not enabled and a support ticket fixes it. A 202 followed by message.failed with that text means the organization is enabled and the owning app is not, and only App Review fixes it.
What the tag does not buy is a longer automation runway. It asserts that a human is answering a support issue, and a scheduled reply carrying it fails at delivery if access is withdrawn in the meantime. Model it as an escalation affordance - the Friday evening conversation an agent picks up on Monday - and keep the 24-hour window as the path every automated reply takes.
Starting a conversation from a comment
Everything above assumes the contact wrote first. There is one way in that does not, and it is narrow. When somebody comments on a connected account's post, you can answer them in a direct message: once, within seven days of the comment. Meta calls it a private reply, and it is the only send in this API that does not require an open window.
1curl -X POST https://api.outstand.so/v1/conversations \
2 -H "Authorization: Bearer YOUR_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "social_account_id": "x8Kp2",
6 "comment_id": "17865799348089039",
7 "content": "Here is the link you asked for: https://example.com/guide"
8 }'social_account_id and comment_id are accountId and commentId from the comment.received webhook, so there is nothing to fetch first. Outstand reads the comment from Instagram to resolve its author and its age, sends the message, then stores the thread and answers you.
1{
2 "success": true,
3 "conversation": {
4 "id": "9dyJS",
5 "socialAccountId": "x8Kp2",
6 "network": "instagram",
7 "participantId": "17841400000000000",
8 "participantDisplayName": "somebody",
9 "lastMessageAt": "2026-10-06T09:00:02.000Z",
10 "lastInboundAt": null,
11 "unreadCount": 0,
12 "status": "active"
13 },
14 "message": {
15 "id": "3kPqR",
16 "conversationId": "9dyJS",
17 "direction": "outbound",
18 "source": "api",
19 "content": "Here is the link you asked for: https://example.com/guide",
20 "status": "sent",
21 "commentId": "17865799348089039",
22 "platformSentAt": "2026-10-06T09:00:02.000Z",
23 "scheduledAt": null,
24 "createdAt": "2026-10-06T09:00:02.000Z"
25 }
26}The response is 201 and the message is already sent, which makes this the one exception to everything the previous section said about 202. Delivery is synchronous. Instagram permits exactly one private reply per comment, so a queued retry could only ever fail, and there is no pending state worth polling: a 201 means it arrived.
Three fields to read carefully. commentId is set on the message, and it is null on every other message in the system, so it is both your audit trail and the key to reconcile sends against comments. participantDisplayName is the commenter's @username, taken from the comment rather than resolved later, so an inbox shows a handle from the first message. And lastInboundAt is null.
That last one is the part people design around wrongly. A private reply does not start the clock. You have messaged them; they have not messaged you. Until they answer, a send on that thread comes back:
1422 instagram_no_inbound_message: The contact has not messaged this account yet.
2After a private reply, Instagram only allows further messages once the contact replies.Plan for one message that carries its own weight, and treat the contact's reply as the event that unlocks the rest of the API.
Code | Cause |
|---|---|
| Invalid body, or an account on a network without private replies |
|
|
|
|
|
|
| Instagram rejected the send, with its response in |
The 409 is enforced by a unique index on the message's commentId, so two requests racing each other end with one delivered DM and one 409 rather than two DMs in somebody's inbox. Keep your own dedupe on commentId in front of the call regardless, because comment deliveries are at-least-once and a 409 does not tell you whose send won.
The 502 is the only retryable code here. Outstand writes the conversation and the message row before calling Instagram, so that Instagram's echo of your own send is matched to it rather than relayed to you as somebody typing in the app. If the send is refused, those rows are deleted again, which leaves the comment cleanly unanswered and your retry clean.
Two operational notes. A private reply is text only: media is not supported, so a file payload has to be a link. And it counts as an API send, which makes that conversation billable exactly as a reply does - a funnel that answers every comment on a post that takes off opens a billable conversation per commenter, so price it before you launch it.
Scopes are the pleasant half of this. Sending a private reply needs instagram_business_manage_comments, one of Instagram's defaults, so an account connected only for publishing can already do it. Hearing the answer needs instagram_business_manage_messages, the opt-in from trap one above. You can ship the send without the receive and not notice for a while, so wire both before you launch.
Two webhooks follow a successful call: conversation.started for a thread that did not exist, then message.sent with source: "api". A commenter who had messaged the account before reuses their existing conversation, and only message.sent fires.
Scheduling a reply, and cancelling it
Add a future ISO 8601 scheduled_at. The window check runs against the scheduled time, immediately:
1curl -X POST https://api.outstand.so/v1/conversations/9dyJS/messages \
2 -H "Authorization: Bearer YOUR_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{
5 "content": "Following up on your order.",
6 "scheduled_at": "2026-09-21T18:00:00.000Z"
7 }'A reply scheduled past the window is rejected with 422 up front, while the window is still open, with the error reading will have expired by the requested scheduled_at. That is deliberate: you find out now, not at 6pm. An invalid or non-future timestamp is a 400.
Cancel while it is still pending:
1curl -X DELETE https://api.outstand.so/v1/conversations/9dyJS/messages/3kPqR \
2 -H "Authorization: Bearer YOUR_API_KEY"Cancellation deletes the message, it does not mark it cancelled. A message that already sent, failed, or was never scheduled returns 409; one not in that conversation returns 404. Refresh the thread if cancellation races delivery.
Meta Instagram messaging API webhook setup: the five events
Polling a DM inbox is the wrong shape. Subscribe an endpoint in webhook settings; every event arrives on the same event / timestamp / data envelope.
Event | When |
|
|---|---|---|
| A new thread is stored |
|
| An inbound message is stored |
|
| An API reply was delivered, or the owner sent from the phone |
|
| An accepted reply failed delivery |
|
| The contact read your reply |
|
An inbound message:
1{
2 "event": "message.received",
3 "timestamp": "2026-09-21T10:00:01.000Z",
4 "data": {
5 "conversationId": "9dyJS",
6 "messageId": "3kPqR",
7 "orgId": "org_example",
8 "network": "instagram",
9 "content": "Can you help with my order?",
10 "senderId": "17841400000000000",
11 "sentAt": "2026-09-21T10:00:00.000Z"
12 }
13}A failure:
1{
2 "event": "message.failed",
3 "timestamp": "2026-09-21T10:01:00.000Z",
4 "data": {
5 "conversationId": "9dyJS",
6 "messageId": "4rStU",
7 "orgId": "org_example",
8 "network": "instagram",
9 "error": "Example delivery error from the platform"
10 }
11}The thing to internalise: webhook payloads are notifications, not message objects. message.received has no mediaUrls at all, so a photo-only DM reaches you as an event with null content and nothing to render. Fetch the messages endpoint for current state. Verify signatures against the raw body, acknowledge with a 2xx quickly, and handle duplicates idempotently.
The message your own user sent from their phone
The account owner has the Instagram app, and they will answer a thread from it while your inbox is open in another tab. Your inbox has to survive that.
Those messages are relayed as message.sent with source: "native_app". They are not stored, so they never appear in GET /v1/conversations/{id}/messages, and the webhook is the only place they exist:
1{
2 "event": "message.sent",
3 "timestamp": "2026-09-21T10:05:01.000Z",
4 "data": {
5 "conversationId": "9dyJS",
6 "messageId": null,
7 "orgId": "org_example",
8 "network": "instagram",
9 "platformMessageId": "aWdfZAG1faXRlbToxOk...",
10 "source": "native_app",
11 "participantId": "17841400000000001",
12 "senderId": "17841400000000000",
13 "content": "Thanks, I will check and get back to you.",
14 "mediaUrls": [],
15 "sentAt": "2026-09-21T10:05:00.000Z"
16 }
17}Four things to handle:
messageIdisnull, because there is no stored message. Do not key on it.conversationIdisnullwhen the thread is not stored yet. Match onparticipantId, the contact's platform user ID.- Deduplicate on
platformMessageId. Webhooks retry, and this is your only stable key. mediaUrlsare platform CDN links that expire. Copy anything you want to keep.
source is one of api, contact, or native_app. Stored messages carry api or contact.
Instagram messaging API rate limits
There is no number to hard-code. Outstand's limits are dynamic, derived from your post traffic and how many accounts you have connected, so your ceiling moves with your usage. Read it from the response headers:
Header | Meaning |
|---|---|
| Requests allowed in the current window |
| Requests still available |
| Unix timestamp when the window resets |
Pace off X-RateLimit-Remaining and you will not hit the wall. If you do, it is a 429:
1{
2 "error": "API key rate limit exceeded",
3 "details": "Rate limit exceeded."
4}A 429 is retryable and does not mean your key is wrong. Honour Retry-After when present, otherwise back off exponentially. One variant is not retryable: "error": "API key request quota exhausted" means a fixed quota has run out, and waiting will not refill it.
Meta applies its own messaging limits above that, also unpublished. In practice the 24-hour window constrains a DM product far more than any request ceiling. See Authentication for the full policy.
The full error table
Code | Cause | What to do |
|---|---|---|
| Validation. No | Fix the request |
| Conversation or message not found in your organization | Refresh your local copy |
| Cancelling a message that already sent, failed, or was never scheduled, or a second private reply to the same comment | Refetch the thread. On a private reply it means the comment is already answered |
| Messaging window expired, or will have expired by | Do not retry. Wait for an inbound message |
| Rate limit or exhausted quota | Back off, or request more quota |
| Async delivery failure after a | Read |
| Instagram rejected a private reply, with its response in | Retryable. Read the message before retrying |
Errors are {"success": false, "error": "..."}. Keep the two classes apart: a rejected request never created a message, so there is nothing to track, while a message.failed refers to a row in your database that needs updating.
The message.failed you will meet first has nothing to do with the window. Send from an account whose token lacks instagram_business_manage_messages and the request is still accepted with 202, because Outstand validates the window, not the scope. Delivery then fails with Instagram send failed: <HTTP status> <Instagram error body>. When a customer reports "sends look fine but nothing arrives", read the error field first.
What to build
Three shapes that fit the constraints rather than fighting them.
A shared team inbox. Subscribe to all five events. conversation.started and message.received add threads, message.sent and message.failed resolve your optimistic sends, message.read renders receipts, and native_app events keep the inbox honest when the owner replies from their phone. Call /read when an agent opens a thread, not when your poller fetches it. The one non-obvious piece of UI: a countdown from lastInboundAt, because a reply box that silently 422s is worse than one that greys out with four hours left on the clock.
A support router that escalates inside the window. Classify each message.received, answer the easy ones from the API, hand the rest to a human. Order the human queue by remaining window rather than by arrival time: without HUMAN_AGENT the escalation SLA is the same 24 hours, and with it the thread is still only reachable for seven days. That ordering is the whole product.
An order-status responder. The strongest fit, because the contact always writes first. Match the inbound text to an order, reply with status, and use scheduled_at for the follow-up when the parcel ships, as long as that timestamp still falls inside the window. DELETE it if the order changes first. This is the one place scheduling earns its keep.
A comment to DM funnel. The one that needed a feature we did not have until now. A post asks for a keyword, comment.received fires, you reply publicly under the comment and privately to its author in the same handler. The engineering is two requests; the product thinking is that you get one message and no follow-up, so the DM has to contain the whole payload rather than a promise of it. The comment to DM walkthrough has the handler end to end.
What is still missing, honestly
Inbound Direct Messages are Managed Keys only. They reach Outstand through webhooks configured on Outstand's own Meta app, and that setup is not available for customer-owned apps yet, so a BYOK connection receives nothing on the DM side. Comments are not restricted the same way, and that distinction matters more now than it used to: a comment delivery is verified against the signing secret of whichever app owns the connection, so a BYOK account gets its comments, and can send a private reply, and will not hear the answer. If you are on BYOK and the DM half matters, get in touch.
The unsolicited first DM is still not available, and it is not going to be. Private replies closed the comment-shaped hole in the wall, not the wall: you need either an inbound message or a comment of theirs to answer. Any product that claims to DM a cold list is either using private replies on comments it prompted for, or doing something Meta will eventually notice.
The comment trigger is the other half of that, and it is worth knowing in detail because it is what makes a private reply possible. comment.received fires the moment somebody comments on a connected account's post, or replies to a comment on it, carrying the comment text, its author and its commentId. It covers posts published outside Outstand, it skips the account's own comments so an auto-responder cannot answer itself, and unlike DMs it is not Managed Keys only. Comments need no extra scope either; instagram_business_manage_comments is already in the default four.
1{
2 "event": "comment.received",
3 "timestamp": "2026-10-01T10:30:00.000Z",
4 "data": {
5 "orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
6 "network": "instagram",
7 "accountId": "x8Kp2",
8 "postId": "a1B2c",
9 "platformPostId": "17912345678901234",
10 "commentId": "17865799348089039",
11 "parentCommentId": null,
12 "authorId": "17841400000000000",
13 "authorUsername": "jane.doe",
14 "text": "Is the wool coat still in stock in M?"
15 }
16}Two things to know before you build on it. The first used to be a dead end and is not any more: postId is null when the post was not published through Outstand, and the public reply endpoint only works against posts we know about, so those comments cannot be answered publicly. They can be answered privately, because POST /v1/conversations needs only accountId and commentId, and both are always on the event. A comment on a Reel the owner posted from their phone is fully actionable on the DM side.
The second has got sharper. Comments are not stored, so a redelivery hands you the same comment twice - deduplicate on commentId before anything with a side effect. That side effect is now a direct message to a real person, not just a public reply, so the dedupe is load-bearing.
Put together, the loop closes: a comment arrives, you answer it publicly under the post and privately in the author's inbox, and when they reply you get message.received and the full 24-hour window to do whatever the conversation needs. The ceiling is one private message per comment, which is lower than the marketing on any comment to DM tool implies and is the actual rule they are all working inside.
HUMAN_AGENT is enabled per organization on request, and separately requires Meta's approval on the app that owns the connection. Until both are in place, 24 hours is your window.
None of that changes the shape of what you build, because the binding constraint was never the endpoint list. The contact either writes to you or comments, you get one message or one day, and the clock is theirs rather than yours. Build for the clock, starting with connect an account with messaging access and the message lifecycle and webhooks reference. For the rest of the Instagram surface, Instagram Reels API and Stories API covers publishing, Instagram API pricing covers what access costs, and social media API approval by platform covers the review gates you meet next.