Google My Business (Google Business Profile)
Category: Local SEO / Publishing
Integration type: Tenant OAuth (stored in integrations table)
External API: Google Business Profile API v1 (formerly My Business API)
Purpose
Google My Business (now officially called Google Business Profile, GBP) integration serves two distinct functions:
- GBP Post publishing — The GBP Post Writer agent generates posts (offers, updates, events); the GBP Publisher worker pushes them to the tenant’s business listing on Google Search and Google Maps
- Review management — The Review Response Writer agent generates responses to customer reviews; the DM Portal shows pending reviews for HITL approval before responses are posted
- Business insights — Profile views, search queries, direction requests, phone call clicks — pulled for the Report Writer
GBP is particularly high-value for local-service businesses (plumbers, dentists, restaurants) where Google Maps visibility directly drives revenue.
Config Structure
OAuth flow
scope: https://www.googleapis.com/auth/business.manage
Stored in integrations:
provider: 'google_my_business'
api_key: encrypt(refresh_token)
metadata: {
accountId: 'accounts/123456789', // GBP Account ID
locationId: 'accounts/123456789/locations/987654321', // Location resource name
locationName: 'Acme Plumbing - Sydney',
accessToken: string,
accessTokenExpiresAt: string,
}Platform OAuth credentials (env vars)
The Google Business Profile API uses the same Google OAuth app as Search Console and Analytics. Only the scope differs.
GOOGLE_CLIENT_ID=xxxxxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxxxxxxxIntegration Pattern
Publishing a GBP post
class GoogleMyBusinessTool {
constructor(
private locationId: string, // Full resource name: 'accounts/.../locations/...'
private accessToken: string,
private baseUrl: string = 'https://mybusiness.googleapis.com/v4',
) {}
private headers() {
return { Authorization: `Bearer ${this.accessToken}` };
}
async createPost(options: {
summary: string; // Post body text (max 1,500 chars)
callToAction?: {
actionType: 'BOOK' | 'ORDER' | 'SHOP' | 'LEARN_MORE' | 'SIGN_UP' | 'CALL';
url?: string; // Required for all except CALL
};
mediaUrl?: string; // Public image URL
topicType?: 'STANDARD' | 'EVENT' | 'OFFER'; // Default: STANDARD
event?: {
title: string;
startDate: string; // YYYY-MM-DD
endDate: string;
};
offer?: {
couponCode?: string;
redeemUrl?: string;
termsConditions?: string;
};
}): Promise<{ postName: string; postUrl?: string }> {
const body: any = {
languageCode: 'en',
summary: options.summary,
topicType: options.topicType ?? 'STANDARD',
};
if (options.callToAction) {
body.callToAction = {
actionType: options.callToAction.actionType,
url: options.callToAction.url,
};
}
if (options.mediaUrl) {
body.media = [{
mediaFormat: 'PHOTO',
sourceUrl: options.mediaUrl,
}];
}
if (options.topicType === 'EVENT' && options.event) {
body.event = {
title: options.event.title,
schedule: {
startDate: this.parseDate(options.event.startDate),
endDate: this.parseDate(options.event.endDate),
},
};
}
if (options.topicType === 'OFFER' && options.offer) {
body.offer = options.offer;
}
const response = await axios.post(
`${this.baseUrl}/${this.locationId}/localPosts`,
body,
{ headers: this.headers() },
);
return {
postName: response.data.name,
postUrl: response.data.searchUrl,
};
}
async listReviews(options?: {
pageSize?: number;
orderBy?: 'update_time desc' | 'rating desc';
}): Promise<GBPReview[]> {
const response = await axios.get(
`${this.baseUrl}/${this.locationId}/reviews`,
{
headers: this.headers(),
params: {
pageSize: options?.pageSize ?? 20,
orderBy: options?.orderBy ?? 'update_time desc',
},
},
);
return (response.data.reviews ?? []).map((r: any) => ({
reviewId: r.reviewId,
reviewer: r.reviewer.displayName,
starRating: r.starRating, // 'ONE' | 'TWO' | 'THREE' | 'FOUR' | 'FIVE'
comment: r.comment,
createTime: r.createTime,
updateTime: r.updateTime,
reply: r.reviewReply?.comment ?? null,
}));
}
async replyToReview(reviewId: string, reply: string): Promise<void> {
await axios.put(
`${this.baseUrl}/${this.locationId}/reviews/${reviewId}/reply`,
{ comment: reply },
{ headers: this.headers() },
);
}
async getInsights(options: {
startDate: string; // YYYY-MM-DD
endDate: string;
metrics: string[];
}): Promise<LocationInsight[]> {
const response = await axios.post(
`${this.baseUrl}/${this.locationId}:reportInsights`,
{
locationNames: [this.locationId],
basicRequest: {
metricRequests: options.metrics.map(m => ({ metric: m })),
timeRange: {
startTime: new Date(options.startDate).toISOString(),
endTime: new Date(options.endDate).toISOString(),
},
},
},
{ headers: this.headers() },
);
return response.data.locationMetrics ?? [];
}
async verify(): Promise<void> {
const response = await axios.get(
`${this.baseUrl}/${this.locationId}`,
{ headers: this.headers() },
);
if (!response.data.name) throw new Error('Google Business Profile location not found');
}
private parseDate(dateStr: string) {
const [year, month, day] = dateStr.split('-').map(Number);
return { year, month, day };
}
}Review Response Workflow
Review posted on Google Maps
│
▼ (webhook or polling — GBP has no webhook, polling every 4hrs)
Review saved to reviews table in PostgreSQL
│
▼
Review Response Writer agent generates draft reply
│
▼ (HITL — shown in DM Portal reviews queue)
DM staff approves or edits reply
│
▼
GBP Publisher posts reply via replyToReview()GBP Post Types
| Type | Use | Key fields |
|---|---|---|
STANDARD | General updates, news, announcements | summary, optional image + CTA |
EVENT | Workshop, open day, seasonal event | summary, event.title, startDate, endDate |
OFFER | Discount, promotion, limited-time deal | summary, offer.couponCode, offer.redeemUrl, expiry |
GBP posts expire after 7 days (STANDARD) or at the event end date (EVENT/OFFER). The Social Calendar Planner accounts for this when scheduling GBP posts.
Test Cases
Unit tests (packages/tools/src/google-my-business.test.ts)
| Test | Approach |
|---|---|
createPost() POSTs to /localPosts with correct body | Mock axios.post; assert summary, topicType |
createPost() includes event block when topicType: 'EVENT' | Assert event dates in request |
createPost() omits CTA when not provided | Assert no callToAction in body |
listReviews() maps star rating strings | Mock response; assert starRating: 'FIVE' |
replyToReview() PUTs to correct URL | Assert URL contains reviews/{reviewId}/reply |
getInsights() passes correct metric requests | Assert metricRequests array |
verify() throws when location not found | Mock 404; assert throws |
Related
- GBP Post Writer Agent — generates GBP post copy
- Review Response Writer Agent — generates review replies
- Google Search Console Provider — same Google OAuth app
- Tool Integration Layer — OAuth management