DEV Community

Vinit Gohil
Vinit Gohil

Posted on

How I built automated Google Business Profile(GBP) posting with the GBP API (and what I learned)

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}`
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Step 3 — Publishing to GBP

The GBP API endpoint for creating a post:

POST https://mybusiness.googleapis.com/v4/{locationId}/localPosts
Enter fullscreen mode Exit fullscreen mode
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()
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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

  1. GBP API docs are rough. Half the examples reference deprecated v3 endpoints. Use v4 and expect some trial and error.
  2. Always request offline access and force consent prompt. Otherwise you get a one-hour access token with no refresh capability.
  3. Extracting the location list is separate from posting. You need mybusinessaccountmanagement.googleapis.com to list accounts, mybusinessbusinessinformation.googleapis.com to get location details, and mybusiness.googleapis.com to post. Three different base URLs.
  4. 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)