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 budget in plain language
Section titled “The budget in plain language”- 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.
What costs budget and what is free
Section titled “What costs budget and what is free”| 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.
What a 429 looks like
Section titled “What a 429 looks like”When there is no budget, the call is not made and you get:
HTTP/1.1 429 Too Many RequestsRetry-After: 7Content-Type: application/json
{ "ok": false, "code": "gmb_rate_limited", "retryAfterSeconds": 7 }Retry-AfterandretryAfterSecondscarry the same number: the whole seconds to wait, at least 1. The Google Business endpoints are the only ones that setRetry-After:gmb_rate_limitedhere, and therate_limitedof 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 ownRetry-After(a short circuit opens for the whole platform so nobody piles on). - A
429is 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-Keyafter a429is safe: a refused call releases the key. - A request that reuses an
Idempotency-Keywhose first request is still running also answers429 gmb_rate_limitedwithretryAfterSeconds: 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 |
Hosted connect
Section titled “Hosted connect”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.
How to stay inside the budget
Section titled “How to stay inside the budget”- Read from the list, not from Google.
GET /gmb/locationsandGET /gmb/locations/{id}are free and already carry name, address, phone, website, category and verification state. Use?live=1only when you need the description or the opening hours, or must be certain the copy is current. - Cache reviews yourself, and lean on ours. Poll no faster than once a minute per business: the 60-second cache makes faster polling pointless.
- 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.
- Queue refreshes instead of looping.
POST …/refreshreturns a job at once, and the worker runs it under the background lane. - Honour
Retry-After. Sleep for it (plus a little jitter) and retry. Add a ceiling on attempts. - Use idempotency keys on every write, so a retry after a timeout or a
429is exactly one change.
Backoff, in code
Section titled “Backoff, in code”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');If you see 429 all the time
Section titled “If you see 429 all the time”- 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/quotashowsbusyorthrottled. Spread the work out and do the large jobs at quiet hours. - Everything is
429even at a trickle andretryAfterSecondsis 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.