Connect your clients' Google Business Profiles
If you run an agency, a platform or a SaaS, your clients do not have a MauKirim account and you should not have to collect their Google passwords or ask them to forward screenshots. The hosted connect flow lets each client connect their own Google Business Profile in a few clicks, on a page MauKirim hosts, and then send them back to your app. The businesses they connect appear on your account, tagged with your id for that client and switched off until you turn them on, and you manage them with the same API and MCP tools as any other business.
This guide is for the developer integrating it. The other ways to connect (you connect a Google account yourself in the dashboard, or through an agency manager account) are in Google Business Profile.
The flow in one picture
Section titled “The flow in one picture”your server ── POST /gmb/connect-sessions ─────────────▶ MauKirim { externalId, redirectUrl } ◀── { session: { id, url, expiresAt, … } }
your server ── sends the client to session.url ────────▶ client's browser │ MauKirim consent card ◀─┘ "Acme wants to manage your Business Profile" Allow │ Deny ▼ Google's own consent screen (the client signs in to Google) ▼ MauKirim imports the businesses (OFF) ──▶ client's browser is redirected to redirectUrl?maukirim_session=…&status=success &external_id=…&connection_id=…your server ── GET /gmb/connect-sessions/{id} ─────────▶ confirm, then use the businessesyour server ── GET /gmb/locations?externalId=… ────────▶ the client's businesses (disabled, waiting)your server ── POST /gmb/locations/activation ─────────▶ turn on the ones you wantThe client never needs a MauKirim account, never sees your API key and never gives you or MauKirim their Google password. Only they can approve Google’s consent screen, in their own browser.
What you need
Section titled “What you need”- A package that includes the Google Business connector (Growth and above). Without it, creating a session
answers
402 gmb_plan_required. - An API key with the
gmbscope. Reading sessions, return addresses and businesses also works withread. See Authentication. - An HTTPS page of your own that the client is sent back to (
http://localhostis allowed for development).
Step 1 — register your return addresses
Section titled “Step 1 — register your return addresses”MauKirim will only send a browser back to an address you registered in advance. This is what stops the connect page from being used as an open redirect to somebody else’s site. Register the address, or the folder of addresses, your client returns to:
curl -X POST https://app.maukirim.com/api/v1/gmb/redirect-uris \ -H "Authorization: Bearer mk_live_…" \ -H "Content-Type: application/json" \ -d '{ "uri": "https://app.example.com/google/return" }'{ "ok": true, "redirectUri": { "id": "gmr_4k9s2x7q1d", "uri": "https://app.example.com/google/return", "createdAt": "2026-10-14T02:00:00.000Z" }}You can also manage them under Services → Google Business → Return addresses in the dashboard. The rules
are in Return addresses: https only (plain http for localhost,
127.0.0.1 and [::1]), no username or password, no #fragment, no ?query; the address is stored as
origin plus path without a trailing slash. A session’s redirectUrl is accepted when it is equal to or below
a registered address on a path boundary: registering https://app.example.com/google allows
https://app.example.com/google/return?ref=7 but not https://app.example.com/googled and not another host.
Step 2 — create a session for one client
Section titled “Step 2 — create a session for one client”When one of your clients wants to connect, create a session on your server with your id for that client:
curl -X POST https://app.maukirim.com/api/v1/gmb/connect-sessions \ -H "Authorization: Bearer mk_live_…" \ -H "Content-Type: application/json" \ -d '{ "externalId": "client-4821", "redirectUrl": "https://app.example.com/google/return?ref=4821", "label": "Kopi Satu" }'{ "ok": true, "session": { "id": "gms_7d2q9m4k1x", "state": "pending", "externalId": "client-4821", "connectionId": null, "error": null, "expiresAt": "2026-10-14T02:30:00.000Z", "createdAt": "2026-10-14T02:00:00.000Z", "completedAt": null, "url": "https://app.maukirim.com/connect/gmb/gcs_Tq8…" }}| Field | Rule |
|---|---|
externalId |
Required. Your own id for the end user: 1–128 characters of letters, digits and _ . : @ + -. It comes back on the redirect, on the session, and on every connection and business created through it |
redirectUrl |
Required. Where to send the client afterwards: https (or http on localhost), no credentials, no fragment, up to 2,000 characters, inside a registered return address. A query string is allowed and is kept |
label |
Optional. A name for the connection (up to 80 characters), for example the client’s shop name. It appears in your dashboard |
An unregistered or unsafe redirectUrl is refused with 400 gmb_redirect_not_allowed and nothing is
created. Details of the endpoint, including every error, are in
Connect sessions.
session.url is the link to give the client. It is a secret: it appears in this one response and
never again — reading the session or listing sessions does not contain it, and MauKirim stores only a hash
of it. Do not log it. If you lose it, create a new session.
Step 3 — send the client to the link
Section titled “Step 3 — send the client to the link”Redirect the client’s browser to session.url, or put it behind a button or in an email. The link works for
30 minutes (expiresAt) and for one trip.
Store session.id against your own client record before you redirect. That is how you will recognise who
came back.
What the client sees
Section titled “What the client sees”- A MauKirim consent card — the same card design MauKirim uses when an AI app asks for access. It shows your company name (the company on your MauKirim account, or your name if there is none) as the app that is asking, the permission requested (to manage their Business Profile), and the address the browser will be sent back to, host and path, so the client can see where they are going. There are two buttons, Allow and Deny.
- Deny sends the client straight back to you with
status=cancelled&error=access_denied. Nothing is connected. - Allow sends them to Google’s own consent screen to sign in to the Google account that manages the business and approve the Business Profile permission. If they decline there, it is the same as Deny.
- After Google approves, MauKirim reads the businesses the account manages, imports them switched off, and
sends the browser back to your
redirectUrlwithstatus=success.
If something goes wrong after Allow (Google refuses, the budget is busy, the Business Profile API is not available), the client stays on a MauKirim page that says it did not work and offers a Back to the app button; nothing is left connected.
A link that is unknown or expired shows an error card and stops: the browser is not sent anywhere, because there is no verified address to send it to. A link whose trip has already ended (connected, declined or failed) shows a short “this is already done” page with a Back to the app button, and nothing can be approved again.
What comes back on the return URL
Section titled “What comes back on the return URL”The client’s browser is sent to your redirectUrl with these query parameters added to whatever query you
already had (yours are kept; if one of yours has the same name as one of MauKirim’s, MauKirim’s wins):
| Parameter | Value |
|---|---|
maukirim_session |
The session id (gms_…) |
status |
success, cancelled or error |
external_id |
The externalId you passed |
connection_id |
Only on success: the connection id (gmc_…) |
error |
access_denied when status=cancelled; a gmb_* code when status=error; absent on success |
| Outcome | What the URL looks like |
|---|---|
| Success | …/google/return?ref=4821&maukirim_session=gms_7d2q9m4k1x&status=success&external_id=client-4821&connection_id=gmc_5p1x8c3m7z |
| The client denied (on MauKirim’s card or at Google) | …?maukirim_session=gms_7d2q9m4k1x&status=cancelled&external_id=client-4821&error=access_denied |
| It failed | …?maukirim_session=gms_7d2q9m4k1x&status=error&external_id=client-4821&error=gmb_authorization_failed |
access_denied is the same OAuth refusal MauKirim’s MCP consent flow uses. For status=error the error is a
symbolic code from the Errors page — never Google’s own wording — such as
gmb_authorization_failed, gmb_rate_limited, gmb_api_unavailable, gmb_forbidden or gmb_upstream_error.
A failure is shown to the client first and they reach your page only when they press Back to the app.
There is no secret on the redirect. No token, no link, no Google identifier: only ids.
Never trust the redirect alone
Section titled “Never trust the redirect alone”Anyone can type a URL into a browser, and a client may close the tab before they return. Treat the redirect as a hint to look, not as proof:
- Find your own record from
maukirim_session— the one you stored in step 3 — not fromexternal_idin the query string. - Read the session with your API key (below) and require
state: "completed"and theexternalIdyou expect. - Only then mark the client as connected.
And do not depend on the redirect to learn about failures. If the client never comes back, poll the session
(it moves to completed, cancelled or failed by itself) or look at it the next time they open your app.
Step 4 — read the result
Section titled “Step 4 — read the result”curl https://app.maukirim.com/api/v1/gmb/connect-sessions/gms_7d2q9m4k1x \ -H "Authorization: Bearer mk_live_…"{ "ok": true, "session": { "id": "gms_7d2q9m4k1x", "state": "completed", "externalId": "client-4821", "connectionId": "gmc_5p1x8c3m7z", "error": null, "expiresAt": "2026-10-14T02:30:00.000Z", "createdAt": "2026-10-14T02:00:00.000Z", "completedAt": "2026-10-14T02:03:41.000Z" }}state |
Meaning |
|---|---|
pending |
Created, not finished. If expiresAt has passed it is expired: the link no longer works; create a new session |
completed |
The client connected; connectionId is set |
cancelled |
The client denied; nothing was connected |
failed |
Something went wrong after they allowed; error holds the code, and nothing is left connected |
A session moves forward once and never changes again. You can list recent sessions with
GET /gmb/connect-sessions (see Connect sessions).
Then ask for that client’s connection and businesses by your id:
curl "https://app.maukirim.com/api/v1/gmb/connections?externalId=client-4821" -H "Authorization: Bearer mk_live_…"curl "https://app.maukirim.com/api/v1/gmb/locations?externalId=client-4821" -H "Authorization: Bearer mk_live_…"Every connection and every business in a list carries externalId. Connections you made yourself in the
dashboard have "externalId": "", and ?externalId= (empty) returns exactly those. The business ids
(gml_…) you get are used everywhere else: posts,
photos, insights,
reviews and the rest — see Google Business Profile.
Expiry and retries
Section titled “Expiry and retries”- A link lives 30 minutes and is good for one trip. After that, opening it shows an error
(
gmb_session_expired, HTTP 410); once the trip has ended it only shows that it is done (a second attempt to start it isgmb_session_state, HTTP 409). Create a new session: that is the retry. Sessions are cheap, but there is a limit (below). - The client may open the link, close the tab and come back within the 30 minutes: it still works until somebody approves or denies.
- If the client connects the same Google account again for the same
externalId, MauKirim reuses the same connection (and switches back on the businesses a lost connection had switched off; businesses you left off stay off, and ones found for the first time arrive off). Two differentexternalIds using the same Google account are two separate connections, so your clients stay apart. - Creating a session is not idempotent: each call is a new link. If your request times out, read your session
list (filter by
externalId) before creating another.
Step 5 — turn on the businesses you want
Section titled “Step 5 — turn on the businesses you want”The businesses a client connects arrive switched off. Every business found through the client’s Google account
is imported with status: "disabled" and disabledBy: "user". A switched-off business costs no allowance and
nothing about it is lost, but you cannot act on it (reviews, posts, edits answer 409 gmb_location_disabled) until
you turn it on. So after the client is back, find what is waiting and choose:
# What is waiting for this clientcurl "https://app.maukirim.com/api/v1/gmb/locations?externalId=client-4821&status=disabled" \ -H "Authorization: Bearer mk_live_…"
# Turn on the ones you want to manage (the ids are the gml_… values from the list)curl -X POST https://app.maukirim.com/api/v1/gmb/locations/activation \ -H "Authorization: Bearer mk_live_…" \ -H "Content-Type: application/json" \ -d '{ "activate": ["gml_8f3kq2x1ab", "gml_4d7wn0c9ze"] }'{ "ok": true, "activated": ["gml_8f3kq2x1ab", "gml_4d7wn0c9ze"], "deactivated": [], "active": 2, "maxLocations": 10 }The change is atomic against your allowance: if turning them on would take you past the package’s limit the
call answers 409 gmb_location_limit and changes nothing, so choose fewer, switch another business off
("deactivate": [...]) or upgrade. You can come back at any time: a client with forty branches can connect once,
and you switch branches on as they become a paying part of your service. Reference:
POST /gmb/locations/activation, scope gmb.
Working on a client’s business
Section titled “Working on a client’s business”Once a client’s businesses are in your list, you act on them with the same business ids as on your own, and the same API creates what does not exist yet:
- Upload a photo the client sent you (a JPEG or PNG file, 10 KB to 5 MB) with
POST /gmb/locations/{id}/media/upload; no need to host it first. A photo can still be added by URL. - Open a new branch for the client with
POST /gmb/connections/{id}/locations, using the client’sconnection_id(gmc_…; find it with?externalId=). Check withvalidateOnlyfirst. The new business counts against your allowance, is created switched on and starts unverified: walk it through verification. - Give the client’s staff access by inviting them as managers (Google emails them), and remove them again. Ownership stays with the client.
- Delete a client’s business from Google with
DELETE /gmb/locations/{id}, which needs the exact title. Do this only on the client’s explicit instruction: it cannot be undone.
Your key needs the gmb scope for all of these, and every call is drawn from your account’s share of the
Google budget.
Offboarding a client
Section titled “Offboarding a client”When a client leaves, disconnect their connection:
curl -X DELETE https://app.maukirim.com/api/v1/gmb/connections/gmc_5p1x8c3m7z \ -H "Authorization: Bearer mk_live_…"MauKirim asks Google to forget the saved permission (best effort: a Google outage never stops you leaving),
marks the connection revoked and switches its businesses off, so they stop counting against your
allowance. History and usage stay. The public listings themselves are not touched. Scope gmb. See
Connections. The client can also remove access
themselves at myaccount.google.com/permissions; the connection then
becomes needs_reauth. To bring a client back, create a new session with the same externalId.
Security properties
Section titled “Security properties”- Nothing secret travels in the redirect. Only ids and a status.
- A registered-address allow-list. A session whose
redirectUrlis not inside an address you registered is refused before anything is created, and the address stored for the session is exactly the one that passed the check. - The link is a short-lived, single-use capability. 30 minutes; each step only works from the state before it, so a replayed link cannot be approved twice; only its SHA-256 hash is stored, like an API key; it is not returned by get or list.
- The Allow button is a
POSTfrom the same origin, so a link scanner or prefetcher that merely opens the URL cannot start the flow. - Google’s consent is the client’s alone. MauKirim and you never see the Google password, and the refresh token is stored encrypted and never returned by any API.
- Everything is done as you. The connection, the Google budget it spends and the allowance belong to your
account. A session that is not yours is
404 gmb_session_not_found, the same answer as one that does not exist. - Rate limits. At most 120 sessions per hour per account (
429 rate_limitedwithRetry-After), and the public page’s Allow/Deny actions are limited to 60 per 15 minutes per IP address. See rate limits.
Billing and the allowance
Section titled “Billing and the allowance”Nothing in the flow is charged from your credit balance; it is part of the package. The businesses a client
connects are your businesses, and they arrive switched off: a switched-off business costs no allowance. Only
the businesses you turn on (step 5) count against the package’s allowance of
active businesses (10 on Growth, 50 on Business, 200 on Scale, 1,000 on Platform) together with everything else on
your account. A client whose Google account manages more businesses than you have room for is fine: every business is
imported, the session completes, and you choose which to turn on, now or later (turning on more than fits is refused
as a whole with gmb_location_limit, changing nothing). A business you create for a client arrives on and counts the
same way, and is refused (gmb_location_limit) before it exists on Google when there is no room. Disconnecting a
client switches their businesses off and frees their share. Google calls for the
client’s businesses (syncs, edits, posts, insights) are drawn from your account’s per-minute share of the
shared Google budget.
A complete example
Section titled “A complete example”Node (Express)
Section titled “Node (Express)”import express from "express"
const API = "https://app.maukirim.com/api/v1"const headers = { Authorization: `Bearer ${process.env.MAUKIRIM_KEY}`, "Content-Type": "application/json" }const app = express()
// Your own store: clientId -> { googleSessionId, googleConnected }const clients = new Map()
// 1. Once, at deployment: register where clients come back to.async function registerReturnAddress() { const response = await fetch(`${API}/gmb/redirect-uris`, { method: "POST", headers, body: JSON.stringify({ uri: "https://app.example.com/google/return" }), }) if (!response.ok && (await response.json()).code !== "gmb_redirect_exists") throw new Error("could not register")}
// 2. The client presses "Connect Google" in your app.app.post("/clients/:id/google/connect", async (req, res) => { const clientId = req.params.id const response = await fetch(`${API}/gmb/connect-sessions`, { method: "POST", headers, body: JSON.stringify({ externalId: clientId, redirectUrl: "https://app.example.com/google/return", label: clients.get(clientId)?.name, }), }) const body = await response.json() if (!response.ok) return res.status(502).json({ error: body.code })
// Remember which session belongs to which client BEFORE redirecting. Never log session.url. clients.set(clientId, { ...clients.get(clientId), googleSessionId: body.session.id, googleConnected: false }) res.json({ redirect: body.session.url })})
// 3. The client comes back (or doesn't: poll the same check from a job).app.get("/google/return", async (req, res) => { const sessionId = String(req.query.maukirim_session ?? "") const entry = [...clients.entries()].find(([, value]) => value.googleSessionId === sessionId) if (!entry) return res.status(404).send("Unknown session") const [clientId, record] = entry
const response = await fetch(`${API}/gmb/connect-sessions/${encodeURIComponent(sessionId)}`, { headers }) const { session } = await response.json() if (session?.state === "completed" && session.externalId === clientId) { record.googleConnected = true const list = await fetch(`${API}/gmb/locations?externalId=${encodeURIComponent(clientId)}`, { headers }) const { locations } = await list.json() // Businesses arrive switched off: turn on the ones you want (here, every one that is waiting for a choice). const waiting = locations.filter((l) => l.status === "disabled" && l.disabledBy === "user").map((l) => l.id) if (waiting.length > 0) { const turnOn = await fetch(`${API}/gmb/locations/activation`, { method: "POST", headers, body: JSON.stringify({ activate: waiting }) }) // 409 gmb_location_limit: nothing was turned on; choose fewer, or upgrade. if (!turnOn.ok) console.warn("activation refused", (await turnOn.json()).code) } record.businesses = (await (await fetch(`${API}/gmb/locations?externalId=${encodeURIComponent(clientId)}`, { headers })).json()).locations return res.redirect("/dashboard?google=connected") } if (session?.state === "cancelled") return res.redirect("/dashboard?google=declined") return res.redirect(`/dashboard?google=failed&reason=${encodeURIComponent(session?.error ?? req.query.error ?? "unknown")}`)})
// 4. The client leaves.app.delete("/clients/:id/google", async (req, res) => { const connections = await (await fetch(`${API}/gmb/connections?externalId=${encodeURIComponent(req.params.id)}`, { headers })).json() for (const connection of connections.connections) { await fetch(`${API}/gmb/connections/${connection.id}`, { method: "DELETE", headers }) } res.status(204).end()})
await registerReturnAddress()app.listen(3000)<?php// connect.php — POST: the client pressed "Connect Google"; returns the link to send them to.const API = 'https://app.maukirim.com/api/v1';
function maukirim(string $method, string $path, ?array $body = null): array{ $curl = curl_init(API . $path); curl_setopt_array($curl, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('MAUKIRIM_KEY'), 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, ]); $data = json_decode((string) curl_exec($curl), true) ?? ['ok' => false, 'code' => 'server_error']; curl_close($curl); return $data;}
$clientId = $_POST['client_id']; // your id for the end user$result = maukirim('POST', '/gmb/connect-sessions', [ 'externalId' => $clientId, 'redirectUrl' => 'https://app.example.com/google_return.php', 'label' => $_POST['shop_name'] ?? null,]);if (!$result['ok']) { http_response_code(502); exit(json_encode(['error' => $result['code']]));}
// Store the session id against your client BEFORE redirecting. Never log or store session.url.saveGoogleSession($clientId, $result['session']['id']);header('Location: ' . $result['session']['url']);<?php// google_return.php — where the client lands afterwards. Verify; do not trust the query string.$sessionId = $_GET['maukirim_session'] ?? '';$clientId = clientForGoogleSession($sessionId); // your own lookup, from step oneif ($clientId === null) { http_response_code(404); exit('Unknown session'); }
$result = maukirim('GET', '/gmb/connect-sessions/' . rawurlencode($sessionId));$session = $result['session'] ?? null;
if ($session && $session['state'] === 'completed' && $session['externalId'] === $clientId) { $businesses = maukirim('GET', '/gmb/locations?externalId=' . rawurlencode($clientId))['locations']; // Businesses arrive switched off: turn on the ones you want (here, every one waiting for a choice). $waiting = []; foreach ($businesses as $business) { if ($business['status'] === 'disabled' && $business['disabledBy'] === 'user') $waiting[] = $business['id']; } if ($waiting) { $turnOn = maukirim('POST', '/gmb/locations/activation', ['activate' => $waiting]); // code gmb_location_limit: nothing was turned on; choose fewer, or upgrade. if ($turnOn['ok']) $businesses = maukirim('GET', '/gmb/locations?externalId=' . rawurlencode($clientId))['locations']; } markGoogleConnected($clientId, $session['connectionId'], $businesses); header('Location: /dashboard.php?google=connected');} elseif ($session && $session['state'] === 'cancelled') { header('Location: /dashboard.php?google=declined');} else { header('Location: /dashboard.php?google=failed&reason=' . rawurlencode($session['error'] ?? 'unknown'));}From an assistant (MCP)
Section titled “From an assistant (MCP)”An assistant with the gmb scope can run steps 1, 2, 4, 5 and offboarding for you:
add_gmb_redirect_uri, create_gmb_connect_session, get_gmb_connect_session,
list_gmb_connections / list_gmb_locations with external_id (and status: disabled for the businesses waiting),
set_gmb_locations_active, and disconnect_gmb_connection. Afterwards it
can also work on the client’s business: upload_gmb_media, create_gmb_location, invite_gmb_manager and
delete_gmb_location. It cannot
complete the client’s consent — only the client can — so it hands you the link and you send it. See
MCP: onboarding a client.
Troubleshooting
Section titled “Troubleshooting”400 gmb_redirect_not_allowed when creating a session. The redirectUrl is not inside a registered return
address (check the scheme, host, port and that it is below the registered path on a / boundary), or it is not
a safe address (not https, has credentials or a fragment). List what you registered with
GET /gmb/redirect-uris.
The client sees “link expired” or “already used”. The 30 minutes passed, or the trip already ended. Create a new session.
The session stays pending. The client has not decided yet, or abandoned the page. After expiresAt it is
expired; create a new one.
failed with gmb_authorization_failed. Google did not give MauKirim a usable permission: the client
declined part of it, or used an account without the Business Profile permission. Try again, with the Google
account that manages the business.
failed with gmb_api_unavailable. Not your client’s problem: the Business Profile API is not enabled or
approved for the MauKirim deployment’s Google project. Contact support.
Connected, but a business is missing or does nothing. It is most likely switched off: businesses arrive off
until you turn them on (list ?status=disabled). If turning it on answers
gmb_location_limit your allowance is full. Otherwise the Google account is not an Owner or Manager of it. See
Google Business Profile → Troubleshooting.