
🤖 Setting up persistent menu and buttons for Facebook Messenger bot
You've launched a bot in Facebook Messenger, it responds to messages, but something is missing. A user opens the chat and sees an empty window. No menu, no hints, no "Get Started" button. First touch, and silence. People simply don't understand what your bot can do.
Three components turn a faceless bot into an understandable interface: a persistent menu with quick actions, a "Get Started" button for first contact, and greeting text that welcomes the user by name. Previously, all of this was configured through the deprecated thread_settings parameter. Now Meta has moved control to the unified Messenger Profile API, and old cURL calls from 2017 tutorials simply don't work.
In this guide, the current approach to configuring the persistent menu, Get Started button, and greeting through the Messenger Profile API. With working cURL examples and a PHP POSTBACK handler that distinguishes clicks on menu items.
💡 Quick overview:
- Configure persistent menu through
persistent_menuin Messenger Profile API: up to 20 items, localization, composer disabling - Add "Get Started" button through
get_startedparameter: on first touch Facebook returns the user's PSID - Set up greeting text through
greeting:{{user_first_name}}templates still work in 2026 - Write a PHP POSTBACK handler: switch on
payloaddistinguishes which menu item the user clicked - Consider limits: 10 Profile API calls per 10 minutes per page, up to 24 hours for menu cache updates
What changed: Thread Settings → Messenger Profile API
Before 2020, menu, "Get Started" button, and greeting were configured with separate POST requests to the /me/thread_settings endpoint. The setting type was specified in the body via setting_type: call_to_actions for menu, greeting for greeting. In 2026, this approach doesn't work, the endpoint has been removed from the documentation.
Now all bot properties are set through Messenger Profile API, a unified endpoint:
1 POST https://graph.facebook.com/v22.0/me/messenger_profile?access_token=PAGE_ACCESS_TOKEN
The request body is a JSON object with the needed properties: persistent_menu for menu, get_started for "Get Started" button, greeting for greeting, ice_breakers and whitelisted_domains. You can pass them together or separately. The profile is overwritten, don't pass a property if you don't want to change it.
Official documentation: Persistent Menu on Meta for Developers. The API version updates every six months, check the current one via Changelog.
Requirements for menu to work, current as of June 2026:
- Facebook page is published, bot switched to "public" mode in app settings
- App has
pages_messagingpermission - User runs Messenger version 106 or higher
- "Get Started" button is configured (without it menu doesn't show)
- You have page administrator role

1. Persistent menu (persistent_menu)
The menu hangs to the left of the input field, the user clicks the "hamburger" icon and sees a list of actions. This is top-level navigation: "Help", "Catalog", "Support". Up to 20 items, but Meta recommends limiting to five for better UX.
Each item is an object with type (postback or web_url), title (up to 30 characters) and either payload or url. Emoji in titles work, copy from getemoji.com.
Setting up menu. Send POST to Messenger Profile API:
1 curl -X POST -H "Content-Type: application/json" -d '{ 2 "persistent_menu": [ 3 { 4 "locale": "default", 5 "composer_input_disabled": false, 6 "call_to_actions": [ 7 { 8 "type": "postback", 9 "title": "🆘 Help", 10 "payload": "HELP_PAYLOAD" 11 }, 12 { 13 "type": "postback", 14 "title": "📰 News", 15 "payload": "LATEST_POSTS_PAYLOAD" 16 }, 17 { 18 "type": "web_url", 19 "title": "🌐 Website", 20 "url": "https://yoursite.com/", 21 "webview_height_ratio": "full" 22 } 23 ] 24 } 25 ] 26 }' "https://graph.facebook.com/v22.0/me/messenger_profile?access_token=PAGE_ACCESS_TOKEN"
Successful response: {"result": "success"}. The menu won't appear immediately, client-side cache updates for up to 24 hours. When testing, delete the conversation and start fresh to see changes instantly.
Removing menu. DELETE with fields parameter:
1 curl -X DELETE "https://graph.facebook.com/v22.0/me/messenger_profile?fields=persistent_menu&access_token=PAGE_ACCESS_TOKEN"
Disabling composer. If the bot works only through menu and buttons, set "composer_input_disabled": true. The input field will disappear, the user interacts exclusively through menu items and postback buttons. Useful for FAQ bots and catalogs.
Localization. Add objects with locale key for each language. Object with "locale": "default" is mandatory, it works as fallback:
1 { 2 "persistent_menu": [ 3 { 4 "locale": "default", 5 "call_to_actions": [...] 6 }, 7 { 8 "locale": "ru_RU", 9 "call_to_actions": [...] 10 } 11 ] 12 }
Custom menu. Through the /me/custom_user_settings endpoint you can override the menu for a specific user by PSID. Limit: 10 calls per user per 10 minutes. After removing custom menu, the page menu is restored.
2. Handling POSTBACK in PHP
When a user clicks a menu item with type: "postback", Facebook sends a messaging_postbacks event to your webhook. In the request body, a postback object with a payload field that you set when configuring the menu.
The PHP handler reads incoming JSON from php://input, extracts payload and through switch determines which action to perform. Below, current code for 2026 with v22.0 endpoint:
1 <?php 2 // Read incoming request from Facebook 3 $input = json_decode(file_get_contents('php://input'), true); 4 5 // Extract recipient and sender data 6 $page_id = $input['entry'][0]['id']; 7 $sender = $input['entry'][0]['messaging'][0]['sender']['id']; 8 9 // Determine whether message or postback 10 $message = $input['entry'][0]['messaging'][0]['message']['text'] ?? ''; 11 $postback = $input['entry'][0]['messaging'][0]['postback']['payload'] ?? ''; 12 13 if ($message || $postback) { 14 15 if ($message) { 16 $reply = 'Message received: ' . $message; 17 } else { 18 switch ($postback) { 19 case 'HELP_PAYLOAD': 20 $reply = 'You clicked the "Help" button. How can I help?'; 21 break; 22 23 case 'LATEST_POSTS_PAYLOAD': 24 $reply = 'Here are fresh posts from this week.'; 25 break; 26 27 default: 28 $reply = 'Action not recognized. Try again.'; 29 } 30 } 31 32 // Form response 33 $responseJSON = json_encode([ 34 'recipient' => ['id' => $sender], 35 'message' => ['text' => $reply], 36 ]); 37 38 $access_token = 'YOUR_PAGE_ACCESS_TOKEN'; 39 $url = 'https://graph.facebook.com/v22.0/me/messages?access_token=' . $access_token; 40 41 // Send via cURL 42 $ch = curl_init($url); 43 curl_setopt($ch, CURLOPT_POST, 1); 44 curl_setopt($ch, CURLOPT_POSTFIELDS, $responseJSON); 45 curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); 46 $result = curl_exec($ch); 47 curl_close($ch); 48 }
What changed. API version in URL raised from v2.7 to v22.0. Instead of manual JSON string assembly, json_encode() is used, less chance to break escaping. Added default block in switch for unrecognized payload. ?? (null coalescing) construct is cleaner than old isset() with ternaries.
Where to place. The code goes in the file pointed to by the Facebook app webhook URL. Usually this is webhook.php in the site root. Make sure the GET method on the same URL handles webhook verification via hub_challenge.
Important. Store access token in environment variables or config, not in code. For local development use .env file with PAGE_ACCESS_TOKEN=....
3. Get Started button (get_started)
The button appears on the welcome screen at first contact with the page. After clicking, Facebook sends messaging_postbacks with the payload you specified. In response, you can greet the user by name or show a menu of buttons.
Without a configured "Get Started" button, the persistent menu doesn't display, this is a mandatory platform requirement.
Setup:
1 curl -X POST -H "Content-Type: application/json" -d '{ 2 "get_started": { 3 "payload": "GET_STARTED_PAYLOAD" 4 } 5 }' "https://graph.facebook.com/v22.0/me/messenger_profile?access_token=PAGE_ACCESS_TOKEN"
Payload is an arbitrary string up to 1000 characters. In the PHP handler, add case 'GET_STARTED_PAYLOAD' in switch and return a personalized greeting.
Removal:
1 curl -X DELETE "https://graph.facebook.com/v22.0/me/messenger_profile?fields=get_started&access_token=PAGE_ACCESS_TOKEN"
4. Greeting text (greeting)
The greeting is shown in an empty chat before the first message. Supports templates {{user_first_name}} {{user_last_name}} and {{user_full_name}}. Personalization by name increases engagement, the user sees that the bot is addressing them specifically.
Setup:
1 curl -X POST -H "Content-Type: application/json" -d '{ 2 "greeting": [ 3 { 4 "locale": "default", 5 "text": "Hi, {{user_first_name}}! I am a helper bot. Ask a question or open the menu on the left." 6 } 7 ] 8 }' "https://graph.facebook.com/v22.0/me/messenger_profile?access_token=PAGE_ACCESS_TOKEN"
Greeting text also supports localization, add objects with locale key.
Removal:
1 curl -X DELETE "https://graph.facebook.com/v22.0/me/messenger_profile?fields=greeting&access_token=PAGE_ACCESS_TOKEN"
5. Ice Breakers: common questions before first message
Ice Breakers is a relatively new Messenger Platform feature. These are buttons with ready-made questions that appear before the user has written anything. Clicking sends the text on behalf of the user and starts the dialogue.
Convenient for onboarding: instead of an empty window, a person sees "What can you do?", "Where is my order?", "Contact support" and starts the dialogue with one touch.
Setup:
1 curl -X POST -H "Content-Type: application/json" -d '{ 2 "ice_breakers": [ 3 { 4 "question": "What can you do?", 5 "payload": "ICE_CAPABILITIES" 6 }, 7 { 8 "question": "Where is my order?", 9 "payload": "ICE_ORDER_STATUS" 10 } 11 ] 12 }' "https://graph.facebook.com/v22.0/me/messenger_profile?access_token=PAGE_ACCESS_TOKEN"
Up to 4 questions, maximum 80 characters per question. Payload is handled in the same PHP handler through switch.
⁉️🤔 Frequently asked questions
Why doesn't the menu appear even though the request returned success?
Main reasons: "Get Started" button not configured, page not published, app in development mode, user using old Messenger version or Facebook Mobile Browser. Menu is cached locally, client-side update takes up to 24 hours. For testing, delete the conversation with the bot and start fresh. If menu still not visible, check all points: page published, bot public,
pages_messagingpermission obtained, "Get Started" button configured.
Can you create a nested menu?
Yes, through the
call_to_actionsparameter inside a menu item. Nesting supports one level, submenu expands when clicking on parent item. Format is similar to main menu: array of objects withtype,titleandpayload/url. Parent item with nested menu cannot beweb_url, onlypostback. Maximum depth is one level. This is a platform limitation, cannot be circumvented.
How does user-level menu differ from page-level?
Page-level menu is one for all page users. User-level through
/me/custom_user_settingsallows showing different items to different people: new users get "What I can do", returning users get "Order history". User-level updates in real time, page-level with delay up to 24 hours. Limit: 10 calls per user per 10 minutes. After removing custom menu, page menu automatically restores.
How to check that webhook receives POSTBACK?
Enable debug mode in Facebook app settings and click a menu item in chat with bot. Logs will show an entry with
postback.payloadfield. Alternatively adderror_log(print_r($input, true))at the start of handler and check server logs. For local development use ngrok: it tunnels a public HTTPS URL to your localhost with a valid certificate.
Do you need to update code if API version changes?
Yes, twice a year. Meta releases a new API version every six months, disables old ones 2 years after replacement release. Follow Changelog Messenger Platform. In code, just replace version number in URL, call logic changes rarely. Scheduled deprecation: v19.0, January 2026, v20.0, May 2026, v21.0, October 2026. Version v22.0 active until May 2027. Set
$api_versionvariable in code.
What to do with the bot after setup: checklist
Menu, button, and greeting are the foundation, not the finish. When basic mechanics work, three steps turn the bot from a business card into a useful tool:
Connect analytics. Facebook Messenger Insights shows opens, sent and received messages, active dialogues. Without this data you won't know which menu items are actually used.
Configure fallback response. When a user writes text not provided for in the scenario, the bot should respond meaningfully, not stay silent. Add a default branch to the handler with an offer to open the menu or contact a human.
Update menu seasonally. Promotions, new products, holiday sales, change menu items throughout the year. Through Messenger Profile API this is done with one POST request.
If the bot handles orders or collects contacts, configure domain in whitelisted_domains for correct WebView operation inside Messenger. And don't forget rate limits: 10 Profile API calls per 10 minutes per page. Queue batch menu updates for thousands of users with delays.



