Skip to content

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.

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 businesses
your server ── GET /gmb/locations?externalId=… ────────▶ the client's businesses (disabled, waiting)
your server ── POST /gmb/locations/activation ─────────▶ turn on the ones you want

The 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.

  • 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 gmb scope. Reading sessions, return addresses and businesses also works with read. See Authentication.
  • An HTTPS page of your own that the client is sent back to (http://localhost is allowed for development).

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:

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

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

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.

  1. 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.
  2. Deny sends the client straight back to you with status=cancelled&error=access_denied. Nothing is connected.
  3. 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.
  4. After Google approves, MauKirim reads the businesses the account manages, imports them switched off, and sends the browser back to your redirectUrl with status=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.

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.

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:

  1. Find your own record from maukirim_session — the one you stored in step 3 — not from external_id in the query string.
  2. Read the session with your API key (below) and require state: "completed" and the externalId you expect.
  3. 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.

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

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

  • 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 is gmb_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 different externalIds 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:

Terminal window
# What is waiting for this client
curl "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.

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’s connection_id (gmc_…; find it with ?externalId=). Check with validateOnly first. 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.

When a client leaves, disconnect their connection:

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

  • Nothing secret travels in the redirect. Only ids and a status.
  • A registered-address allow-list. A session whose redirectUrl is 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 POST from 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_limited with Retry-After), and the public page’s Allow/Deny actions are limited to 60 per 15 minutes per IP address. See rate limits.

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.

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 one
if ($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'));
}

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.

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.