If you searched for Instagram DM automation you probably wanted a tool. There are a dozen of them, most have a free tier, and if what you need is a keyword trigger on one account you own, one of them is the right answer. Go use it. This post will waste your afternoon.
This is for the other reader: the one putting DM automation inside a product, on accounts that belong to customers, where it has to keep working when the account count goes from one to four hundred.
For that reader the useful fact is that underneath every one of those dashboards there are exactly three moving parts. A webhook that tells you a message arrived. A 24 hour window that decides whether you are allowed to answer. A send call. That is the whole surface. The tools are renting you those three things with a rules builder on top, and the rules builder is the part you were going to write anyway.
Here is what the three parts look like when you hold them directly, using Outstand's Conversations API for the concrete calls, plus the limits that the tool landing pages tend to leave out.
The short version
- A thread starts one of two ways: the contact messages the account, or you answer a comment of theirs with a private reply. There is no third.
- A private reply is one message per comment, within 7 days of it, and it does not open the messaging window.
POST /v1/conversationssends it. - You get 24 hours from the contact's last inbound message to send freely. Your own sends do not extend it. After that the request is rejected with
422. - Comments arrive on a
comment.receivedwebhook, so both halves of comment to DM are now real. The ceiling is one DM per comment. - A reply returns
202 Acceptedwith statuspending. Acceptance is not delivery. The outcome arrives on a webhook. A private reply is the exception:201, already sent. HUMAN_AGENTextends the window to seven days and is not an automation loophole. It means a human is typing.
What "DM automation" actually is
Three moving parts, and only three.
Part | What it is | The call |
|---|---|---|
Inbound | A webhook fires when a contact messages the connected account |
|
Permission | A rolling 24 hour window measured from the contact's last inbound message |
|
Outbound | One request that queues a reply |
|
The thing that kills a naive design is not the absence of a part, it is the shape of the first one. You do not get to choose when the conversation starts. The surface is GET /v1/conversations, GET /v1/conversations/{id}, GET /v1/conversations/{id}/messages, POST /v1/conversations/{id}/messages, POST /v1/conversations/{id}/read, a cancel call for scheduled messages, and one way in: POST /v1/conversations, which answers a comment with a direct message to its author.
That last one is Meta's private replies feature and it is deliberately small. One message per comment, within seven days of the comment, text only, and it does not open the 24-hour window - so it buys you a single message and no follow-up until the contact writes back. It is a door, not a channel.
So "Instagram DM automation" is almost never outbound messaging in the sense a cold-email tool means it. It is either a reply to someone who messaged you, or one message to someone who commented, decided by code instead of by a person. Any product plan that assumes a sequence you control the timing of is planning a feature Meta does not sell.
The 24 hour window governs every design decision
This is the constraint that shapes everything else, and it is the one the tool pages bury three scrolls down or skip entirely.
Free-form text is allowed for 24 hours after the contact's last inbound message. You read that timestamp off the conversation object as lastInboundAt:
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 "network": "instagram",
7 "participantId": "17841400000000000",
8 "participantDisplayName": "string",
9 "lastMessageAt": "2019-08-24T14:15:22Z",
10 "lastInboundAt": "2019-08-24T14:15:22Z",
11 "unreadCount": 0,
12 "status": "active"
13 }
14 ],
15 "pagination": {
16 "hasMore": true,
17 "nextCursor": 0,
18 "limit": 0
19 }
20}Two properties of that clock decide your architecture.
It resets on their message, not yours. A reply you send does not extend the window. If a contact writes at 09:00 and you send four messages over the next ten hours, the window still closes at 09:00 the following day. Only another inbound message moves it.
It is enforced at the request, not swallowed. Past the window, the send is rejected with 422. You do not get a queued message that goes out later, and you do not get silence. You get an error you have to handle.
Situation | What you may send | What happens |
|---|---|---|
Answering a comment, within 7 days of it | One text-only message, once per comment |
|
Thread created by a private reply, contact has not answered | Nothing |
|
Inside 24h of | Free-form text and media |
|
Past 24h, no tag | Nothing free-form |
|
Past 24h, | Free-form, human authored |
|
Past 7 days | Nothing |
|
The design consequence is that a DM automation is a short-lived state machine, not a drip campaign. Every sequence you can legitimately build fits inside one day, and every sequence you sketch that spans a week is a sequence you cannot ship. Decide that before you design the data model, not after a support ticket tells you.
Scheduling does not buy you a way around it. scheduled_at takes an ISO 8601 timestamp, and the window check runs synchronously against that timestamp rather than at send time. A scheduled send that would land outside the window is rejected with 422 up front, so you find out when you schedule it. An invalid or past timestamp gets you a 400.
What you can automate inside the window, and what you cannot
Reasonable, shippable, entirely inside the rules:
- An instant acknowledgement, so the contact is not staring at nothing while your queue drains.
- Answering a commenter privately, once, with
POST /v1/conversations. The only send that does not need them to have written first. - Triage. Read the message, classify it, route it to the right human or the right internal system.
- An answer pulled from your own data. Order status, booking time, delivery window, account state.
- A handoff, where automation covers the first sixty seconds and a person takes it from there.
- Marking the thread read with
POST /v1/conversations/{id}/readso your unread counts mean something.
Not available, in any tool, at any price:
- Cold DMs. You need an inbound message or a comment of theirs to answer. There is no "message this username".
- Welcome messages to new followers. A follow is not a message and not a comment, so there is nothing to answer.
- Nudges on day three. The window closed on day two, and a private reply does not open one.
- A sequence off the back of a comment. You get one message per comment and then silence until they reply.
- Media in a private reply. Text only, so the payload has to be a link.
Comment to DM is the one people ask about most, and both halves of it now exist. It is worth laying out properly, because the half that shipped last is much smaller than the landing pages selling it suggest.
The trigger is comment.received. Subscribe an endpoint and an event fires the moment somebody comments on a connected account's post, or replies to a comment on it, carrying the text, the author's id and username, and the commentId. It covers posts published outside Outstand, it skips comments written by the account itself so an auto-responder cannot answer its own reply, and it needs no extra scope - instagram_business_manage_comments ships in the default four. It is also not Managed Keys only, which inbound DMs are: each delivery is verified against the signing secret of the app that owns the connection, so a bring-your-own-key account gets its comments.
The send is POST /v1/conversations. You pass social_account_id and comment_id - the accountId and commentId the event already gave you - plus the text, and Instagram delivers a direct message to the person who commented. The response is 201 with the message already sent, because delivery is synchronous here rather than queued: Instagram allows exactly one private reply per comment, so there would be nothing useful for a retry to do.
Now the limits, because this is where product plans break. One message per comment, not per person. Seven days from the comment, not from when your queue reached it. Text only. And it does not open the 24-hour window, so until the contact answers, a second send on that thread is rejected with 422 instagram_no_inbound_message. Whatever you were going to say across five messages has to fit in one.
The handler for the whole loop:
1// comment.received -> public reply + private reply. Both halves, one handler.
2const OUTSTAND = "https://api.outstand.so/v1"
3const headers = { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" }
4
5export async function onComment(data: CommentReceived) {
6 // Comments are never stored by Outstand, so a redelivery is yours to catch.
7 // The side effect below is a DM to a real person; dedupe before it, not after.
8 if (!(await claimOnce(data.commentId))) return
9
10 if (!/\bCOAT\b/i.test(data.text ?? "")) return
11
12 // Public reply needs a post Outstand published. Skip it if postId is null,
13 // but do not skip the comment: the DM does not need postId at all.
14 if (data.postId) {
15 await fetch(`${OUTSTAND}/posts/${data.postId}/replies`, {
16 method: "POST",
17 headers,
18 body: JSON.stringify({
19 content: "Just sent you the details!",
20 parent_comment_id: data.commentId,
21 account_username: "myinsta",
22 }),
23 })
24 }
25
26 // The private reply. Delivered before this resolves - no 202, no polling.
27 const res = await fetch(`${OUTSTAND}/conversations`, {
28 method: "POST",
29 headers,
30 body: JSON.stringify({
31 social_account_id: data.accountId,
32 comment_id: data.commentId,
33 content: "In stock in M. Reply here and we'll hold one for you: https://shop.example.com/coat",
34 }),
35 })
36
37 if (res.status === 409) return // this comment is already answered
38 if (res.status === 422) return // comment older than 7 days, out of reach
39 if (res.status === 502) return retryLater(data) // Instagram refused; rows rolled back
40 if (!res.ok) throw new Error(`private reply ${res.status}: ${await res.text()}`)
41
42 // They cannot be messaged again until they answer, so the DM above had to carry
43 // the whole payload. Record the intent for the moment they reply.
44 const { conversation } = await res.json()
45 await armFollowUp({ conversationId: conversation.id, intent: "coat_stock" })
46}claimOnce is a conditional write on commentId that returns false the second time, and it matters more here than on any other webhook. Outstand does enforce one private reply per comment with a unique index, so a race ends in one DM and one 409 rather than two DMs. But if your retry logic reads 409 as a failure it will page somebody at 3am about a message that was delivered correctly.
The other thing that changed quietly: postId being null used to end the flow. It means the post was not published through Outstand, and the public reply endpoint only works against posts we know about. The private reply only needs accountId and commentId, so a comment on a Reel the owner posted from their phone is now fully actionable on the DM side. For a product running on customer accounts, that is most of the comments.
Then the second half, if it comes. The contact replies, message.received fires, lastInboundAt is set, and you have the full 24 hours to answer with anything including media. That is the moment to look up the intent you armed, and it is also the real conversion event - the private reply landing in a message request is not the same as somebody engaging with it.
Be honest with yourself about the funnel maths rather than the capability. One message with no follow-up converts worse than the five-touch sequence a hosted tool's flow builder will happily draw for you, and it converts better than the public reply saying "DM us" that was the only option before. If a visual flow builder for a non-engineer is the requirement, a hosted tool is still the honest recommendation. What Outstand gives you is both halves of the loop, across every customer account, in your own code.
A worked auto-responder
Receive, branch, send inside the window, handle the failure. Four steps, and the fourth is the one people skip.
Webhook payloads are notifications rather than full message objects. message.received carries the text and the sender, not the media:
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}The handler. Note that it computes the window itself rather than trusting the inbound timestamp, because your queue may have held the event:
1const WINDOW_MS = 24 * 60 * 60 * 1000
2
3export async function onWebhook(event: WebhookEvent) {
4 switch (event.event) {
5 case "message.received":
6 return handleInbound(event.data)
7 case "message.failed":
8 return handleFailure(event.data)
9 case "message.sent":
10 // Owner replies from the phone app arrive here with
11 // source: "native_app" and messageId: null. They are not stored.
12 return recordSent(event.data)
13 default:
14 return
15 }
16}
17
18async function handleInbound(data: InboundData) {
19 const conversation = await getConversation(data.conversationId)
20 const openUntil = Date.parse(conversation.lastInboundAt) + WINDOW_MS
21
22 if (Date.now() >= openUntil) {
23 // Stale event. Do not attempt the send; it would be a 422.
24 return escalateToHuman(data.conversationId, "window_closed")
25 }
26
27 const intent = classify(data.content)
28 if (intent.kind === "human") {
29 return escalateToHuman(data.conversationId, intent.reason)
30 }
31
32 await sendReply(data.conversationId, intent.reply)
33}The send itself is one request:
1curl -X POST https://api.outstand.so/v1/conversations/CONVERSATION_ID/messages \
2 -H "Authorization: Bearer YOUR_API_KEY" \
3 -H "Content-Type: application/json" \
4 -d '{"content": "Hello! How can I help you today?"}'It returns 202 Accepted, and the body is a message with status pending:
1{
2 "success": true,
3 "message": {
4 "id": "3kPqR",
5 "conversationId": "string",
6 "platformMessageId": "string",
7 "direction": "outbound",
8 "source": "api",
9 "content": "string",
10 "mediaUrls": ["string"],
11 "status": "pending",
12 "scheduledAt": "2019-08-24T14:15:22Z",
13 "createdAt": "2019-08-24T14:15:22Z"
14 }
15}Acceptance is not delivery. 202 means Outstand took the message, not that Instagram showed it to anyone. That distinction is the whole reason step four exists: the outcome arrives later, as message.sent or as message.failed.
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}If you treat the 202 as success and never subscribe to message.failed, your automation reports a 100 percent reply rate and your customers see dropped conversations. Handle it: mark the message failed, and route the thread to a human while the window is still open.
Two more things belong in any real handler. Deduplicate on platformMessageId, because the owner answering from the Instagram phone app arrives as message.sent with source: "native_app" and messageId: null and is not stored. And treat webhooks as at-least-once, keyed on message id plus event type. The full event list and payloads are in the message lifecycle docs.
Code | Meaning |
|---|---|
| Validation. Missing content, or an invalid or past |
| Conversation not found, or a comment not visible to that account |
| Cancel conflict, or a second private reply to the same comment |
| Outside the messaging window, or a comment more than 7 days old |
| Instagram rejected a private reply. Retryable, with its reason in |
| Accepted, then the platform refused it. Asynchronous |
HUMAN_AGENT is not an automation loophole
Send instagram: { tag: "HUMAN_AGENT" } and the window stretches from 24 hours to seven days. Every team that reads that sentence has the same next thought, so let us close it off.
It is gated twice, and neither gate is a formality. Outstand enables the tag per organization, by hand, on request - until then a send carrying it comes back 403 The HUMAN_AGENT tag is not enabled for this organization. On top of that, the Meta app that owns the connection needs App Review plus business verification for Human Agent, which is a real submission with a real reviewer; if that is missing the reply is accepted with a 202 and then fails at delivery with Instagram's own refusal. We have written about what platform approval actually involves.
More to the point, the tag asserts something specific: a human is answering. Using it to buy six extra days of automated messaging misrepresents your traffic to the reviewer who granted it, and the enforcement mechanism is the connection your product runs on. A scheduled reply carrying the tag also fails at delivery if access is withdrawn in the meantime.
The legitimate use is narrow and genuinely useful. A support conversation comes in Friday evening, nobody is on shift, an agent picks it up Monday morning and can still reply in the thread. That is the feature. Automation inside seven days is not.
Build it yourself, or rent it
An honest table, from a company that sells the API.
What you need | Build on the API | Hosted DM tool |
|---|---|---|
One account you own, keyword triggers | Overkill | The right answer |
A visual flow builder for a non-engineer | You are building it | Included |
Comment to DM via Private Replies | Available, | Available, wrapped in a rule builder |
A multi-step sequence after a comment | Not possible. One DM per comment | Not possible either. Same Meta rule |
Many customer accounts under your product | Built for this | Priced per account, awkwardly |
Replies that depend on your own data | Straightforward | Webhooks and glue, if the plan allows it |
Conversations inside your own UI | Yours by default | Their UI, their branding tier |
Message data in your own store | Yours by default | Export, on their terms |
Time to first automated reply | Days | An afternoon |
The row that decides it is rarely the comment to DM row any more, because both columns now say yes and both are working inside the same one-message-per-comment rule. It is usually the account-count row. A hosted tool is priced and designed for the account owner. If you are the account owner, that alignment is worth real money and you should pay it. If your customers are the account owners, you are paying per seat for a product whose UI you then have to hide, and the economics turn over somewhere in the low hundreds of accounts. We reach the same conclusion in the scheduling category, where the Hootsuite alternatives and Metricool alternative comparisons land on build versus rent for the same structural reason.
Two practical notes before you commit to building. Inbound Direct Messages are Managed Keys only for now, so a bring-your-own-key connection gets comments and can send private replies but will not receive the answers. And the messaging scope is not on by default: the scopes field on the auth URL call replaces Instagram's defaults rather than adding to them, and every existing account has to be reconnected once through that call or it stays silent with no error anywhere. Sending a private reply needs only instagram_business_manage_comments, which is a default scope, so it is entirely possible to ship the outbound half and discover weeks later that nobody's replies ever arrived. The exact scope string and the reconnection trap are in the Instagram DM API reference.
What this leaves you with
DM automation is a webhook, a clock, and a send call. The clock is 24 hours, it belongs to the contact rather than to you, and the one door that does not need an open clock gives you exactly one message. Build inside that and the thing you ship is small, reliable and honest. Build as though the clock is negotiable, or as though one private reply is the start of a sequence, and you will ship an automation that reports success and loses conversations.
For the full mechanics, the Instagram DM API reference covers pagination, scheduling, cancellation and all five webhooks with payloads, and the launch post covers what shipped. Pricing for the Instagram surface generally is in the Instagram API pricing breakdown.
FAQ
Is there a free Instagram DM automation option? For a single account you own, yes, several hosted tools have free tiers and they are the shortest path. There is no free version of the constraint: free or paid, the tool is working inside the same 24 hour window and the same absence of an outbound endpoint.
Can I DM everyone who comments on my post? Yes, once each. The comment reaches you in real time on a comment.received webhook, and POST /v1/conversations sends a direct message to its author - one per comment, within seven days of it, text only. What you cannot do is keep messaging them: the private reply does not open the 24-hour window, so anything after the first message waits until they reply.
Can I run a sequence after someone comments? No. One private reply per comment, and the messaging window stays shut until the contact answers, so there is no day-two nudge to send. This is a Meta rule rather than an Outstand one, and no tool at any price gets around it. Put the whole payload in the first message.
Does my reply reset the 24 hour window? No. Only an inbound message from the contact moves lastInboundAt. Sending more messages does not buy more time.
What happens if I send after the window closes? The request is rejected with 422. Nothing is queued and nothing is delivered. For scheduled messages the check runs against scheduled_at when you schedule, so you get the 422 immediately rather than at send time.
Can I automate messages with the HUMAN_AGENT tag? No. It extends the window to seven days for a human answering a conversation. It also has to be enabled for your organization by Outstand on request, which returns 403 until it is, and separately approved by Meta on the app that owns the connection. Using it for automated sends misrepresents what the tag asserts.
Do I need webhooks, or can I poll? You can poll GET /v1/conversations and GET /v1/conversations/{id}/messages, but a 24 hour window rewards latency poorly and the message.failed outcome is only prompt on the webhook. Use webhooks for the automation and polling for reconciliation.
Last verified: October 2026, against POST /v1/conversations, the Conversations API reference and the webhook event list.