How to Manage Google Business Profile Menus and Services via API
Publish a full food menu with sections, items, and prices to a restaurant's Google Business Profile, or maintain the service list on any other business type — programmatically, with the eligibility checks that decide which one applies.
Menus or services: which one applies
Google keeps two different structured catalogues on a location, and a location gets one or the other depending on its primary category:
| Food menus | Service list | |
|---|---|---|
| Who | Restaurants, cafés, bars, bakeries | Salons, clinics, trades, agencies, most other categories |
| Structure | Menus → sections → items, each with a price and description | Flat list of services, each optionally priced |
| Gate | metadata.canHaveFoodMenus on the location | metadata.canModifyServiceList on the location |
| Endpoints | GET .../food_menus, PATCH .../update_food_menus | GET .../service_list, PATCH .../update_service_list |
Check the gate before writing. Both flags come back in the location resource:
curl "https://api.postproxy.dev/api/profiles/prof_abc123/google_business/location?location_id=accounts/113344/locations/558899&read_mask=name,title,categories,metadata" \ -H "Authorization: Bearer YOUR_API_KEY"{ "name": "locations/558899", "title": "Acme Coffee", "categories": { "primaryCategory": { "name": "categories/gcid:coffee_shop", "displayName": "Coffee shop" } }, "metadata": { "canHaveFoodMenus": true, "canModifyServiceList": false }}A write against a surface the location is not eligible for returns 422 naming the flag. Every request takes location_id, the placement id from List placements.
Read the current menus
curl "https://api.postproxy.dev/api/profiles/prof_abc123/google_business/food_menus?location_id=accounts/113344/locations/558899" \ -H "Authorization: Bearer YOUR_API_KEY"{ "name": "locations/558899/foodMenus", "menus": [ { "labels": [{ "displayName": "Main menu", "languageCode": "en" }], "sections": [ { "labels": [{ "displayName": "Drinks", "languageCode": "en" }], "items": [ { "labels": [{ "displayName": "Filter coffee", "languageCode": "en" }], "attributes": { "price": { "currencyCode": "USD", "units": "3" } } } ] } ] } ]}An empty menus array means nothing has been published yet, which is also what a location with canHaveFoodMenus: true returns before its first write.
Publish a food menu
The update replaces the whole menu set. Send every menu, section, and item you want to exist:
curl -X PATCH "https://api.postproxy.dev/api/profiles/prof_abc123/google_business/update_food_menus" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "location_id": "accounts/113344/locations/558899", "menus": [ { "labels": [{ "displayName": "Main menu", "languageCode": "en" }], "sections": [ { "labels": [{ "displayName": "Drinks", "languageCode": "en" }], "items": [ { "labels": [{ "displayName": "Filter coffee", "description": "Single-origin, brewed to order", "languageCode": "en" }], "attributes": { "price": { "currencyCode": "USD", "units": "3", "nanos": 500000000 } } }, { "labels": [{ "displayName": "Flat white", "languageCode": "en" }], "attributes": { "price": { "currencyCode": "USD", "units": "4" } } } ] }, { "labels": [{ "displayName": "Pastries", "languageCode": "en" }], "items": [ { "labels": [{ "displayName": "Almond croissant", "languageCode": "en" }], "attributes": { "price": { "currencyCode": "USD", "units": "4", "nanos": 250000000 } } } ] } ] } ] }'Shape rules:
- Every label carries a
languageCode. Add a second label object with another code to publish the menu in two languages. - Prices are
unitsplusnanos.unitsis the whole amount as a string;nanosis the fractional part in billionths, so $3.50 isunits: "3", nanos: 500000000. Omitnanosfor whole amounts. - Item descriptions live in the label, not in
attributes. - Menu-level
cuisinesand section-level ordering follow Google’s schema; the response you read back shows any extra fields the location already uses, and those can be edited and returned unchanged.
Google displays the menu in the listing’s Menu tab and uses item names in search matching, so a dish name that customers search for belongs in displayName, not only in the description.
Read and replace the service list
For non-restaurant locations:
curl "https://api.postproxy.dev/api/profiles/prof_abc123/google_business/service_list?location_id=accounts/113344/locations/558899" \ -H "Authorization: Bearer YOUR_API_KEY"{ "name": "locations/558899/serviceList", "serviceItems": [ { "freeFormServiceItem": { "category": "categories/gcid:hair_salon", "label": { "displayName": "Balayage", "languageCode": "en" } } }, { "structuredServiceItem": { "serviceTypeId": "job_type_id:haircut", "price": { "currencyCode": "USD", "units": "45" } } } ]}Two item kinds appear:
structuredServiceItemreferences one of Google’s predefined services for the category byserviceTypeId. These get matched to searches like “haircut near me”. The IDs come from the category’sserviceTypeswhen fetched withview=FULLfrom the available categories endpoint.freeFormServiceItemis a custom service with your own name, attached to one of the location’s categories.
Both take an optional price and a description on the label (free-form) or item (structured).
Update by sending the complete list:
curl -X PATCH "https://api.postproxy.dev/api/profiles/prof_abc123/google_business/update_service_list" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "location_id": "accounts/113344/locations/558899", "serviceItems": [ { "structuredServiceItem": { "serviceTypeId": "job_type_id:haircut", "price": { "currencyCode": "USD", "units": "50" } } }, { "freeFormServiceItem": { "category": "categories/gcid:hair_salon", "label": { "displayName": "Balayage", "description": "Hand-painted highlights, 2–3 hours", "languageCode": "en" }, "price": { "currencyCode": "USD", "units": "180" } } } ] }'Anything not in the array is removed. To add one service, read the list, append, and send it all back.
Keep prices in sync from a POS or booking system
Because both endpoints replace wholesale, the simplest integration renders the full catalogue from your source of truth on every change rather than diffing:
const BASE = "https://api.postproxy.dev";const headers = { Authorization: `Bearer ${process.env.POSTPROXY_API_KEY}`, "Content-Type": "application/json",};
function toGoogleMenu(products) { const bySection = Map.groupBy(products, (p) => p.section); return [{ labels: [{ displayName: "Main menu", languageCode: "en" }], sections: [...bySection].map(([section, items]) => ({ labels: [{ displayName: section, languageCode: "en" }], items: items.map((p) => ({ labels: [{ displayName: p.name, description: p.description, languageCode: "en" }], attributes: { price: { currencyCode: "USD", units: String(Math.floor(p.price)), nanos: Math.round((p.price % 1) * 1e9) } }, })), })), }];}
const { data: locations } = await fetch(`${BASE}/api/profiles/${PROFILE}/placements`, { headers }) .then((r) => r.json());
for (const loc of locations) { const products = await loadMenuForLocation(loc.id); // your POS await fetch(`${BASE}/api/profiles/${PROFILE}/google_business/update_food_menus`, { method: "PATCH", headers, body: JSON.stringify({ location_id: loc.id, menus: toGoogleMenu(products) }), });}Run it on a price change or nightly. Locations that share a menu get the same payload; locations with local specials get their own product set.
Errors
| Status | Cause |
|---|---|
400 | location_id missing, or menus / serviceItems absent |
404 | The profile cannot see that location_id |
422 | canHaveFoodMenus or canModifyServiceList is false for the location, an unknown serviceTypeId, a label without languageCode, or a malformed price |
Full parameter tables are on the Google Business API reference. Menu and booking links (as opposed to the menu content) are attributes and action links: see attributes for url_menu and place action links for the “Order online” button.