Google My Business / Google Business Profile (gmb)
gmb publishes a local post to one Google Business Profile location through
the legacy Google My Business API v4 accounts.locations.localPosts
endpoint, and reads location-level performance metrics through the Business
Profile Performance API. Accounts and locations are listed through the v1
My Business Account Management and My Business Business Information APIs.
The provider is is_between_steps = true: OAuth authenticates a Google
account, then channelSetupTool selects one business location (an account can
own many). Publishing is synchronous — Google either returns the new local post
name or rejects it; there is no media-polling phase.
- Rust source:
crates/rustiz-providers/src/gmb.rs - Upstream port:
gmb.provider.ts - Editor: Normal
- Connection mode:
oauth+ a mandatory location-selection step
Two independent gates. OAuth consent (Testing/Production) is one; the Google Business Profile API access request is the other. Even a perfectly configured OAuth client gets
0 QPMandPERMISSION_DENIEDuntil Google approves the Cloud project for the Business Profile APIs. Approval requires a verified Business Profile active for 60+ days and a website.
Overview
| Property | Value |
|---|---|
| Identifier | gmb |
| Display name | Google My Business |
| Connection mode | oauth, then channelSetupTool (location selection) |
| Editor | normal |
| Channel kind | social |
| Comments / threads | No. supports_comments = false; only the first post is published |
| Pending / async publishing | No. supports_pending = false; localPosts.create is synchronous |
| Analytics | Yes. supports_analytics = true; location-level Performance API metrics |
| Post analytics | Flag is true, implementation always returns []. Local posts have no per-post metrics; the API is location-level |
| Mentions | No. supports_mentions = false |
is_between_steps | true |
convert_to_jpeg | false |
max_concurrent_job | 3 |
Character limit (max_length) | 1500 characters (GBP local-post summary limit) |
| API | My Business API v4 (mybusiness.googleapis.com), Account Management v1, Business Information v1, Business Profile Performance v1 |
| OAuth scopes | https://www.googleapis.com/auth/userinfo.profile, .../userinfo.email, .../business.manage |
| Token lifetime | Google access token ~1 h; refresh token until revoked (7 days in Testing mode) |
Server Environment Variables
required_env() returns four names because the provider accepts two credential
families:
#![allow(unused)]
fn main() {
&[
"GOOGLE_GMB_CLIENT_ID",
"GOOGLE_GMB_CLIENT_SECRET",
"YOUTUBE_CLIENT_ID",
"YOUTUBE_CLIENT_SECRET",
]
}
required_env_alternatives() collapses each pair:
#![allow(unused)]
fn main() {
&[
&["GOOGLE_GMB_CLIENT_ID", "YOUTUBE_CLIENT_ID"],
&["GOOGLE_GMB_CLIENT_SECRET", "YOUTUBE_CLIENT_SECRET"],
]
}
So rustiz providers --status reports gmb as configured when either pair is
present. The runtime fallback is per field:
#![allow(unused)]
fn main() {
client_id = GOOGLE_GMB_CLIENT_ID or YOUTUBE_CLIENT_ID
client_secret = GOOGLE_GMB_CLIENT_SECRET or YOUTUBE_CLIENT_SECRET
}
| Variable | Required | Default | Purpose |
|---|---|---|---|
GOOGLE_GMB_CLIENT_ID | One of the pair | none | Preferred GMB OAuth client id. |
GOOGLE_GMB_CLIENT_SECRET | One of the pair | none | Preferred GMB OAuth client secret. |
YOUTUBE_CLIENT_ID | One of the pair | none | Fallback client id (shared Google app). |
YOUTUBE_CLIENT_SECRET | One of the pair | none | Fallback client secret. |
FRONTEND_URL | Yes, in practice | http://localhost:5000 | Builds the redirect URI. Trailing / stripped. |
GOOGLE_AUTH_BASE_URL | No | https://accounts.google.com | /o/oauth2/v2/auth. |
GOOGLE_OAUTH_BASE_URL | No | https://oauth2.googleapis.com | /token, /tokeninfo. |
GOOGLE_BASE_URL | No | https://www.googleapis.com | /oauth2/v2/userinfo. |
GMB_ACCOUNTS_BASE_URL | No | https://mybusinessaccountmanagement.googleapis.com | /v1/accounts. |
GMB_INFO_BASE_URL | No | https://mybusinessbusinessinformation.googleapis.com | /v1/{account}/locations, /v1/{location}, /v1/{location}/media. |
GMB_BASE_URL | No | https://mybusiness.googleapis.com | v4 local posts: /v4/{id}/localPosts, where {id} is the full accounts/<n>/locations/<m> resource name. |
GMB_PERFORMANCE_BASE_URL | No | https://businessprofileperformance.googleapis.com | /v1/locations/{id}:fetchMultiDailyMetricsTimeSeries. |
GMB_WEB_BASE_URL | No | https://business.google.com | Fallback release URL /locations/{locationId}. |
Using the YouTube credentials is fully supported: the app and redirect URIs are yours to share. Using separate GMB credentials is cleaner when the GMB app must be verified independently.
rustiz providers --status | jq '.[] | select(.identifier=="gmb")'
Step-by-Step Developer App Setup
1. Prepare the Business Profile
- You need a Google account that owns or manages a Google Business Profile.
- The profile must be verified and active for at least 60 days.
- The business must have a website listed on the profile.
- For multi-location clients, the same account can manage many locations; the provider lists all of them and you select one.
2. Create the Google Cloud project and request API access
- Open https://console.cloud.google.com/ → Create project.
- Copy the Project number from the project dashboard.
- Submit Application for Basic API Access: https://support.google.com/business/contact/api_default → choose Application for Basic API Access. Provide the project number and a contact email that is an owner/manager on the Business Profile.
- Wait for the approval email. To check without waiting: open
APIs & Services → Quotas for the Business Profile APIs.
- 0 QPM → not approved yet;
- 300 QPM → approved.
- Approval can take a few days to a week. Do not file a quota-increase request while the quota is 0; that is not the right form.
3. Enable the APIs
Under APIs & Services → Enable APIs and Services:
| API | Used for |
|---|---|
| Google My Business API (v4) | localPosts.create — only appears in the library after the access request is approved |
| My Business Account Management API | GET /v1/accounts |
| My Business Business Information API | GET /v1/{account}/locations, GET /v1/{location}, GET /v1/{location}/media |
| Business Profile Performance API | fetchMultiDailyMetricsTimeSeries analytics |
The Postiz GMB guide lists only the first three. Enable the Performance API too, otherwise the Analytics tab silently returns no data (the provider catches the error).
4. OAuth consent screen
Google Auth Platform → Audience:
- User type External (or Internal for a Workspace-only deployment).
- App name, support email, developer contact, logo, authorized domains.
- Add the scopes
.../auth/userinfo.profile,.../auth/userinfo.email,.../auth/business.manage. - Add every Google account that will connect as a test user while in Testing.
- Publish the app for production.
business.manageis a sensitive Business Profile scope and will require OAuth verification (privacy policy, homepage, demo video, scope justification).
5. Create the OAuth client
-
Google Auth Platform → Clients → Create client.
-
Type Web application.
-
Authorized redirect URIs:
{FRONTEND_URL}/integrations/social/gmbExample
https://social.example.com/integrations/social/gmb.http://localhost:5000/integrations/social/gmbis allowed for local testing. No trailing slash beyond whatFRONTEND_URLproduces (it is trimmed). -
Copy the Client ID and Client secret.
6. Configure Rustiz
Either the dedicated pair:
GOOGLE_GMB_CLIENT_ID="....apps.googleusercontent.com"
GOOGLE_GMB_CLIENT_SECRET="..."
FRONTEND_URL="https://social.example.com"
or reuse the YouTube app (and add the GMB redirect URI to it):
YOUTUBE_CLIENT_ID="....apps.googleusercontent.com"
YOUTUBE_CLIENT_SECRET="..."
FRONTEND_URL="https://social.example.com"
Restart, check rustiz providers --status, then connect and pick a location.
Exact OAuth scopes and checks
#![allow(unused)]
fn main() {
vec![
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/business.manage",
]
}
business.manage is the only Business Profile scope. It grants read/write access
to every location the authenticated account manages — there is no
per-location scope.
After the code exchange, token_scopes() calls
GET {GOOGLE_OAUTH_BASE_URL}/tokeninfo?access_token=<token> and accepts either a
scopes array or a scope string. check_scopes requires all three. A user who
unchecks business.manage on the granular consent screen is refused with
not_enough_scopes.
The authorization URL is built with access_type=offline and prompt=consent:
{GOOGLE_AUTH_BASE_URL}/o/oauth2/v2/auth
?client_id=...&redirect_uri=...&response_type=code
&scope=...userinfo.profile ...userinfo.email ...business.manage
&state=<7 chars>&prompt=consent&access_type=offline
Redirect URI details
{FRONTEND_URL}/integrations/social/gmb
Config::frontend_url()strips trailing slashes; the path is appended once.- No
redirectmeto.com; the literal value is sent at authorize time and again at token exchange. Google requires an exact match (scheme, host, port, path). - Localhost HTTP is allowed by Google for development; production must be HTTPS.
Sandbox / testing mode
- The OAuth consent screen’s Testing status is the only “sandbox”. It allows up to 100 test users and shows the unverified-app warning.
- Test-mode authorizations and refresh tokens expire after 7 days. A location connected in Testing mode stops working a week later.
- The Business Profile API access approval is independent of Testing mode:
without it, every Business Profile call returns
PERMISSION_DENIED/ 0 QPM. You cannot bypass it by adding test users. - Use a real location you manage. Local posts are public on Google Search/Maps as soon as they are created; the API has no draft state. If you do not want test content visible, use a secondary/test location or delete the post after the test.
- The legacy v4
localPostsendpoint still works in 2026 even though accounts, locations and performance moved to v1. The provider depends on it.
Production app review
- Publish the OAuth app and complete OAuth verification for
business.manage(sensitive scope). Google wants a homepage, privacy policy, demo video and a clear explanation of why the app needs Business Profile access. - Business Profile API access must already be approved (300 QPM). The approval is tied to the Cloud project, not the OAuth client.
- Quotas after approval (defaults):
- Account Management API: 300 QPM;
- Business Information API: 300 QPM;
- Performance API: 300 QPM;
- location edits: 10 per minute per profile, not increasable.
Exceeding a quota returns HTTP 429 (
RESOURCE_EXHAUSTEDfor gRPC-style clients), which the provider maps to a “Rate limit exceeded”BadBody.
business.managecan be revoked by the user at any time at https://myaccount.google.com/permissions; the next call fails withUNAUTHENTICATED/invalid_grantand the channel is marked for reconnect.
Connecting a Channel (CLI & MCP)
CLI
# 1. OAuth login. Creates an in-between-steps placeholder channel.
rustiz channels connect gmb --open
# Remote server: poll the login.
rustiz channels connect-status <connectionId>
# 2. No CLI location-selection command; finish with MCP channelSetupTool.
rustiz channels list
Do not pass --fields (custom-credentials branch) or --external-url
(unsupported).
The CLI’s in-process callback server only runs when FRONTEND_URL is
http://localhost/http://127.0.0.1; otherwise rustiz serve handles the
callback.
MCP
Step 1 — start OAuth:
{ "provider": "gmb" }
Step 2 — poll:
{ "connectionId": "abc123" }
The channel exists with inBetweenSteps = true; its internal_id is the Google
account id and its name/picture are the Google account’s, not a location’s.
Step 3 — list accounts and locations:
{
"integrationId": "int_...",
"methodName": "pages",
"dataSchema": []
}
pages performs, in order:
GET {GMB_ACCOUNTS_BASE_URL}/v1/accounts[?pageToken=...]
GET {GMB_INFO_BASE_URL}/v1/{accountName}/locations?readMask=name,title,storefrontAddress,metadata[&pageToken=...]
GET {GMB_INFO_BASE_URL}/v1/{locationName}/media
and returns one object per location:
{
"id": "accounts/123/locations/456",
"name": "Downtown Shop",
"picture": { "data": { "url": "https://lh3.googleusercontent.com/..." } },
"accountName": "accounts/123",
"locationName": "locations/456"
}
picture is the PROFILE media item, or the first media item, or "". The media
request is per location (N+1); failures are swallowed and produce an empty
picture.
Step 4 — select the location:
{
"integrationId": "int_...",
"data": {
"id": "accounts/123/locations/456",
"accountName": "accounts/123",
"locationName": "locations/456"
}
}
All three fields are needed:
locationNameis required byfetch_page_information(missing →"locationName is required to fetch the location");idbecomes the channel’sinternal_idand is the{id}in/v4/{id}/localPosts;accountNameis accepted for symmetry but not used by the fetch call.
fetch_page_information calls
GET /v1/{locationName}?readMask=name,title,storefrontAddress,metadata, then the
media endpoint for the picture, and returns id (from the data), title as the
name, and the picture. channelSetupTool writes it all in, clears
inBetweenSteps, and returns { "success": true, "name": "Downtown Shop" }.
Provider functions (triggerTool)
| Function | Parameters | Returns | Notes |
|---|---|---|---|
pages | none | array of {id, name, picture, accountName, locationName} | Paginates accounts and locations; a failing account’s location list is skipped silently. |
Post Settings & Media Validation
settings_schema()
All fields are optional; there is no required array.
| Field | Type | Behavior |
|---|---|---|
topicType | enum STANDARD, EVENT, OFFER | Defaults to STANDARD at publish time when missing/empty. |
callToActionType | enum NONE, BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, GET_OFFER, CALL | CTA button. NONE disables it. The CTA is only sent when both a type and a non-empty callToActionUrl are present — see the CALL gotcha. |
callToActionUrl | string | URL opened by the CTA. Validated as an absolute http(s) URL with a host when non-empty. |
eventTitle | string | Required for EVENT. |
eventStartDate | string YYYY-MM-DD | Event start; invalid/missing → today (local date). |
eventEndDate | string YYYY-MM-DD | Event end; invalid/missing → today. |
eventStartTime | string HH:MM | Optional; parsed into {hours, minutes, seconds: 0, nanos: 0}. |
eventEndTime | string HH:MM | Optional. |
offerCouponCode | string | Offer coupon code. |
offerRedeemUrl | string | Offer redemption URL; validated as an absolute http(s) URL when non-empty. |
offerTerms | string | Offer terms and conditions. |
additionalProperties: true. There is no visibility, language, topic/category
or scheduling field; the post’s message becomes summary.
validate_settings() rules
topicType, if present and non-null, must be a string inSTANDARD/EVENT/OFFER.- If
topicType == "EVENT",eventTitlemust be a JSON string (an empty string passes this check;check_validitycatches it later). callToActionType, if present, must be one of the eight enum values.callToActionUrlandofferRedeemUrl, when present and non-empty, must parse ashttp/httpswith a host. Empty strings are allowed (they simply disable the field).- Everything else is passed through unvalidated.
check_validity() rules
- More than one media item →
"Google My Business posts can only have one image". - Any media path containing
mp4→"Google My Business posts do not support video attachments". topicType == "EVENT"with an empty/missingeventTitle→"Event posts require an event title".- Zero media is allowed (text-only post).
Media and publish behavior
-
One image, no video. The post body still has a
mediaarray with one item:{"mediaFormat": "PHOTO"|"VIDEO", "sourceUrl": <path>}. Validation only rejects paths containingmp4, so a.mov/.webmvideo path would pass and be sent asVIDEO; Google does not accept video local posts, so keep media to JPEG/PNG.sourceUrlmust be a public HTTPS URL that Google can fetch; a local file path will not work. -
The image is referenced, not uploaded: GBP pulls it from the URL. There is no size validation in the provider.
-
Synchronous create:
POST {GMB_BASE_URL}/v4/{id}/localPosts Authorization: Bearer <token> Content-Type: application/jsonwhere
{id}is the fullaccounts/<n>/locations/<m>name.Body:
{ "languageCode": "en", "summary": "<the post message>", "topicType": "STANDARD", "callToAction": { "actionType": "LEARN_MORE", "url": "https://..." }, "media": [{ "mediaFormat": "PHOTO", "sourceUrl": "https://cdn.example/a.jpg" }], "event": { "title": "...", "schedule": { "startDate": { "year": 2026, "month": 11, "day": 6 }, "endDate": { "year": 2026, "month": 11, "day": 6 }, "startTime": { "hours": 9, "minutes": 0, "seconds": 0, "nanos": 0 } } }, "offer": { "couponCode": "...", "redeemOnlineUrl": "https://...", "termsConditions": "..." } }eventis emitted only forEVENTwith a non-empty title;offeronly forOFFER. Empty strings are omitted rather than sent as blanks. -
languageCodeis hard-coded"en". There is no setting for it; thesummarytext itself is whatever language you write. -
Response handling:
state == "REJECTED"→BadBody: “Google rejected this post for a content policy violation. Please review the post content and try again.”- missing/empty
name→BadBody: “Google did not confirm the post creation. Please try again.” - success → release URL is
searchUrlwhen present, otherwise{GMB_WEB_BASE_URL}/locations/<last path segment of id>.
-
No pending state.
supports_pending = false; the post isSuccessas soon as Google returns a resource name. There is no media-processing poll and nofinalize_post.
Analytics
analytics() (location-level):
GET {GMB_PERFORMANCE_BASE_URL}/v1/locations/<locationId>:fetchMultiDailyMetricsTimeSeries
?dailyMetrics=WEBSITE_CLICKS
&dailyMetrics=CALL_CLICKS
&dailyMetrics=BUSINESS_DIRECTION_REQUESTS
&dailyMetrics=BUSINESS_IMPRESSIONS_DESKTOP_MAPS
&dailyMetrics=BUSINESS_IMPRESSIONS_MOBILE_MAPS
&dailyRange.startDate.year=...&...&dailyRange.endDate...=...
It takes the first multiDailyMetricTimeSeries[0] entry, iterates its
dailyMetricTimeSeries, and emits:
| API metric | Label |
|---|---|
WEBSITE_CLICKS | Website Clicks |
CALL_CLICKS | Phone Calls |
BUSINESS_DIRECTION_REQUESTS | Direction Requests |
BUSINESS_IMPRESSIONS_DESKTOP_MAPS | Desktop Map Views |
BUSINESS_IMPRESSIONS_MOBILE_MAPS | Mobile Map Views |
Dates are formatted YYYY-MM-DD; value is a string and is parsed with
parseInt semantics (leading integer, 0 on failure). Series with no points are
omitted. Any error — including the Performance API not being enabled — is
swallowed and analytics() returns [].
post_analytics() always returns [], despite
supports_post_analytics = true. GBP local posts have no per-post metrics in the
API; the flag is inherited from upstream metadata.
Plugs
Neither run_plug() nor run_post_plug() is overridden. Both use the trait
defaults and return Unsupported for every method. There is no auto-repost or
auto-comment plug for GMB.
Implementation Notes & Gotchas
GOOGLE_GMB_*wins per field, not per pair.client_id()prefersGOOGLE_GMB_CLIENT_IDand falls back toYOUTUBE_CLIENT_ID;client_secret()does the same independently. Mixing a GMB id with a YouTube secret works, which is convenient but easy to misconfigure.provider_statususes the paired alternatives, so it may say “configured” for a mixed set that still works.- The API access approval is tied to the project, not the OAuth client. If you switch projects, the new project has 0 QPM until approved again. The “Google My Business API” entry does not even appear in the library before approval.
business.manageis all-or-nothing. There is no per-location consent. The provider enumerates every account and location the user manages, which can be a lot for an agency account.pages()is N+1 and partially silent. It makes one media request per location; a location-list error for one account breaks out of that account’s pagination without failing the whole call, so the returned list can be incomplete. Media failures only produce an empty picture. For an account with hundreds of locations this call is slow.fetch_page_informationrequireslocationName. The common mistake is to pass only{"page": ...}(LinkedIn style) or only{"id": ...}. GMB needsid,accountNameandlocationName, exactly as returned bypages.- The
internal_idis the full resource name.accounts/123/locations/456is used verbatim in/v4/{id}/localPosts. Do not shorten it to the location id; the fallback release URL is the only place the last segment is extracted. CALLCTAs are effectively broken.callToActionis only emitted when bothcallToActionTypeand a non-emptycallToActionUrlexist. A phone-call CTA in GBP does not take a URL, so aCALLsetting without a URL is silently dropped, and with a URL Google may reject or ignore it. UseLEARN_MOREwith a URL for reliable behavior.- Dates default to today, not to the event fields. An invalid/missing
eventStartDate/eventEndDatebecomes today; anEVENTwith only a title can therefore be created as a zero-length event today. Always send both dates. - Times are parsed leniently.
eventStartTime/eventEndTimesplit on:; non-numeric parts become0."9:5"becomes 09:05,"abc"becomes 00:00. There is no timezone field; GBP interprets the schedule in the location’s timezone. languageCodeis fixed toen. The API may localize the CTA label based on this value even if the summary is in another language. A per-location language setting is not implemented.- A post is public immediately. There is no draft/pending state; a successful
localPosts.createis live on Search/Maps. Tests must account for that. REJECTEDis mapped to a content-policy message. Google can also reject for other reasons (e.g. an invalid CTA URL); the message always says “content policy violation”. Read thebodyin the failure details for the real reason. The body is preserved, except for token calls wherewithout_request_bodyscrubs it.PERMISSION_DENIEDtriggers a token refresh.handle_errorsmapsPERMISSION_DENIEDtoHandledError::refresh, so the engine refreshes the token and retries once before failing with “Permission denied. Please ensure you have access to this business location.” This is usually a location/role problem, not an expired token; the extra refresh is wasted but harmless.- The
Unauthorizedmessage mentions YouTube. It is a copy/paste from the YouTube provider (“Token expired or invalid, please reconnect your YouTube account.”). The behavior (refresh) is correct; the text is not. - Token exchange bodies are scrubbed from error details.
without_request_bodyreplaces the recorded request body with{}forBadBody/RefreshToken, because the body contains the client secret and the refresh token. Preserve this behavior if you refactor error handling. - Refresh tokens are not rotated by Google.
details()falls back to the previous refresh token when the response omits one, so the channel keeps working. A 7-day Testing-mode expiry still kills it regardless. refresh_token()returns the Google account, not the location. It callsuserinfoand returns the account id/name/picture. Only the tokens and expiry are stored, so the locationinternal_idis preserved; do not treat the refresh result’s id as a location id.expires_incan be 0. If Google returns neitherexpires_innorexpiry_date, the provider stores0. Sincerefresh_cronis false, this does not cause a refresh loop; it only means the token’s expiry is unknown.supports_post_analytics = trueis a lie.post_analytics()returns[]. Do not build UI that expects per-post GMB metrics.- Analytics errors are swallowed. A missing Performance API, a quota error or
an unsupported metric/date combination all become
[]. Postiz’s own docs warn that the Performance API restricts metric/date combinations and returns no data for newer or unverified profiles. - Quota errors are
BadBody, not provider retries. A 429 is retried by the generic HTTP layer twice (three attempts total, 5 s apart) and then fails; aRESOURCE_EXHAUSTEDbody is mapped to “Rate limit exceeded. Please try again later.” Either way there is no provider-level quota handling, so a sustained quota problem surfaces as a failed post/analytics call. localPostsis a legacy v4 remnant. Accounts/locations/performance use v1 APIs, but posts and reviews still live on v4. A future Google migration would break this provider; there is no v1 local-posts replacement in the code.- No plug support and no comments. GMB cannot auto-repost or auto-comment in
this port, and
supports_comments = false.
References
- Business Profile API prerequisites (verified profile 60+ days, website, project number, access request, 0 vs 300 QPM): https://developers.google.com/my-business/content/prereqs
- Business Profile API quota limits (300 QPM, 10 edits/min per profile): https://developers.google.com/my-business/content/limits
- Basic setup / enable the APIs: https://developers.google.com/my-business/content/basic-setup
- Local posts v4 reference: https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts
- Business Profile Performance API: https://developers.google.com/my-business/reference/performance/rest
- OAuth app audience and Testing-mode limits (100 users, 7-day expiry): https://support.google.com/cloud/answer/15549945
- Managing OAuth clients and redirect URI rules: https://support.google.com/cloud/answer/15549257
- Business Profile API access request form: https://support.google.com/business/contact/api_default
- Postiz Google My Business guide: https://docs.postiz.com/self-host/providers/google-my-business
- Postiz posting rules: https://docs.postiz.com/general/platforms/overview