Message lifecycle and webhooks

Handle asynchronous delivery, scheduled replies, per-network messaging windows, quick replies, buttons, reactions, and conversation webhook events.

Message states

DirectionStateMeaning
InboundreceivedA message from the contact has arrived.
InboundreadYour application has marked the conversation as read.
OutboundpendingThe reply is queued, including replies scheduled for later.
OutboundsentThe reply was sent through the underlying platform.
OutboundreadThe platform reported that the contact read the reply.
OutboundfailedDelivery failed; inspect the message's error field.

Message source

Every stored message has a source:

SourceMeaning
apiSent through the Outstand API with Send a message or Start a conversation.
contactReceived from the other participant.
native_appSent by the account owner outside Outstand, for example from the Instagram app.

Messages the account owner sends from the Instagram app are relayed to your webhook as message.sent with source: "native_app", but they are not stored, so they do not appear in List messages and do not start a stored conversation. native_app only appears on messages stored before this behaviour was introduced. To keep your own inbox in sync, record these messages from the webhook.

Send a message returns 202 Accepted with a pending message. Use its id to correlate message.sent or message.failed events, or find it in List messages. Do not treat acceptance as successful delivery or automatically send another copy while the first message is pending.

Text and media

Send non-blank content, at least one URL in media_urls, or both. Media URLs must be accessible to the delivery service. To prepare hosted media, see the Media API.

{
  "content": "Here is the image you requested.",
  "media_urls": ["https://your-cdn.example.com/product.jpg"]
}

Request bodies use media_urls and scheduled_at; message response objects expose mediaUrls and scheduledAt.

Quick replies

On Instagram, a text message can carry up to 13 quick replies: buttons the contact taps instead of typing a reply. Pass them on Send a message, or on the first message when you start a conversation. Each has a title of up to 20 characters, shown on the button, and an id of up to 1,000 characters, unique within the message, that only you see. Use the id to tell which button was tapped, and to carry any context you need, such as the order or product it is about.

{
  "content": "What size do you need?",
  "instagram": {
    "quick_replies": [
      { "title": "Small", "id": "SIZE_S" },
      { "title": "Large", "id": "SIZE_L" }
    ]
  }
}

When the contact taps a button, Instagram removes the buttons and posts its title to the conversation as the contact's message. It arrives like any other inbound message, with the title as its content, and its message.received event carries the button's id:

{
  "event": "message.received",
  "timestamp": "2026-09-21T10:02:01.000Z",
  "data": {
    "conversationId": "9dyJS",
    "messageId": "5vWxY",
    "orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
    "network": "instagram",
    "content": "Small",
    "senderId": "17841400000000000",
    "sentAt": "2026-09-21T10:02:00.000Z",
    "interaction": { "type": "quick_reply", "id": "SIZE_S" }
  }
}

The tap is also stored on the message: in List messages, a message sent by tapping a quick reply has interaction: { "type": "quick_reply", "id": "SIZE_S" }, and every other message has interaction: null. Use it to tell a tap from a typed reply with the same text, or to recover taps whose webhook you missed. type tells you what was tapped: quick_reply for a quick reply, or button for a button.

The message you sent keeps its buttons: its quickReplies field lists them as you sent them (title and id), so your inbox can show what the contact was offered. Messages sent without quick replies have quickReplies: null.

Quick replies need content and cannot be combined with media_urls; either returns 400. Only plain text buttons are supported. Instagram does not show quick replies on desktop, where the contact sees the text alone and can still type a reply.

Buttons

On Instagram, a text message can also carry up to 3 buttons, shown under the text. Pass them as instagram.buttons on Send a message, or on the first message when you start a conversation. Each button has a type and a title of up to 20 characters:

  • postback: the contact taps it to reply. Give it an id of up to 1,000 characters, unique within the message, that only you see.
  • url: opens an http or https link. Opening it sends nothing back.
{
  "content": "Your order has shipped. What would you like to do?",
  "instagram": {
    "buttons": [
      { "type": "postback", "title": "Track my order", "id": "TRACK_ORDER_1042" },
      { "type": "url", "title": "Visit the store", "url": "https://shop.example.com" }
    ]
  }
}

A tap on a postback button arrives like a quick reply tap: an inbound message whose content is the button's title, with interaction: { "type": "button", "id": "TRACK_ORDER_1042" } on the stored message and its message.received event. As a reply from the contact, it opens the 24-hour window. Unlike quick replies, buttons stay on the message after a tap, so the contact can tap them again later; each tap arrives as a new message.

The message you sent lists its buttons in its buttons field, as you sent them. Messages sent without buttons have buttons: null.

Buttons need content of up to 640 characters, and cannot be combined with media_urls or quick_replies; any of these returns 400.

Story replies and mentions

Instagram does not let a business reply to a story directly. Story interactions reach you as direct messages instead:

  • Story reply: the contact replied to your story. The inbound message has story: { "type": "reply", "id": "...", "url": "..." }, and content is their text.
  • Story mention: the contact mentioned your account in their story. The inbound message has story: { "type": "mention", "url": "..." }. Instagram only sends mentions from public accounts, or from private accounts that follow you.

The same story object is on the message.received event. Every other message has story: null. Both kinds are messages from the contact, so they open the 24-hour window.

To answer one, send a message with instagram.reply_to set to the inbound message's id. Instagram shows your message quoting the contact's story reply or mention:

{
  "content": "Thanks for sharing! Here is 10% off your next order.",
  "instagram": { "reply_to": "3kPqR" }
}

instagram.reply_to works with any delivered message in the conversation, and with text, media, quick replies and buttons. A message that is not in the conversation returns 404, and one that has not been delivered yet returns 400. The message you sent has replyTo: { "messageId": "3kPqR" }; other messages have replyTo: null.

The story's url is an Instagram CDN link that stops working once the story expires after 24 hours or is deleted. Show a placeholder when it no longer loads, and do not download or store the story media itself: Instagram's terms only allow keeping the link.

Reactions

On Instagram, your account can react to a message in the conversation with an emoji. React to a message with a single emoji in emoji:

{ "emoji": "❤️" }

Your account has one reaction per message, so reacting again replaces it, and Remove a reaction takes it away. Unlike sending a message, the reaction is applied on Instagram before the response returns: there is no pending state and no webhook. Reacting returns { "success": true } and removing returns 204 with no body. List messages then shows the message's reaction as the emoji, or null once removed. Reactions left from the Instagram app are not shown.

Only a message Instagram has delivered can be reacted to: a message you sent that is still pending, or failed, returns 409. Instagram's own messaging rules still apply, and a reaction it refuses returns 502 with its error.

Messaging windows and scheduling

Reply windows are a Meta rule. On Instagram and Facebook the standard reply window ends 24 hours after the contact's last inbound message (lastInboundAt), and your own outbound replies do not extend it.

X has no reply window: a contact whose DM settings accept the account can be messaged at any time. A send X will not take is rejected with 422, so check the outcome rather than the clock.

For an eligible human support reply, pass:

{
  "content": "I have checked your support request.",
  "instagram": { "tag": "HUMAN_AGENT" }
}

HUMAN_AGENT is Instagram-only and extends the window to seven days after the contact's last message. It is for a human replying to a support issue and requires App Review plus business verification on the Meta app that owns the connection. It is not a general-purpose extension for automated replies.

The tag is enabled per organization on request - contact support to request access. Until it is enabled for your organization, a send with instagram.tag is rejected with 403, and a scheduled message that carries the tag fails at delivery if access is removed in the meantime.

To schedule a reply, add scheduled_at with a future ISO 8601 timestamp. Calculate it from your intended delivery time and the contact's current messaging window. The API checks the window against that scheduled time immediately: a reply scheduled beyond the window is rejected with 422, even if the request is made while the window is still open. An invalid or non-future timestamp returns 400.

Scheduled messages remain pending until delivery. The delivery outcome arrives through the same webhooks as an immediate reply.

Starting a conversation

The first message of a conversation you start follows each network's own rules. On Instagram and Facebook, it must be a private reply to the contact's comment, sent within 7 days of the comment, and each comment can receive one. That private reply does not open the 24-hour window. The window opens when the contact answers, so until then the conversation's lastInboundAt is null and Send a message is rejected with 422. On X, pass exactly one of user_id, username or comment_id; there is no window to open, but delivery still depends on the contact's DM settings. Adding quick replies or buttons to the first message lets the contact answer with one tap. See Comment-to-DM for the full flow.

Cancel a scheduled reply

Call Cancel a scheduled message with the conversation ID and message ID. Only a scheduled message that is still pending can be cancelled. Successful cancellation removes the message from the queue and deletes it.

A reply that was not scheduled, or is no longer pending, returns 409. A message not found in the conversation returns 404. Refresh the message list if cancellation races with delivery.

Pagination and read state

Both list endpoints return data and pagination. When hasMore is true, pass the returned nextCursor as the next request's cursor, retaining your filters. Treat the cursor as opaque.

Conversations are ordered by most recent activity; messages are ordered newest first. Conversation pages default to 25 items (maximum 100); message pages default to 50 items (maximum 200). The message list supports an inbound or outbound direction filter.

Mark conversation as read updates inbound messages and clears the unread count.

Webhook events

Configure subscriptions using the shared Webhooks guide. All events have the standard event, timestamp, and data envelope.

EventWhen it occursFields in data
conversation.startedA new conversation is stored, from a contact's message or because you started it.conversationId, orgId, network, participantId
message.receivedAn inbound message is stored.conversationId, messageId, orgId, network, content, senderId, sentAt; interaction when the contact tapped a quick reply or a button; story when it was a story reply or mention
message.sentA message sent through the API was delivered (source: "api"), or the account owner sent a message from the Instagram app (source: "native_app").conversationId, messageId, orgId, network, platformMessageId, source; for native_app also participantId, senderId, content, mediaUrls, sentAt
message.failedAn accepted outbound message fails delivery.conversationId, messageId, orgId, network, error
message.readThe contact read an outbound message.conversationId, messageId, orgId, network

For example, an inbound message event:

{
  "event": "message.received",
  "timestamp": "2026-09-21T10:00:01.000Z",
  "data": {
    "conversationId": "9dyJS",
    "messageId": "3kPqR",
    "orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
    "network": "instagram",
    "content": "Can you help with my order?",
    "senderId": "17841400000000000",
    "sentAt": "2026-09-21T10:00:00.000Z"
  }
}

An asynchronous delivery failure uses the same envelope:

{
  "event": "message.failed",
  "timestamp": "2026-09-21T10:01:00.000Z",
  "data": {
    "conversationId": "9dyJS",
    "messageId": "4rStU",
    "orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
    "network": "instagram",
    "error": "Example delivery error from the platform"
  }
}

A message sent from the Instagram app is not stored, so its message.sent event has messageId: null and carries the message itself. conversationId is set when the conversation is already stored and null otherwise; use participantId (the contact's platform user ID) to match it to a thread. The mediaUrls are platform CDN links that expire, so copy any media you need to keep. Use platformMessageId to deduplicate these events.

{
  "event": "message.sent",
  "timestamp": "2026-09-21T10:05:01.000Z",
  "data": {
    "conversationId": "9dyJS",
    "messageId": null,
    "orgId": "kR7mQ2xLpN4vT8wZ3bC6yH9sJ1dF5gAe",
    "network": "instagram",
    "platformMessageId": "aWdfZAG1faXRlbToxOk...",
    "source": "native_app",
    "participantId": "17841400000000001",
    "senderId": "17841400000000000",
    "content": "Thanks, I will check and get back to you.",
    "mediaUrls": [],
    "sentAt": "2026-09-21T10:05:00.000Z"
  }
}

Webhook payloads are event notifications, not complete message objects. In particular, message.received does not include mediaUrls; fetch the messages endpoint for current message details. Media-only messages can have null content.

Verify signatures against the raw request body, acknowledge promptly with a 2xx response, and handle duplicate notifications idempotently. Use the message ID and event type to correlate updates, and fetch the latest message state when reconciling delayed events. See Delivery and retries.

Handling request failures

Handle validation errors (400), missing conversations or messages (404), cancellation conflicts (409), and expired messaging windows (422) separately from asynchronous message.failed events. Requests rejected before acceptance do not represent a pending message to track. Correct the request or wait for a new inbound message as appropriate, instead of retrying an expired-window reply unchanged.