Skip to content

Google Business rate limits

Google gives MauKirim 300 queries per minute for the whole project. That is one pool, shared by every MauKirim customer at once. MauKirim does not find the limit by running into Google’s 429s: it keeps its own count and refuses a call itself, politely and with a wait time, before Google would. This page explains what that means for your code.

  • The pool is shared. Whatever your account spends, other accounts cannot. So a hard loop in one integration could starve everybody else. That is why there are two limits, not one.
  • Part of the pool is held back. By default 10% (30 of the 300) is a reserve that MauKirim never spends, so a miscount or a burst can never take the project over Google’s limit. In practice about 270 calls a minute are available to all customers together.
  • Your account has its own share. By default 60 calls per minute, so one customer syncing hundreds of businesses cannot use the whole pool. The share is a refilling bucket: it holds about ten seconds’ worth (about 10 calls) and refills continuously, so a short burst is fine and a sustained rate above 60 a minute is not. An operator can raise or lower an account’s share; read yours with GET /gmb/quota.
  • Interactive beats background. Your API calls and dashboard clicks are interactive. Syncs and refreshes the worker runs for you are background, and they only use what interactive traffic leaves, so a big sync never makes the dashboard stall.
Call Metered?
GET /gmb/locations (the list) Free. Served from MauKirim’s own table
GET /gmb/locations/{id} Free. The stored copy
GET /gmb/locations/{id}?live=1 1 call
GET …/reviews 1 call on a miss, free while the 60-second cache holds (the response says "cached": true)
PATCH /gmb/locations/{id} 1 call
PUT / DELETE …/reviews/{id}/reply 1 call. An idempotent replay ("replayed": true) is free
POST …/refresh Queues a background job; the job then spends calls when it runs
GET /gmb/jobs/{id}, /gmb/connections, /gmb/usage, /gmb/quota Free
GET …/posts, POST …/posts, PATCH / DELETE …/posts/{postId} 1 call each. A replayed create ("replayed": true) is free
GET …/media, POST …/media, DELETE …/media/{mediaId} 1 call each. A replayed add is free
POST …/media/upload (a photo file) 3 calls: reserve a place at Google, send the bytes, create the media item. A replayed upload is free. A file refused for size or type (gmb_file_too_large, gmb_unsupported_media) is free
POST /gmb/connections/{id}/locations (create a business) 1 call, plus 1 to find the Google account when accountName is left out. A replay is free; one refused for the allowance (gmb_location_limit) is free
DELETE /gmb/locations/{id} (delete a business) 1 call. A wrong confirmTitle is free
GET …/insights, GET …/insights/keywords 1 call each. Not cached by MauKirim: do not poll them (Google’s numbers are daily or monthly)
GET …/attributes, GET …/attributes/available, PATCH …/attributes, GET …/categories 1 call each (per page for the available list)
GET …/verifications, POST …/verifications/options, POST …/verifications/start, POST …/verifications/{id}/complete, GET …/admins 1 call each. A replayed start is free
POST …/admins (invite a manager), DELETE …/admins/{adminId} 1 call each. A replayed invite is free
POST …/google 1 call per request, whatever the method. A replayed write is free. A request the allow-list refuses is free (it never reaches Google)
POST /gmb/connect-sessions, GET /gmb/connect-sessions[/{id}], GET/POST/DELETE /gmb/redirect-uris Free: they never reach Google (but see the session limit below)
DELETE /gmb/connections/{id} Free of budget: it revokes the token with Google’s token service, which is not the metered API
Getting a Google access token Not metered: it is a different Google quota

A request MauKirim refuses for being invalid (a bad post, an unknown metric, a path outside the allow-list) costs nothing: validation happens before the call. A call Google rejects was still a call.

Free calls are not free of authentication: they still need a key, the plan and the scope. They simply never reach Google.

A call that MauKirim refuses for lack of budget is counted as throttled in your usage, not as a call.

When there is no budget, the call is not made and you get:

HTTP/1.1 429 Too Many Requests
Retry-After: 7
Content-Type: application/json
{ "ok": false, "code": "gmb_rate_limited", "retryAfterSeconds": 7 }
  • Retry-After and retryAfterSeconds carry the same number: the whole seconds to wait, at least 1. The Google Business endpoints are the only ones that set Retry-After: gmb_rate_limited here, and the rate_limited of connect-session creation (below). Other endpoints never do.
  • It is the same code whichever limit you hit. The wait is computed for the one that is binding: your own share refilling, the shared pool refilling, or — if Google itself answered 429 — Google’s own Retry-After (a short circuit opens for the whole platform so nobody piles on).
  • A 429 is never charged and never an error in your code: it means “later”. Retrying immediately makes things worse for you and for everyone else.
  • A write retried with the same Idempotency-Key after a 429 is safe: a refused call releases the key.
  • A request that reuses an Idempotency-Key whose first request is still running also answers 429 gmb_rate_limited with retryAfterSeconds: 2 — try again in a moment.

There is no X-RateLimit-* header, and no header carries a count. To look before you leap, read GET /gmb/quota:

{ "ok": true, "accountQpm": 60, "level": "busy" }

level is the platform as a whole, deliberately coarse so it never reveals other customers’ traffic:

level Meaning
ok Less than 60% of the sustained budget is in use this minute
busy The pool is in heavy use; spread your work out
throttled Calls were refused in the current minute, or the circuit is open after a Google 429

Connecting your own clients (guide) has two limits that are not the Google budget:

Limit Value Keyed by What you see
Creating connect sessions (POST /gmb/connect-sessions) 120 per hour Your account 429 rate_limited with Retry-After and retryAfterSeconds
The public connect page’s Allow and Deny buttons 60 per 15 minutes The end user’s IP address The end user sees an error on the card and can try again later

They exist so a leaked key cannot mint links without end and a script cannot hammer the public page. Reuse a session that is still pending rather than creating many.

The Google calls the flow makes — reading the accounts and businesses of the client’s Google account right after they approve — are your account’s calls: they spend your per-minute share, interactively, so a client who manages hundreds of businesses can briefly use it up. If the budget is busy the first sync may not finish; the connection is then reported as failed and nothing is left connected (the client retries with a new session). Afterwards, every call for that client’s businesses is metered against your account like any other.

  1. Read from the list, not from Google. GET /gmb/locations and GET /gmb/locations/{id} are free and already carry name, address, phone, website, category and verification state. Use ?live=1 only when you need the description or the opening hours, or must be certain the copy is current.
  2. Cache reviews yourself, and lean on ours. Poll no faster than once a minute per business: the 60-second cache makes faster polling pointless.
  3. Batch your writes. Spread edits, replies, posts and photos over time; one call per business per change is the floor. A queue in your own system with a rate of about one call per second per account is comfortably inside the default share.
  4. Queue refreshes instead of looping. POST …/refresh returns a job at once, and the worker runs it under the background lane.
  5. Honour Retry-After. Sleep for it (plus a little jitter) and retry. Add a ceiling on attempts.
  6. Use idempotency keys on every write, so a retry after a timeout or a 429 is exactly one change.

Retry only on 429 (and 502 gmb_upstream_error), wait what the server says, add jitter, and stop after a few attempts. Reuse the same Idempotency-Key on every attempt.

async function gmbRequest(path, { method = "GET", body, idempotencyKey } = {}, maxAttempts = 5) {
for (let attempt = 1; ; attempt++) {
const response = await fetch(`https://YOUR-ORIGIN/api/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.MAUKIRIM_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
if (response.ok) return response.json()
const failure = await response.json().catch(() => ({ code: "server_error" }))
const retryable = response.status === 429 || failure.code === "gmb_upstream_error"
if (!retryable || attempt === maxAttempts) {
throw Object.assign(new Error(failure.code), { status: response.status, code: failure.code })
}
// The server says how long; fall back to exponential backoff for a 502.
const waitSeconds = failure.retryAfterSeconds ?? (Number(response.headers.get("retry-after")) || 2 ** attempt)
const jitter = Math.random() * 0.25 * waitSeconds
await new Promise((resolve) => setTimeout(resolve, (waitSeconds + jitter) * 1000))
}
}
await gmbRequest("/gmb/locations/gml_…/reviews/AbFvOqm…/reply", {
method: "PUT",
body: { comment: "Thank you!" },
idempotencyKey: "reply-AbFvOqm-1",
})
function gmbRequest(string $path, string $method = 'GET', ?array $body = null, ?string $idempotencyKey = null, int $maxAttempts = 5): array
{
for ($attempt = 1; ; $attempt++) {
$headers = ['Authorization: Bearer ' . getenv('MAUKIRIM_KEY')];
if ($body !== null) $headers[] = 'Content-Type: application/json';
if ($idempotencyKey !== null) $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
$curl = curl_init('https://YOUR-ORIGIN/api/v1' . $path);
curl_setopt_array($curl, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($body));
$raw = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$data = json_decode((string) $raw, true) ?? ['ok' => false, 'code' => 'server_error'];
if ($status >= 200 && $status < 300) return $data;
$retryable = $status === 429 || ($data['code'] ?? '') === 'gmb_upstream_error';
if (!$retryable || $attempt === $maxAttempts) {
throw new RuntimeException(($data['code'] ?? 'server_error') . " (HTTP $status)");
}
$wait = $data['retryAfterSeconds'] ?? (2 ** $attempt);
usleep((int) (($wait + mt_rand(0, 250) / 1000 * $wait) * 1_000_000));
}
}
gmbRequest('/gmb/locations/gml_…/reviews/AbFvOqm…/reply', 'PUT', ['comment' => 'Thank you!'], 'reply-AbFvOqm-1');
  • Your integration is above its share: lower the rate, or ask the operator to raise the account’s share.
  • The whole platform is busy: GET /gmb/quota shows busy or throttled. Spread the work out and do the large jobs at quiet hours.
  • Everything is 429 even at a trickle and retryAfterSeconds is large: Google is refusing the project. Wait; the operator is alerted.

If it keeps happening, contact support with the time (and timezone) and the code. Never include a key.