I built a feature recently that I wanted to write up — automated weekly Google Business Profile posts triggered by a cron job. Here is how it works, what the GBP API is actually like, and the gotchas I ran into.
The problem
Local business owners almost never post on Google Business Profile consistently, even though it directly helps local search rankings. The friction is the problem — it is one more task that never gets done.
So I built a system that does it automatically every Monday.
The stack
- Next.js 14 (App Router)
- Vercel Cron — triggers the job every Monday at 3:30am UTC
- Google Business Profile API v4 — publishes the post
- Prisma + Neon — stores connection tokens and post history
- Resend — sends the user an email confirmation
Step 1 — Google OAuth setup
The GBP API requires OAuth 2.0 with the business.manage scope. Standard OAuth flow — redirect to Google, exchange code for tokens, store access + refresh tokens.
const GBP_SCOPE = 'https://www.googleapis.com/auth/business.manage'
export function getGbpAuthUrl(state: string): string {
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
redirect_uri: process.env.GBP_REDIRECT_URI!,
response_type: 'code',
scope: GBP_SCOPE,
access_type: 'offline',
prompt: 'consent', // forces refresh_token to be returned
state,
})
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`
}
Gotcha: Without prompt: 'consent', Google only returns a refresh token on the very first authorisation. If the user has authorised before, you get no refresh token and your access token expires in 1 hour with no way to renew it. Always force consent.
Step 2 — Token refresh helper
Access tokens expire after 1 hour. Before every API call, check expiry and refresh if needed.
export async function getValidGbpToken(userId: string): Promise<string | null> {
const conn = await prisma.gbpConnection.findUnique({ where: { userId } })
if (!conn) return null
// Still valid
if (conn.expiresAt > new Date()) return conn.accessToken
// Refresh
const res = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
refresh_token: conn.refreshToken,
grant_type: 'refresh_token',
}),
})
const data = await res.json()
if (!data.access_token) return null
await prisma.gbpConnection.update({
where: { userId },
data: {
accessToken: data.access_token,
expiresAt: new Date(Date.now() + data.expires_in * 1000),
},
})
return data.access_token
}
Step 3 — Publishing to GBP
The GBP API endpoint for creating a post:
POST https://mybusiness.googleapis.com/v4/{locationId}/localPosts
export async function createGbpPost(
accessToken: string,
locationId: string, // e.g. "accounts/123/locations/456"
content: string,
): Promise<{ name: string }> {
const res = await fetch(
`https://mybusiness.googleapis.com/v4/${locationId}/localPosts`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
languageCode: 'en',
summary: content,
topicType: 'STANDARD',
}),
}
)
if (!res.ok) {
const err = await res.text()
throw new Error(`GBP API error ${res.status}: ${err}`)
}
return res.json()
}
Gotcha: The GBP API is separate from the Google Maps API and the Places API. The base URL is mybusiness.googleapis.com — not maps.googleapis.com. The documentation mixes these up constantly and it caused me 2 hours of confusion.
Step 4 — The Vercel Cron
// vercel.json
{
"crons": [
{
"path": "/api/cron/gbp-posts",
"schedule": "30 3 * * 1"
}
]
}
The route handler:
// app/api/cron/gbp-posts/route.ts
export async function GET(req: Request) {
const cronSecret = process.env.CRON_SECRET
if (!cronSecret || req.headers.get('authorization') !== `Bearer ${cronSecret}`) {
return new Response('Unauthorized', { status: 401 })
}
const connections = await prisma.gbpConnection.findMany({
where: { locationId: { not: null } },
select: { userId: true },
})
const results = await Promise.allSettled(
connections.map((c) => processGbpPostForUser(c.userId))
)
const posted = results.filter(
(r) => r.status === 'fulfilled' && r.value.success
).length
return Response.json({ processed: results.length, posted })
}
Vercel injects the CRON_SECRET automatically and sends it as a Bearer token on every cron invocation — so the auth check just works.
What I learned
- GBP API docs are rough. Half the examples reference deprecated v3 endpoints. Use v4 and expect some trial and error.
-
Always request
offlineaccess and force consent prompt. Otherwise you get a one-hour access token with no refresh capability. -
Extracting the location list is separate from posting. You need
mybusinessaccountmanagement.googleapis.comto list accounts,mybusinessbusinessinformation.googleapis.comto get location details, andmybusiness.googleapis.comto post. Three different base URLs. - Test with a real GBP account. There is no sandbox. Create a test Google Business Profile for a fake business during development.
This is part of SEO-Snap (https://seo-snap.com) — an SEO audit tool for local businesses. Happy to answer questions on the GBP API — drop them in the comments.
Top comments (0)