Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 QPM and PERMISSION_DENIED until Google approves the Cloud project for the Business Profile APIs. Approval requires a verified Business Profile active for 60+ days and a website.

Overview

PropertyValue
Identifiergmb
Display nameGoogle My Business
Connection modeoauth, then channelSetupTool (location selection)
Editornormal
Channel kindsocial
Comments / threadsNo. supports_comments = false; only the first post is published
Pending / async publishingNo. supports_pending = false; localPosts.create is synchronous
AnalyticsYes. supports_analytics = true; location-level Performance API metrics
Post analyticsFlag is true, implementation always returns []. Local posts have no per-post metrics; the API is location-level
MentionsNo. supports_mentions = false
is_between_stepstrue
convert_to_jpegfalse
max_concurrent_job3
Character limit (max_length)1500 characters (GBP local-post summary limit)
APIMy Business API v4 (mybusiness.googleapis.com), Account Management v1, Business Information v1, Business Profile Performance v1
OAuth scopeshttps://www.googleapis.com/auth/userinfo.profile, .../userinfo.email, .../business.manage
Token lifetimeGoogle 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
}
VariableRequiredDefaultPurpose
GOOGLE_GMB_CLIENT_IDOne of the pairnonePreferred GMB OAuth client id.
GOOGLE_GMB_CLIENT_SECRETOne of the pairnonePreferred GMB OAuth client secret.
YOUTUBE_CLIENT_IDOne of the pairnoneFallback client id (shared Google app).
YOUTUBE_CLIENT_SECRETOne of the pairnoneFallback client secret.
FRONTEND_URLYes, in practicehttp://localhost:5000Builds the redirect URI. Trailing / stripped.
GOOGLE_AUTH_BASE_URLNohttps://accounts.google.com/o/oauth2/v2/auth.
GOOGLE_OAUTH_BASE_URLNohttps://oauth2.googleapis.com/token, /tokeninfo.
GOOGLE_BASE_URLNohttps://www.googleapis.com/oauth2/v2/userinfo.
GMB_ACCOUNTS_BASE_URLNohttps://mybusinessaccountmanagement.googleapis.com/v1/accounts.
GMB_INFO_BASE_URLNohttps://mybusinessbusinessinformation.googleapis.com/v1/{account}/locations, /v1/{location}, /v1/{location}/media.
GMB_BASE_URLNohttps://mybusiness.googleapis.comv4 local posts: /v4/{id}/localPosts, where {id} is the full accounts/<n>/locations/<m> resource name.
GMB_PERFORMANCE_BASE_URLNohttps://businessprofileperformance.googleapis.com/v1/locations/{id}:fetchMultiDailyMetricsTimeSeries.
GMB_WEB_BASE_URLNohttps://business.google.comFallback 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

  1. Open https://console.cloud.google.com/ → Create project.
  2. Copy the Project number from the project dashboard.
  3. 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.
  4. 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.
  5. 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:

APIUsed for
Google My Business API (v4)localPosts.create — only appears in the library after the access request is approved
My Business Account Management APIGET /v1/accounts
My Business Business Information APIGET /v1/{account}/locations, GET /v1/{location}, GET /v1/{location}/media
Business Profile Performance APIfetchMultiDailyMetricsTimeSeries 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).

Google Auth Platform → Audience:

  1. User type External (or Internal for a Workspace-only deployment).
  2. App name, support email, developer contact, logo, authorized domains.
  3. Add the scopes .../auth/userinfo.profile, .../auth/userinfo.email, .../auth/business.manage.
  4. Add every Google account that will connect as a test user while in Testing.
  5. Publish the app for production. business.manage is a sensitive Business Profile scope and will require OAuth verification (privacy policy, homepage, demo video, scope justification).

5. Create the OAuth client

  1. Google Auth Platform → Clients → Create client.

  2. Type Web application.

  3. Authorized redirect URIs:

    {FRONTEND_URL}/integrations/social/gmb
    

    Example https://social.example.com/integrations/social/gmb. http://localhost:5000/integrations/social/gmb is allowed for local testing. No trailing slash beyond what FRONTEND_URL produces (it is trimmed).

  4. 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 localPosts endpoint still works in 2026 even though accounts, locations and performance moved to v1. The provider depends on it.

Production app review

  1. 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.
  2. Business Profile API access must already be approved (300 QPM). The approval is tied to the Cloud project, not the OAuth client.
  3. 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_EXHAUSTED for gRPC-style clients), which the provider maps to a “Rate limit exceeded” BadBody.
  4. business.manage can be revoked by the user at any time at https://myaccount.google.com/permissions; the next call fails with UNAUTHENTICATED/invalid_grant and 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:

  • locationName is required by fetch_page_information (missing → "locationName is required to fetch the location");
  • id becomes the channel’s internal_id and is the {id} in /v4/{id}/localPosts;
  • accountName is 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)

FunctionParametersReturnsNotes
pagesnonearray 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.

FieldTypeBehavior
topicTypeenum STANDARD, EVENT, OFFERDefaults to STANDARD at publish time when missing/empty.
callToActionTypeenum NONE, BOOK, ORDER, SHOP, LEARN_MORE, SIGN_UP, GET_OFFER, CALLCTA button. NONE disables it. The CTA is only sent when both a type and a non-empty callToActionUrl are present — see the CALL gotcha.
callToActionUrlstringURL opened by the CTA. Validated as an absolute http(s) URL with a host when non-empty.
eventTitlestringRequired for EVENT.
eventStartDatestring YYYY-MM-DDEvent start; invalid/missing → today (local date).
eventEndDatestring YYYY-MM-DDEvent end; invalid/missing → today.
eventStartTimestring HH:MMOptional; parsed into {hours, minutes, seconds: 0, nanos: 0}.
eventEndTimestring HH:MMOptional.
offerCouponCodestringOffer coupon code.
offerRedeemUrlstringOffer redemption URL; validated as an absolute http(s) URL when non-empty.
offerTermsstringOffer 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 in STANDARD/EVENT/OFFER.
  • If topicType == "EVENT", eventTitle must be a JSON string (an empty string passes this check; check_validity catches it later).
  • callToActionType, if present, must be one of the eight enum values.
  • callToActionUrl and offerRedeemUrl, when present and non-empty, must parse as http/https with 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/missing eventTitle → "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 media array with one item: {"mediaFormat": "PHOTO"|"VIDEO", "sourceUrl": <path>}. Validation only rejects paths containing mp4, so a .mov/.webm video path would pass and be sent as VIDEO; Google does not accept video local posts, so keep media to JPEG/PNG. sourceUrl must 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/json
    

    where {id} is the full accounts/<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": "..."
      }
    }
    

    event is emitted only for EVENT with a non-empty title; offer only for OFFER. Empty strings are omitted rather than sent as blanks.

  • languageCode is hard-coded "en". There is no setting for it; the summary text 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 searchUrl when present, otherwise {GMB_WEB_BASE_URL}/locations/<last path segment of id>.
  • No pending state. supports_pending = false; the post is Success as soon as Google returns a resource name. There is no media-processing poll and no finalize_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 metricLabel
WEBSITE_CLICKSWebsite Clicks
CALL_CLICKSPhone Calls
BUSINESS_DIRECTION_REQUESTSDirection Requests
BUSINESS_IMPRESSIONS_DESKTOP_MAPSDesktop Map Views
BUSINESS_IMPRESSIONS_MOBILE_MAPSMobile 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() prefers GOOGLE_GMB_CLIENT_ID and falls back to YOUTUBE_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_status uses 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.manage is 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_information requires locationName. The common mistake is to pass only {"page": ...} (LinkedIn style) or only {"id": ...}. GMB needs id, accountName and locationName, exactly as returned by pages.
  • The internal_id is the full resource name. accounts/123/locations/456 is 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.
  • CALL CTAs are effectively broken. callToAction is only emitted when both callToActionType and a non-empty callToActionUrl exist. A phone-call CTA in GBP does not take a URL, so a CALL setting without a URL is silently dropped, and with a URL Google may reject or ignore it. Use LEARN_MORE with a URL for reliable behavior.
  • Dates default to today, not to the event fields. An invalid/missing eventStartDate/eventEndDate becomes today; an EVENT with only a title can therefore be created as a zero-length event today. Always send both dates.
  • Times are parsed leniently. eventStartTime/eventEndTime split on :; non-numeric parts become 0. "9:5" becomes 09:05, "abc" becomes 00:00. There is no timezone field; GBP interprets the schedule in the location’s timezone.
  • languageCode is fixed to en. 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.create is live on Search/Maps. Tests must account for that.
  • REJECTED is 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 the body in the failure details for the real reason. The body is preserved, except for token calls where without_request_body scrubs it.
  • PERMISSION_DENIED triggers a token refresh. handle_errors maps PERMISSION_DENIED to HandledError::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 Unauthorized message 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_body replaces the recorded request body with {} for BadBody/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 calls userinfo and returns the account id/name/picture. Only the tokens and expiry are stored, so the location internal_id is preserved; do not treat the refresh result’s id as a location id.
  • expires_in can be 0. If Google returns neither expires_in nor expiry_date, the provider stores 0. Since refresh_cron is false, this does not cause a refresh loop; it only means the token’s expiry is unknown.
  • supports_post_analytics = true is 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; a RESOURCE_EXHAUSTED body 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.
  • localPosts is 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