Send Private Replies
A private reply is a direct message to the person who left a public comment. You address it by comment id — not by contact and not by conversation — because at the moment a comment arrives, the commenter's DM identity is not yet known to anyone.
POST /api/v1/comments/{comment_id}/private-reply
Required scope: messages:create
comment_id is Meta's own comment id, exactly as delivered by the comment.received webhook in data.comment.external_id. Instagram ids are numeric; Facebook ids look like {post_id}_{comment_id}. Pass it verbatim — do not split it or normalise it.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
text | string | Yes | The message body, 1–1000 characters. On Instagram this is the entire message. On Facebook Messenger it is the message text, or the button template's body when buttons is set. |
channel_id | string | No | The Instagram or Facebook Messenger channel whose post was commented on. Required when the comment is not yet stored in your workspace — which is the case when you are acting on a fresh comment.received event. Send it always. |
buttons | array | No | Up to 3 buttons. Fully supported on Facebook Messenger; in testing on Instagram (see below). Cannot be combined with image_url. |
quick_replies | array | No | Up to 13 quick-reply chips. Fully supported on Facebook Messenger; in testing on Instagram (see below). Can be combined with text or image_url. |
image_url | string | No | Facebook Messenger only. Publicly reachable image URL. 400 on Instagram. Cannot be combined with buttons. |
Each buttons entry:
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | The label the recipient sees. Max 20 characters (Meta's limit). |
type | string | No | postback (default) or url. |
url | string | Yes for url buttons | Absolute https:// link opened when the button is tapped. Other schemes are rejected. |
action | string | No | start_flow — tapping the button starts one of your flows. Requires flow_id. |
flow_id | string | Yes for start_flow | The flow to start. Must exist in the same workspace. |
payload | string | No | Opaque value echoed back to you when a postback button is tapped — it arrives on the resulting inbound message as meta.button_id. Defaults to title. Not accepted on url or start_flow buttons, which own their behaviour. |
Each quick_replies entry:
| Field | Type | Required | Description |
|---|---|---|---|
title | string | Yes | The chip label. Max 20 characters (Meta's limit). |
content_type | string | No | text — the only kind supported. Omit it and text is assumed, so Meta's own documented payload works unchanged. |
action | string | No | start_flow — tapping the chip starts one of your flows. Requires flow_id. |
flow_id | string | Yes for start_flow | The flow to start. Must exist in the same workspace. |
payload | string | No | Opaque value echoed back to you when the chip is tapped — it arrives on the resulting inbound message as meta.button_id, the same field a button postback uses. Defaults to title. Not accepted on start_flow chips, which own their behaviour. |
Quick replies have no URL variant — that is Meta's model, not our restriction. Sending url on a chip is rejected rather than ignored.
buttons and quick_replies can be sent together: Meta treats a chip set as accompanying the message rather than replacing it.
Platform capability
| Content | Facebook Messenger | |
|---|---|---|
text | Yes | Yes |
buttons | In testing, up to 3 | Yes, up to 3 |
quick_replies | In testing, up to 13 | Yes, up to 13 |
image_url | No — 400 | Yes |
Meta documents rich private replies for Facebook Messenger — its Send API reference states that all messaging types available to the Send API are available for private replies. It makes no statement either way for Instagram: rich content in an Instagram private reply is neither granted nor forbidden anywhere in Meta's Instagram documentation.
Rather than keep guessing, we now accept buttons and quick_replies on Instagram and are verifying on live accounts whether Meta renders them. A 201 from this endpoint does not prove that it did — Meta silently discards message fields it does not accept and still returns success. If the verification fails, these two fields will go back to a hard 400 on Instagram.
So: fine to experiment with, not something to build a dependency on yet. image_url on Instagram is still rejected outright.
None of this constrains the conversation afterwards. Once the recipient answers your private reply the standard messaging window opens, and buttons and quick replies are documented and reliable on Instagram from that point on.
Instagram: a plain private reply
curl -X POST "https://api.sendseven.com/api/v1/comments/17912345678901234/private-reply" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "ch_ig_001",
"text": "Hi! Yes, the blue one is back in stock — here is the link: example.com/blue"
}'
import requests
BASE_URL = "https://api.sendseven.com/api/v1"
HEADERS = {
"Authorization": "Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00",
"Content-Type": "application/json",
}
response = requests.post(
f"{BASE_URL}/comments/17912345678901234/private-reply",
headers=HEADERS,
json={
"channel_id": "ch_ig_001",
"text": "Hi! Yes, the blue one is back in stock — here is the link: example.com/blue",
},
)
print(response.status_code, response.json())
Facebook Messenger: a private reply with buttons
Buttons are sent as a button template: your text becomes the template body, and up to three postback buttons sit underneath it.
curl -X POST "https://api.sendseven.com/api/v1/comments/102938475_564738291/private-reply" \
-H "Authorization: Bearer s7_api_a1b2c3d4e5f6789012345678abcdef00" \
-H "Content-Type: application/json" \
-d '{
"channel_id": "ch_fb_002",
"text": "Thanks for commenting! What would you like to do?",
"buttons": [
{"title": "See pricing", "payload": "pricing"},
{"title": "Book a demo", "payload": "demo"},
{"title": "Talk to sales", "payload": "sales"}
]
}'
When the recipient taps a button, you receive an ordinary inbound message.received event carrying your payload as meta.button_id — which is what lets you continue the conversation.
Link buttons
A url button opens a web page instead of messaging you back. Because nothing comes back, it is the right choice for "read the article" and the wrong choice for anything you need to react to.
{
"channel_id": "ch_fb_002",
"text": "The blue one is back in stock!",
"buttons": [
{"type": "url", "title": "Shop now", "url": "https://example.com/blue"},
{"title": "Ask a question", "payload": "ask"}
]
}
Only absolute https:// links are accepted.
Buttons that start a flow
A button can start one of your flows for the person who tapped it. You do not build the payload — set action and flow_id and the tap is wired up for you, including the link back to the comment that started it all.
{
"channel_id": "ch_fb_002",
"text": "Want the 10% code?",
"buttons": [
{"title": "Yes, send it", "action": "start_flow", "flow_id": "flw_7c1e9a"}
]
}
The flow must already exist in the same workspace, and its trigger may be anything except Comment received. A Comment received flow answers a comment with a private reply of its own — and a comment gets exactly one private reply ever, which this message has already used, so such a flow could only fail silently. Point the button at a flow with a different trigger (Manual is the usual choice) and it will send normally in the conversation the tap opens.
If the flow is unpublished when someone taps, nothing happens; publish it and later taps work.
Plan requirement. action: "start_flow" — on a button or a quick reply, here or on a rule — needs a plan that includes flows, which means Professional and above. Basic can send private replies with plain and URL buttons, but a start_flow entry is rejected with 422 and reason flow_start_requires_plan. The check runs before the flow lookup, so a Basic workspace naming a real flow is told about the plan, not told the flow is unknown.
Quick replies
Quick replies ride alongside the message rather than replacing it, so they can accompany either text or image_url:
{
"channel_id": "ch_fb_002",
"text": "Which size are you after?",
"quick_replies": [
{"title": "Small", "payload": "size_s"},
{"title": "Medium", "payload": "size_m"},
{"title": "Large", "payload": "size_l"}
]
}
An image
{
"channel_id": "ch_fb_002",
"text": "Here it is in blue!",
"image_url": "https://example.com/products/blue.jpg"
}
buttons and image_url cannot be combined — a Messenger message carries one attachment, and both compete for it. Sending both returns 400 unsupported_content.
Response
201 Created:
{
"comment_id": "17912345678901234",
"channel_id": "ch_ig_001",
"platform": "instagram",
"status": "sent",
"message_id": "msg_a1b2c3d4",
"conversation_id": "conv_e5f6a7b8",
"contact_id": "contact_xyz789",
"external_id": "mid.abc123",
"recipient_id": "78901234567890123",
"social_comment_id": "sc_e5f6a7b8",
"state": "replied",
"sent_at": "2026-03-04T16:00:12Z"
}
| Field | Description |
|---|---|
status | Always sent — this endpoint sends synchronously and only returns 201 once Meta has accepted the message. |
message_id | The outbound message in your workspace. |
conversation_id | The DM conversation the reply was filed into. Continue the conversation here with the normal send API. |
contact_id | The commenter, as a contact in your workspace. |
external_id | Meta's message id for the delivered reply. |
recipient_id | The commenter's platform-scoped id. For a comment you had not stored, this is the first time it becomes known — Meta only reveals it in the send response. |
social_comment_id | The stored comment record, when the comment was persisted. |
state | The stored comment's new reply state. |
message_id can be null on a successful sendIn the rare case where Meta accepted the reply but the commenter could not be resolved to a contact, you still get 201 with a null message_id. The DM was delivered. Returning an error there would invite a retry that Meta would permanently reject, because the one private reply has been used. Treat 201 as final regardless of message_id.
The outbound message stores comment_id, in_reply_to_comment_id, comment_platform and private_reply_source in its metadata, which is what links the DM back to the comment it answered.
Errors
Every error body is an object with a stable error_code:
{
"detail": {
"error_code": "private_reply_window_expired",
"message": "The 7-day private reply window for this comment has closed."
}
}
Switch on error_code, never on message or on the status code alone — 422 in particular collides with the framework's own schema-validation response, and error_code is what distinguishes them.
| Status | error_code | Meaning | Retryable? |
|---|---|---|---|
400 | unsupported_content | Content the channel's platform does not accept — an image on an Instagram channel, or buttons together with image_url on either. Instagram buttons and quick replies are accepted while in testing, and would return this code again if that is withdrawn. | No — fix the request. |
400 | channel_required | The comment is not stored in your workspace and no channel_id was given. | No — resend with channel_id. |
403 | feature_not_enabled | Comments and private replies are not enabled for this workspace. | No — ask support to enable the beta. |
404 | channel_not_found | The channel does not exist, is inactive or archived, or is not an Instagram / Facebook Messenger channel. | No — check the id and the channel's state. |
409 | private_reply_already_sent | A private reply has already been sent for this comment — by you, by an agent, or by an automation. Meta allows exactly one per comment, forever. | No. Permanent. Never retry. |
410 | private_reply_window_expired | The 7-day window from the comment's creation has closed. | No. Permanent. |
422 | private_reply_not_eligible | Meta refuses a private reply to this particular comment: it is on an ad, the comment or its post was deleted, or the author is another Page. | No. Nothing about the comment will change. |
422 | invalid_buttons | A button is malformed: too many, a label over 20 characters, a url that is not absolute https, an unknown flow_id, or a flow whose trigger cannot be started from a button. | No — fix the buttons. See below. |
502 | send_failed | Meta rejected or failed the send for another reason. | Yes — retry with backoff. |
409, 410 and 422 are three different thingsThey are deliberately not collapsed into one status:
409— already done. Somebody, possibly one of your own rules, used the single reply.410— too late. The window closed.422— not allowed here. Meta will never accept a private reply to this comment.
None of the three is retryable, and none of them means your integration is broken. Log the reason, mark the comment as dealt with on your side, and move on.
invalid_buttons detail
422 invalid_buttons adds an errors array pointing at the offending button by position:
{
"detail": {
"error_code": "invalid_buttons",
"message": "One or more buttons are invalid.",
"errors": [
{"index": 0, "field": "title", "reason": "too_long"},
{"index": 1, "field": "flow_id", "reason": "unknown_flow"}
]
}
}
Each entry carries only index, field and reason — the value you sent is never echoed back, so it is safe to log the whole body. reason is a stable identifier you can map to your own wording: too_many_buttons, required, too_long, unsupported_button_type, must_be_absolute_https_url, url_button_takes_no_payload, unsupported_action, required_for_start_flow, not_allowed_with_start_flow, reserved_prefix, unknown_flow, flow_trigger_not_startable_from_button, flow_start_requires_plan.
(Content the platform cannot carry is caught earlier, as 400 unsupported_content.)
invalid_quick_replies detail
Quick replies use the same envelope under their own error_code, so you can point the user at the right part of your UI without parsing the errors array:
{
"detail": {
"error_code": "invalid_quick_replies",
"message": "One or more quick replies are invalid.",
"errors": [
{"index": 0, "field": "title", "reason": "too_long"}
]
}
}
reason values: too_many_quick_replies, required, too_long, unsupported_quick_reply_type, unsupported_action, required_for_start_flow, not_allowed_with_start_flow, reserved_prefix, unknown_flow, flow_trigger_not_startable_from_button, flow_start_requires_plan.
unsupported_quick_reply_type covers both an unknown content_type and a url on a chip — Meta's quick reply has no URL variant.
Buttons and quick replies are checked before the reply is sent, so a rejected request leaves the comment untouched and still answerable.
Nothing in Meta's comment webhook marks a comment as being on an ad. 422 private_reply_not_eligible at send time is the only signal there is.
Automating instead
If the reply text does not depend on your own systems, you do not need this endpoint at all — auto-reply rules answer keyword-matched comments without any code, and can hand off to a flow for a real back-and-forth.
Both paths share the same one-reply-per-comment guarantee: if a rule answered a comment, this endpoint returns 409 for it, and vice versa.
Billing
A private reply is billed exactly like any other outbound message on that channel. See Overview → Billing.
Next steps
- Auto-reply rules — answer comments without code
- Listen for comments — where comment ids come from
- Interactive Messages — buttons and lists in the follow-up conversation