A multi-store fashion retailer keeps each shop's weekly and holiday opening hours in a headless CMS. When an editor publishes, a signed webhook hits a Next.js route handler that maps the record and calls the Google Business Profile Business Information API; a Vercel cron runs the same job overnight. Shoppers check Google Maps (the public listing behind the pin) before they visit. Store editors treat the CMS as the source of truth for those hours.
One morning a listing showed no opening times at all, while holiday exceptions sometimes stuck around from an older sync. The break showed up when someone saved a Google location id before the weekly grid was filled in, or when they cleared every weekday row while specials were still on the form.
Google Business Profile is Google's business listing product (Maps hours, address, phone). A field mask (updateMask on locations.patch) lists which fields you intend to overwrite; under AIP-134 semantics, a named field with an empty value in the body is cleared, not left alone. Regular hours are the repeating weekly schedule (regularHours.periods). Special hours are date-specific exceptions like Christmas (specialHours.specialHourPeriods). A one-way sync pushes CMS state to Google and does not read the listing back into the CMS on success.
Why PATCH felt safe and was not
I had a mapper that turned CMS weekdays into regularHours.periods and holiday rows into specialHourPeriods. Unit tests passed on empty input: no open days means an empty array, which looked correct in isolation.
Production still had live hours on Maps from an earlier good publish. The next publish sent regularHours: { periods: [] } with updateMask=regularHours because the editor had not finished the weekly grid. Google replaced the masked field with nothing. That is not "no change"; it is delete what shoppers see.
Google also rejects specialHours when regularHours is empty (SPECIAL_HOURS_SET_WITHOUT_REGULAR_HOURS). The workaround I almost shipped was dropping specialHours from the mask so the request returned 200. That dodges the 400 while still clearing regular hours, which is worse for shoppers.
A review bot flagged the mask branch before merge. Staging dry runs logged payloads but never compared them to what Maps already showed, so the wipe stayed invisible until someone opened the listing on a phone.
Guard before the vendor call
I stopped inferring "clear Maps" from "CMS has no value yet." The sync planner runs before locations.patch:
type Outcome =
| { status: 'synced' }
| { status: 'skipped'; reason: 'missing_regular_hours' | 'specials_without_regular_hours' }
| { status: 'failed'; error: string }
function planPush(body: VendorHours): Outcome | null {
if (body.regularHours.periods.length === 0) {
return {
status: 'skipped',
reason: body.specialHours.specialHourPeriods.length
? 'specials_without_regular_hours'
: 'missing_regular_hours',
}
}
return null
}Skipped stores stay out of the batch error count. Only real vendor failures should make the route return something like 502. The HTTP client still throws if specials arrive without regulars, in case another caller bypasses the planner.
Permanent closure is different: use an explicit "closed" or "clear listing hours" flag in the CMS, not an empty weekly grid left mid-edit.
For a related sync trap (remote config overwriting live console state), see A kill switch must default to on, at both ends.
Pick New store: id first, hours later, leave Naive sync selected, and press Publish. The Maps card drops its weekday hours while the log shows a 200 with updateMask=regularHours. Switch to Guarded sync, publish again, and the listing stays intact with skipped · missing_regular_hours. Try Holiday only on naive versus guarded to see the 400 or the silent regular-hours wipe.
Simulation
Empty source is not a clear
Mock CMS record, computed Google PATCH payload, and a Maps listing that keeps state between publishes.
CMS store record
Weekly hours
- Mon
- Tue
- Wed
- Thu
- Fri
- Sat
- Sun
Special hours
- Dec 25: closed
- Dec 31: closes 16:00
Next publish payload
{
"regularHours": {
"periods": [
{
"closeDay": "monday",
"closeTime": "18:00",
"openDay": "monday",
"openTime": "10:00"
},
{
"closeDay": "tuesday",
"closeTime": "18:00",
"openDay": "tuesday",
"openTime": "10:00"
},
{
"closeDay": "wednesday",
"closeTime": "18:00",
"openDay": "wednesday",
"openTime": "10:00"
},
{
"closeDay": "thursday",
"closeTime": "18:00",
"openDay": "thursday",
"openTime": "10:00"
},
{
"closeDay": "friday",
"closeTime": "18:00",
"openDay": "friday",
"openTime": "10:00"
},
{
"closeDay": "saturday",
"closeTime": "18:00",
"openDay": "saturday",
"openTime": "10:00"
}
]
},
"specialHours": {
"specialHourPeriods": [
{
"closed": true,
"id": "dec-25",
"label": "Dec 25"
},
{
"closed": false,
"closeTime": "16:00",
"id": "dec-31",
"label": "Dec 31"
}
]
}
}Google Maps listing (live)
6 weekday block(s) · No special hours
- monday: 10:00–18:00
- tuesday: 10:00–18:00
- wednesday: 10:00–18:00
- thursday: 10:00–18:00
- friday: 10:00–18:00
- saturday: 10:00–18:00
Publish log
- No publishes yet.
Reusable rule
In a one-way sync to an external system, "the source has no value" and "the destination should be cleared" are different instructions. Never let the first become the second by default, and make clearing an explicit signal.
Skip the pattern when the destination is rebuilt from scratch every run (search index, cache warmers) or when the API is truly merge-only with no mask-clear semantics (still verify in docs). When empty source should clear, name that intent in the model.
Happy coding! Sander