GuideAnleitungen/Google Business Profile

Diese Anleitung ist auf Englisch verfügbar.

How do I connect and manage Google Business Profiles through an API?

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.

Auf einen Blick

Was diese Anleitung leistet
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.
Verwendete APIs
Referenz
ConnectionsVeröffentlichte EinträgeLocations
Voraussetzungen
  • 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
Typische Anwendungsfälle
  • 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.
  1. Connect URLGET /api/v1/connections/google/connect-urlOne per location, with a return path
  2. Owner consentsIn their own browser, on Google's screen
  3. Synup fetches listingsEvery listing the account can manage, scored against your locations
  4. Pairconfirm-match or locations/assignAutomatic when the match is clear, otherwise your call
  5. SyncGET /api/v1/listingsGoogle row moves from not_connected to synced
Connecting a location to Google

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 owner

For 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-match accepts 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/assign pairs 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.google sets the primary Google category. Send both id and name, from GET /api/v1/locations/publisher-categories. Send one alone and Google's category is cleared.
  • attributes is 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 from GET /api/v1/locations/{id} and send it back with your changes. Ids Google rejects for this category come back in rejectedAttributes.
  • verificationStatus on the location is Google's own verification state: verified, pending, unverified or unknown. Updates to a pending or unverified profile 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:

  1. The Google row in the listing overview reports expired or credentials_invalidated.
  2. GET /api/v1/connections with credentialsValid=false lists 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

SituationWhat happensWhat to do
The owner never finishes consentNo account appears; nothing errorsStop polling after a ceiling and send a reminder with the same URL.
The wrong Google account was usedListings fetched don't match any locationHave the right owner use the URL; the scorer won't pair unrelated listings.
One location, two Google listingsOne pairs, the other shows up as a duplicateResolve it with the duplicates guide.
confirm-match returns 409The listing or the location is already connectedRead the connections list. Nothing to do.
request-matches returns 429Called twice within 24 hours for one accountWait until retryAt.
Google row stuck in pending_approvalGoogle is reviewing the listingWait. Nothing to retry.