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.

Google keeps two different structured catalogues on a location, and a location gets one or the other depending on its primary category:

Food menusService list
WhoRestaurants, cafés, bars, bakeriesSalons, clinics, trades, agencies, most other categories
StructureMenus → sections → items, each with a price and descriptionFlat list of services, each optionally priced
Gatemetadata.canHaveFoodMenus on the locationmetadata.canModifyServiceList on the location
EndpointsGET .../food_menus, PATCH .../update_food_menusGET .../service_list, PATCH .../update_service_list

Check the gate before writing. Both flags come back in the location resource:

Terminal window
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

Terminal window
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:

Terminal window
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 units plus nanos. units is the whole amount as a string; nanos is the fractional part in billionths, so $3.50 is units: "3", nanos: 500000000. Omit nanos for whole amounts.
  • Item descriptions live in the label, not in attributes.
  • Menu-level cuisines and 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:

Terminal window
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:

  • structuredServiceItem references one of Google’s predefined services for the category by serviceTypeId. These get matched to searches like “haircut near me”. The IDs come from the category’s serviceTypes when fetched with view=FULL from the available categories endpoint.
  • freeFormServiceItem is 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:

Terminal window
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

StatusCause
400location_id missing, or menus / serviceItems absent
404The profile cannot see that location_id
422canHaveFoodMenus 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.

Ready to get started?

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