How to Send Messenger Quick Replies and Buttons via API

Send tappable quick-reply chips and buttons in Facebook Messenger and Instagram DMs through one API — limits, card templates, Instagram's restrictions, and routing taps by payload.

Quick replies vs buttons

Meta gives you two ways to put tappable options in a DM, and they behave differently:

  • Quick replies are chips rendered above the participant’s composer. Up to 13 per message, and they disappear as soon as one is tapped. Use them for the next step in a conversation — “Track order”, “Talk to support”.
  • Buttons are attached to the message itself and stay in the thread. Up to 3 per message, each either a link (web_url) or a payload you get back (postback). Use them for actions that should still be tappable later — a tracking link, a cancel action.

Both work on Facebook Messenger and Instagram through the same Direct Messages API. Telegram’s equivalent is reply_markup, covered at the end of this guide.

Send quick replies

Each chip needs a title (≤20 characters, what the user sees) and a payload (≤1000 characters, what you get back):

Terminal window
curl -X POST "https://api.postproxy.dev/api/chats/CHAT_ID/messages" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "What can I help with?",
"quick_replies": [
{ "title": "Track order", "payload": "TRACK" },
{ "title": "Talk to support", "payload": "HELP" },
{ "title": "Return an item", "payload": "RETURN" }
]
}'

body is required — the chips ride along with a text message. The response is 202 Accepted with the message in status: "pending", like any other send.

Send buttons and a card

Buttons are delivered as a Meta generic template. Your body becomes that template’s element title, which is why body is capped at 80 characters when buttons are present — Meta’s limit, not Postproxy’s. Longer text comes back as a 422 naming the length instead of being cut off. Buttons also can’t be combined with media.

Terminal window
curl -X POST "https://api.postproxy.dev/api/chats/CHAT_ID/messages" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"body": "Your order shipped",
"buttons": [
{ "type": "web_url", "title": "Track", "url": "https://shop.example.com/orders/123" },
{ "type": "postback", "title": "Cancel", "payload": "CANCEL:123" }
]
}'

web_url buttons need an https:// URL; postback buttons need a payload. Add a card object to fill in the rest of the template element and render a product-style card:

{
"body": "Nike Air Max",
"card": {
"subtitle": "$129 · Arriving Friday",
"image_url": "https://cdn.example.com/shoe.png",
"default_action": { "type": "web_url", "url": "https://shop.example.com/p/air-max" }
},
"buttons": [
{ "type": "web_url", "title": "Buy now", "url": "https://shop.example.com/p/air-max" }
]
}

card requires buttons, subtitle is capped at 80 characters, and both image_url and default_action.url must be https://.

Route taps by payload

A tap arrives as an inbound message with a tapped_action object, on both the message and the message.received webhook. No digging through raw platform payloads:

app.post("/webhooks/postproxy", async (req, res) => {
res.sendStatus(200); // ack fast, work after
const event = req.body;
if (event.type !== "message.received") return;
const msg = event.data.object;
const payload = msg.tapped_action?.payload;
if (!payload) return; // a typed message, not a tap
const replies = {
TRACK: "Send me your order number and I'll look it up.",
HELP: "Connecting you with the team — expect a reply within the hour.",
RETURN: "Returns are free within 30 days. Want me to start one?",
};
await fetch(`https://api.postproxy.dev/api/chats/${msg.chat_id}/messages`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.POSTPROXY_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ body: replies[payload] ?? replies.HELP }),
});
});

Agents get the same thing without the HTTP layer: the MCP server’s dm_message_send takes quick_replies, buttons, and card, and dm_messages_list returns tapped_action on the taps that come back.

tapped_action.kind tells you what was tapped — quick_reply for a chip, postback for a button or an Instagram ice breaker, callback_query for a Telegram inline button. A tap also opens the 24-hour messaging window, so the reply above is an ordinary send with no tag required.

Instagram is stricter than Messenger

Instagram delivers quick replies only on a plain-text message. These two combinations return 422 on Instagram but are accepted on Facebook:

  • quick_replies together with media
  • quick_replies together with buttons

Buttons themselves work on both networks. If you send the same flow to both, keep chips on text-only messages and you never hit the difference.

The Telegram equivalent

quick_replies, buttons, and card are Meta parameters — sending them on a Telegram chat returns 422. Telegram has its own, richer mechanism: pass reply_markup with an inline_keyboard, a custom keyboard, force_reply, or { "remove_keyboard": true }. Taps on an inline button come back through the same tapped_action field with kind: "callback_query", so one handler covers all three networks.

Limits at a glance

Quick repliesButtons
Max per send133
titleRequired, ≤20 charsRequired, ≤20 chars
payloadRequired, ≤1000 charsRequired for postback, ≤1000 chars
urlRequired for web_url, https:// only
body requiredYesYes, capped at 80 chars
With mediaFacebook onlyNot allowed
InstagramPlain text onlyYes
TelegramNo — use reply_markupNo — use reply_markup

Full parameter and error details: Quick replies and buttons. For the surrounding flow, see How to Send Facebook Messenger Messages via API and How to Send Instagram DMs via API.

Ready to get started?

Start with our free plan and scale as your needs grow. No credit card required.