Esta guía está disponible en inglés.
How to Connect and Manage Google Business Profiles via API
Connect a Google Business Profile to a location, match the account's listings, keep the profile in sync and handle reconnects with the Synup API.
De un vistazo
- Qué logra esta guía
- Get a location connected to its Google Business Profile, pair the right Google listing to the right location, push profile changes to Google, and detect a broken connection before customers do.
- APIs utilizadas
- get
/api/v1/connections/google/connect-urlObtener una URL de conexión de Google - get
/api/v1/connectionsListar cuentas conectadas - get
/api/v1/connections/summaryObtener un resumen de cuentas conectadas - post
/api/v1/connections/request-matchesSolicitar nuevas sugerencias de coincidencia - post
/api/v1/connections/locations/confirm-matchConfirmar una coincidencia sugerida - post
/api/v1/connections/locations/assignAsignar un listado a una ubicación - post
/api/v1/connections/fetch-listingsForzar una nueva extracción de los listados de una cuenta - get
/api/v1/listingsObtener el listado de una ubicación - get
/api/v1/locations/{id}Obtener una ubicación - patch
/api/v1/locations/{id}Actualizar una ubicación - post
/api/v1/locations/{id}/mediaSubir fotos a una ubicación
- get
- Referencia
- ConnectionsListados publicadosLocations
- Requisitos previos
- A location created in Synup
- An API key with read and write access to Connections and Locations
- A person who can sign in to the Google account that owns the Business Profile
- Casos de uso habituales
- Onboarding flows that connect a new customer's Google profile without a support ticket
- Multi-location brands pairing hundreds of Google listings to the right locations
- Products that update Google hours, categories, attributes and photos from their own data
Google Business Profile is the one publisher that needs the business owner's own sign-in. The API gives you a Google authorisation URL for a location and the owner approves access in a browser. From then on Synup holds the connection. It fetches the account's listings, pairs them to your locations, pushes profile changes, pulls reviews and analytics, and tells you when the credentials stop working. You never call Google's APIs or apply for Google's API access yourself.
What the API can and can't do with Google
- Can: produce the connect URL, list connected accounts and their health, re-fetch an account's listings, score which listing belongs to which location, confirm or assign a pairing, push every profile change, upload photos, read the profile completeness score and Google verification state.
- Cannot: complete the OAuth consent screen. Google requires an interactive human, and there's no callback to your integration when they finish. You poll instead.
- Connect URL
GET /api/v1/connections/google/connect-urlOne per location, with a return path - Owner consentsIn their own browser, on Google's screen
- Synup fetches listingsEvery listing the account can manage, scored against your locations
- Pair
confirm-match or locations/assignAutomatic when the match is clear, otherwise your call - Sync
GET /api/v1/listingsGoogle row moves from not_connected to synced
Send the owner to Google
GET /api/v1/connections/google/connect-url returns the authorisation URL for one location. Pass returnUrl to choose
the in-app path the owner lands on afterwards. Open the URL in the account owner's browser: an email link, a
button in your onboarding flow, or a shared link to whoever holds the Google login.
curl "https://ai.synup.com/api/v1/connections/google/connect-url?locationId=LOCATION_ID&returnUrl=/connected" \
-H "Authorization: Bearer $SYNUP_API_KEY"const API = "https://ai.synup.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.SYNUP_API_KEY}`,
"Content-Type": "application/json",
};
async function googleConnectUrl(locationId) {
const params = new URLSearchParams({ locationId, returnUrl: "/connected" });
const res = await fetch(`${API}/connections/google/connect-url?${params}`, { headers });
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data.url; // { provider: "google", locationId, url, note }
}import os
import requests
API = "https://ai.synup.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNUP_API_KEY']}"}
def google_connect_url(location_id):
r = requests.get(f"{API}/connections/google/connect-url", headers=HEADERS, timeout=30,
params={"locationId": location_id, "returnUrl": "/connected"})
r.raise_for_status()
return r.json()["data"]["url"]The URL is meant for a person. Don't fetch it server-side. Google's consent screen must run in the owner's browser session.
Poll until the account appears
GET /api/v1/connections lists the agency's connected publisher accounts, filterable by client and
platform. Poll it after handing out the URL, but cap the polling. Check every minute for the first ten minutes,
then back off to hourly, then stop and remind the owner.
curl "https://ai.synup.com/api/v1/connections?clientId=CLIENT_ID&platform=google&credentialsValid=true" \
-H "Authorization: Bearer $SYNUP_API_KEY"async function googleAccounts(clientId) {
const params = new URLSearchParams({ clientId, platform: "google", credentialsValid: "true", limit: "100" });
const res = await fetch(`${API}/connections?${params}`, { headers });
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data.accounts; // paginated with data.nextCursor
}
async function waitForGoogle(clientId, before, { attempts = 10, everyMs = 60_000 } = {}) {
for (let i = 0; i < attempts; i++) {
const accounts = await googleAccounts(clientId);
if (accounts.length > before) return accounts;
await new Promise((r) => setTimeout(r, everyMs));
}
return null; // remind the owner
}import time
def google_accounts(client_id):
r = requests.get(f"{API}/connections", headers=HEADERS, timeout=30,
params={"clientId": client_id, "platform": "google",
"credentialsValid": "true", "limit": 100})
r.raise_for_status()
return r.json()["data"]["accounts"] # paginated with data["nextCursor"]
def wait_for_google(client_id, before, attempts=10, every_s=60):
for _ in range(attempts):
accounts = google_accounts(client_id)
if len(accounts) > before:
return accounts
time.sleep(every_s)
return None # remind the ownerFor a quick "how many of my locations are connected" number, GET /api/v1/connections/summary returns
connected and not-connected counts for Google and Facebook across a client, optionally narrowed by tags.
Pair the right listing to the right location
An account often manages more than one listing. When Synup fetches them it scores each against your locations on name, address and phone, and where the match is clear the pairing is made for you. Two endpoints cover the rest:
POST /api/v1/connections/locations/confirm-matchaccepts a fetched listing that Synup has already matched to a location but not connected. It returns 400 when the listing has no suggested location, and 409 when it is already connected or the location already has a Google connection.POST /api/v1/connections/locations/assignpairs a listing from an already-connected account to a location that has no Google connection yet, by the listing's Google resource name. Use it when you know the answer and the scorer doesn't.
# Accept Synup's suggestion for a fetched listing
curl -X POST https://ai.synup.com/api/v1/connections/locations/confirm-match \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "fetchedListingId": "FETCHED_LISTING_ID" }'
# Or pair a specific Google listing to a specific location yourself
curl -X POST https://ai.synup.com/api/v1/connections/locations/assign \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "google",
"caId": "CONNECTED_ACCOUNT_ID",
"locationId": "LOCATION_ID",
"platformResourceName": "locations/1234567890",
"platformPageName": "Grove Street Dental Mission"
}'async function confirmMatch(fetchedListingId) {
const res = await fetch(`${API}/connections/locations/confirm-match`, {
method: "POST",
headers,
body: JSON.stringify({ fetchedListingId }),
});
if (res.status === 409) return null; // already connected, or the location already has Google
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
const { data } = await res.json();
return data; // { id, platform, clientLocationId, platformResourceName, platformPageName }
}
async function assignListing({ caId, locationId, platformResourceName, platformPageName }) {
const res = await fetch(`${API}/connections/locations/assign`, {
method: "POST",
headers,
body: JSON.stringify({ platform: "google", caId, locationId, platformResourceName, platformPageName }),
});
if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).error}`);
return (await res.json()).data;
}def confirm_match(fetched_listing_id):
r = requests.post(f"{API}/connections/locations/confirm-match", headers=HEADERS,
json={"fetchedListingId": fetched_listing_id}, timeout=30)
if r.status_code == 409:
return None # already connected, or the location already has Google
r.raise_for_status()
return r.json()["data"]
def assign_listing(ca_id, location_id, resource_name, page_name):
r = requests.post(f"{API}/connections/locations/assign", headers=HEADERS, timeout=30,
json={"platform": "google", "caId": ca_id, "locationId": location_id,
"platformResourceName": resource_name, "platformPageName": page_name})
r.raise_for_status()
return r.json()["data"]One honest gap: the REST API has no endpoint that lists an account's fetched Google listings, so there is no
REST call that hands you a fetchedListingId or a listing's platformResourceName. Those come from browsing the
account's listings, which today lives in the Synup app's connection screen (the MCP server exposes it
programmatically as get_google_listings). So confirm-match and assign are reachable over REST, but the ids
they need are not yet listable over REST. If you're pairing entirely through your own backend, use the MCP
tool for the browse step, or pair in the app and let Synup's automatic name, address and phone matching do the
rest.
Two maintenance calls keep the pairing data fresh. POST /api/v1/connections/fetch-listings re-fetches an
account's listings from Google and runs synchronously, so its response already reflects the new set.
POST /api/v1/connections/request-matches re-runs the name, address and phone scoring over what is already stored;
it is limited to once per 24 hours per account and returns 429 with a retryAt timestamp inside that window.
Manage the profile through Synup
Once connected, Google is just another publisher. Changes made with PATCH /api/v1/locations/{id} are queued to
Google along with everyone else, photos from POST /api/v1/locations/{id}/media are pushed to the profile, and
GET /api/v1/listings reports the Google row's sync status plus a gbp block with a completeness
score, the improvements already done and the ones still to do.
Three Google-specific fields on the location matter here:
publisherCategories.googlesets the primary Google category. Send bothidandname, fromGET /api/v1/locations/publisher-categories. Send one alone and Google's category is cleared.attributesis the Google Business Profile attribute map, for example{ "attributes/wi_fi": "free" }. It replaces the whole set on every update, so read the current list fromGET /api/v1/locations/{id}and send it back with your changes. Ids Google rejects for this category come back inrejectedAttributes.verificationStatuson the location is Google's own verification state:verified,pending,unverifiedorunknown. Updates to apendingorunverifiedprofile are accepted by Synup but not shown by Google until verification completes.
curl -X PATCH https://ai.synup.com/api/v1/locations/LOCATION_ID \
-H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"publisherCategories": { "google": { "id": "gcid:dentist", "name": "Dentist" } },
"attributes": { "attributes/wi_fi": "free", "attributes/wheelchair_accessible_entrance": true }
}'Detect a broken connection
Google tokens expire and owners change passwords. Two signals tell you before a customer does:
- The Google row in the listing overview reports
expiredorcredentials_invalidated. GET /api/v1/connectionswithcredentialsValid=falselists accounts whose token has actually gone bad.
credentialsValid is a blunt flag, though. Each account also carries a connectionStatus of CONNECTED,
RENEW, DISCONNECTED, SUSPENDED, MISSING or SUGGESTED_MATCH, and MISSING (a listing Synup found but
nobody has paired yet) reads as credentialsValid: true. Key your "needs attention" view off connectionStatus,
not the boolean alone, or you will miss the accounts that are waiting to be paired.
Fixing it is the first connection over again. Issue a new connect URL for the location and send the owner back through Google's screen. Build that check into a daily job and a banner, and a reconnect is a click for the owner rather than a support ticket for you.
When a connection goes wrong
| Situation | What happens | What to do |
|---|---|---|
| The owner never finishes consent | No account appears; nothing errors | Stop polling after a ceiling and send a reminder with the same URL. |
| The wrong Google account was used | Listings fetched don't match any location | Have the right owner use the URL; the scorer won't pair unrelated listings. |
| One location, two Google listings | One pairs, the other shows up as a duplicate | Resolve it with the duplicates guide. |
confirm-match returns 409 | The listing or the location is already connected | Read the connections list. Nothing to do. |
request-matches returns 429 | Called twice within 24 hours for one account | Wait until retryAt. |
Google row stuck in pending_approval | Google is reviewing the listing | Wait. Nothing to retry. |