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):
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.
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_repliestogether withmediaquick_repliestogether withbuttons
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 replies | Buttons | |
|---|---|---|
| Max per send | 13 | 3 |
title | Required, ≤20 chars | Required, ≤20 chars |
payload | Required, ≤1000 chars | Required for postback, ≤1000 chars |
url | — | Required for web_url, https:// only |
body required | Yes | Yes, capped at 80 chars |
With media | Facebook only | Not allowed |
| Plain text only | Yes | |
| Telegram | No — use reply_markup | No — 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.