Google Business photos — list, upload, add by URL and delete
The photos and videos on a connected business’s Google listing, served by Google’s My Business API (v4). Listing and deleting are one metered Google call; an upload is three and adding by URL is one. All need a package that includes the connector.
Two ways to add a photo.
- Upload the file with
POST …/media/upload: amultipart/form-datarequest with a JPEG or PNG of 10 KB to 5 MB. Use it when the picture only exists as a file (a client’s phone, your own upload form). - Add it by URL with
POST …/media: Google fetches the picture from a publichttpsaddress. This is the only way to add a video. If Google cannot fetch the image or rejects its size or format, the call answers400 invalid_input; Google’s picture requirements are its own.
The media object
Section titled “The media object”{ "id": "m1", "format": "PHOTO", "category": "EXTERIOR", "url": "https://lh3.googleusercontent.com/…", "thumbnailUrl": "https://lh3.googleusercontent.com/…=s200", "width": 800, "height": 600, "createdAt": "2026-10-14T03:10:00.000Z"}| Field | Meaning |
|---|---|
id |
The media id for delete |
format |
PHOTO or VIDEO |
category |
Where Google shows it: one of COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, AT_WORK, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, ADDITIONAL; or null |
url, thumbnailUrl |
Google-hosted addresses, or null |
width, height |
Pixels, or null |
createdAt |
ISO timestamp, or null |
GET /gmb/locations/{id}/media
Section titled “GET /gmb/locations/{id}/media”| Scope | read or gmb |
| Query | pageSize 1–100 (default 25), pageToken |
curl "https://app.maukirim.com/api/v1/gmb/locations/gml_8f3kq2x1ab/media" -H "Authorization: Bearer mk_live_…"{ "ok": true, "media": [ { "id": "m1", "format": "PHOTO", "category": "EXTERIOR", "…": "…" } ], "totalMediaItemCount": 1, "nextPageToken": null }POST /gmb/locations/{id}/media
Section titled “POST /gmb/locations/{id}/media”Adds a photo or video to the public listing. 201 Created.
| Scope | gmb |
| Header | Idempotency-Key (optional, recommended): 8–128 characters of letters, digits and _ . : - |
| Field | Required | Rule |
|---|---|---|
sourceUrl |
yes | An absolute https URL, up to 2,000 characters |
mediaFormat |
no | PHOTO (default) or VIDEO |
category |
no | One of the categories above; default ADDITIONAL |
Any other field is 400 invalid_input.
curl -X POST https://app.maukirim.com/api/v1/gmb/locations/gml_8f3kq2x1ab/media \ -H "Authorization: Bearer mk_live_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: media-gml_8f3kq2x1ab-front-1" \ -d '{ "sourceUrl": "https://cdn.example/front.jpg", "category": "EXTERIOR" }'const response = await fetch(`https://app.maukirim.com/api/v1/gmb/locations/${id}/media`, { method: "POST", headers: { Authorization: `Bearer ${process.env.MAUKIRIM_KEY}`, "Content-Type": "application/json", "Idempotency-Key": `media-${id}-front-1`, }, body: JSON.stringify({ sourceUrl: "https://cdn.example/front.jpg", category: "EXTERIOR" }),})const { media } = await response.json()$curl = curl_init("https://app.maukirim.com/api/v1/gmb/locations/$id/media");curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('MAUKIRIM_KEY'), 'Content-Type: application/json', "Idempotency-Key: media-$id-front-1", ], CURLOPT_POSTFIELDS => json_encode(['sourceUrl' => 'https://cdn.example/front.jpg', 'category' => 'EXTERIOR']), CURLOPT_RETURNTRANSFER => true,]);$media = json_decode(curl_exec($curl), true)['media'];{ "ok": true, "replayed": false, "media": { "id": "m2", "format": "PHOTO", "category": "EXTERIOR", "…": "…" } }The same key with the same body is "replayed": true and adds no second photo; a different body under the
same key is 409 gmb_idempotency_conflict.
POST /gmb/locations/{id}/media/upload
Section titled “POST /gmb/locations/{id}/media/upload”Uploads a photo file to the public listing. 201 Created. The request is multipart/form-data, not JSON.
| Scope | gmb |
| Content-Type | multipart/form-data (your HTTP client sets the boundary) |
| Header | Idempotency-Key (optional, recommended): 8–128 characters of letters, digits and _ . : - |
| Form field | Required | Rule |
|---|---|---|
file |
yes | The picture. JPEG or PNG, from 10 KB to 5 MB |
category |
no | One of the categories above; default ADDITIONAL |
What the file is is decided by its bytes, not by its name or the Content-Type your client declares for the
part. A JPEG sent as application/octet-stream is accepted; a script named logo.png is refused with
415 gmb_unsupported_media. GIF, WebP, HEIC and every other format are refused the same way, so convert them
first. A file under 10 KB is 400 invalid_input (Google rejects it upstream, so MauKirim says so before spending
budget). A file over 5 MB is 413 gmb_file_too_large; when your client announces the size in Content-Length
the request is refused from that header (allowing a little for the form’s own framing) before the body is read, so an oversized upload is never held in memory and costs no Google budget.
curl -X POST https://app.maukirim.com/api/v1/gmb/locations/gml_8f3kq2x1ab/media/upload \ -H "Authorization: Bearer mk_live_…" \ -H "Idempotency-Key: upload-gml_8f3kq2x1ab-front-1" \ -F "file=@front.jpg" \ -F "category=EXTERIOR"import { openAsBlob } from "node:fs"
const form = new FormData()form.set("file", await openAsBlob("front.jpg"), "front.jpg")form.set("category", "EXTERIOR")
const response = await fetch(`https://app.maukirim.com/api/v1/gmb/locations/${id}/media/upload`, { method: "POST", // Do not set Content-Type yourself: fetch adds the multipart boundary. headers: { Authorization: `Bearer ${process.env.MAUKIRIM_KEY}`, "Idempotency-Key": `upload-${id}-front-1`, }, body: form,})const { media } = await response.json()$curl = curl_init("https://app.maukirim.com/api/v1/gmb/locations/$id/media/upload");curl_setopt_array($curl, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('MAUKIRIM_KEY'), "Idempotency-Key: upload-$id-front-1", ], // An array with a CURLFile makes cURL send multipart/form-data. CURLOPT_POSTFIELDS => [ 'file' => new CURLFile('front.jpg', 'image/jpeg', 'front.jpg'), 'category' => 'EXTERIOR', ], CURLOPT_RETURNTRANSFER => true,]);$media = json_decode(curl_exec($curl), true)['media'];{ "ok": true, "replayed": false, "media": { "id": "m3", "format": "PHOTO", "category": "EXTERIOR", "…": "…" } }It costs three Google calls, each metered against the shared budget (see rate limits):
Google first reserves a place for the file, then MauKirim sends the bytes, then it creates the media item that
points at them. Any of the three can be answered 429 gmb_rate_limited with Retry-After; wait and send the
upload again with the same Idempotency-Key. If the last step fails the bytes sit unused on Google’s side, which
is harmless.
The same key with the same file and category is "replayed": true and uploads nothing twice; the same key
with a different file or category is 409 gmb_idempotency_conflict.
DELETE /gmb/locations/{id}/media/{mediaId}
Section titled “DELETE /gmb/locations/{id}/media/{mediaId}”Removes one photo or video from the public listing. It cannot be undone. Returns { "ok": true }.
| Scope | gmb |
curl -X DELETE https://app.maukirim.com/api/v1/gmb/locations/gml_8f3kq2x1ab/media/m1 -H "Authorization: Bearer mk_live_…"Errors
Section titled “Errors”| HTTP | Code | Condition |
|---|---|---|
| 400 | invalid_input |
A field outside the list, sourceUrl not an https URL, an unknown category or mediaFormat, a malformed key or id, or Google rejected the file. For an upload also: no file field (not multipart), an empty file, or a file under 10 KB |
| 401 | api_key_invalid / api_key_revoked / api_key_expired |
|
| 402 | gmb_plan_required |
The package does not include the connector |
| 403 | api_key_scope_denied |
Writes need gmb; reads need read or gmb |
| 403 | gmb_forbidden |
Google says the connected account may not change this business’s photos |
| 404 | gmb_location_not_found |
Not your business, no such business, it left Google, or (delete) no such photo |
| 413 | gmb_file_too_large |
(Upload) The file is over 5 MB. Refused from Content-Length before the body is read when it is announced |
| 415 | gmb_unsupported_media |
(Upload) The bytes are not a JPEG or PNG, whatever the file name or declared type says |
| 409 | gmb_reconnect_required / gmb_location_disabled / gmb_idempotency_conflict |
As on posts |
| 429 | gmb_rate_limited |
No budget now (Retry-After) |
| 502 | gmb_upstream_error |
Google failed |
| 503 | gmb_api_unavailable |
The Business Profile API is not enabled or approved for the deployment’s Google project |