GuideGuides/Build with Synup/Local marketing in your SaaS
How can I add local SEO and local marketing features to my SaaS?

How to Add Local Marketing Features to Your SaaS

Add listings management, reviews, Google posts, profile analytics, rank tracking and AI visibility to a SaaS product with the Synup API, tenant by tenant.

At a glance

What this guide accomplishes
Map your tenants onto Synup clients and locations, run an onboarding flow that connects Google, and expose listings health, reviews, posts, analytics, rankings and AI visibility inside your own product.
APIs used
Reference
ClientsLocationsListings PublishedReviewsPostsProfile AnalyticsSEOAEO
Prerequisites
  • A Synup agency account with an API key that has read and write access to every resource you plan to expose
  • A stable id per tenant and per tenant location in your own database
  • A server-side place to hold the key, which must never reach a browser
Typical use cases
  • Vertical SaaS for dentists, restaurants, gyms, salons or home services adding a marketing tab
  • A website builder or CRM that wants to publish business details to Google, Apple and Bing
  • An agency platform selling local marketing as a module

Your tenant is a Synup client. Your tenant's location is a Synup location. Store those two ids on your side and every local marketing feature, listings, reviews, posts, analytics, rankings, AI visibility, hangs off them as one or two API calls. Nothing to integrate with Google, Apple, Bing or the directories yourself. Below is that mapping, the onboarding call that sets it up, and each feature in the order customers ask for it.

The mapping

  1. Your accountSynup clientOne per paying customer; stores clientId
  2. Your locationSynup locationOne per physical place; stores locationId, sets storeCode
  3. Your userStays yours. Authorisation is your job, the key is one agency key
  4. Feature tabslistings, reviews, posts, analytics, seo, aeoOne or two calls each
Your data model onto Synup's

Hold one agency API key on your server and enforce, in your application, which user may act on which client. Rate limits are per agency, so cache read responses per tenant for an hour and poll on schedules rather than on page loads.

Onboarding a tenant

The onboarding flow creates the client, creates each location, and hands the customer a Google connect link. Everything but the Google consent screen is automatic.

# 1. Create the client (primaryRepresentativeId is one of your team members: GET /api/v1/team/members)
curl -X POST https://ai.synup.com/api/v1/clients \
  -H "Authorization: Bearer $SYNUP_API_KEY" -H "Content-Type: application/json" \
  -d '{ "businessName": "Grove Street Dental", "primaryContactEmail": "owner@grovestreetdental.example", "primaryRepresentativeId": "TEAM_MEMBER_ID" }'

# 2. Create a location under it
curl -X POST https://ai.synup.com/api/v1/locations \
  -H "Authorization: Bearer $SYNUP_API_KEY" -H "Content-Type: application/json" \
  -d '{ "clientId": "CLIENT_ID", "name": "Grove Street Dental Mission", "countryIso": "US", "street": "88 Grove St", "city": "San Francisco", "stateIso": "CA", "postalCode": "94105", "phone": "+1 415 555 0142", "categoryName": "Dentist" }'

# 3. Get the Google connect link to show the customer
curl "https://ai.synup.com/api/v1/connections/google/connect-url?locationId=LOCATION_ID&returnUrl=/connected" \
  -H "Authorization: Bearer $SYNUP_API_KEY"
const API = "https://ai.synup.com/api/v1";
const headers = {
  Authorization: `Bearer ${process.env.SYNUP_API_KEY}`,
  "Content-Type": "application/json",
};

async function call(path, init = {}) {
  const res = await fetch(`${API}${path}`, { ...init, headers });
  const body = await res.json();
  if (!res.ok) throw new Error(`${res.status}: ${body.error}`);
  return body.data;
}

async function onboard(tenant) {
  const { client } = await call("/clients", {
    method: "POST",
    body: JSON.stringify({
      businessName: tenant.name,
      primaryContactEmail: tenant.ownerEmail,
      primaryRepresentativeId: process.env.SYNUP_REP_ID,
    }),
  });

  const locations = [];
  for (const site of tenant.sites) {
    const { locationId } = await call("/locations", {
      method: "POST",
      body: JSON.stringify({
        clientId: client.id,
        name: site.name,
        countryIso: site.country,
        street: site.street,
        city: site.city,
        stateIso: site.state,
        postalCode: site.postalCode,
        phone: site.phone,
        categoryName: site.category,
      }),
    });
    await call(`/locations/${locationId}`, { method: "PATCH", body: JSON.stringify({ storeCode: site.id }) });
    const { url } = await call(`/connections/google/connect-url?locationId=${locationId}&returnUrl=/connected`);
    locations.push({ siteId: site.id, locationId, googleConnectUrl: url });
  }
  return { clientId: client.id, locations };
}
import os
import requests

API = "https://ai.synup.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNUP_API_KEY']}"}

def call(method, path, json=None, params=None):
    r = requests.request(method, f"{API}{path}", headers=HEADERS, json=json, params=params, timeout=30)
    body = r.json()
    if not r.ok:
        raise RuntimeError(f"{r.status_code}: {body.get('error')}")
    return body["data"]

def onboard(tenant):
    client = call("POST", "/clients", {
        "businessName": tenant["name"],
        "primaryContactEmail": tenant["owner_email"],
        "primaryRepresentativeId": os.environ["SYNUP_REP_ID"],
    })["client"]

    locations = []
    for site in tenant["sites"]:
        location_id = call("POST", "/locations", {
            "clientId": client["id"], "name": site["name"], "countryIso": site["country"],
            "street": site["street"], "city": site["city"], "stateIso": site["state"],
            "postalCode": site["postal_code"], "phone": site["phone"], "categoryName": site["category"],
        })["locationId"]
        call("PATCH", f"/locations/{location_id}", {"storeCode": site["id"]})
        url = call("GET", "/connections/google/connect-url",
                   params={"locationId": location_id, "returnUrl": "/connected"})["url"]
        locations.append({"site_id": site["id"], "location_id": location_id, "google_connect_url": url})
    return {"client_id": client["id"], "locations": locations}

Show the connect link in your onboarding UI and poll the connections list until Google appears. The Google Business Profile guide has the polling loop. If you also want customers in Synup's own portal, POST /api/v1/clients/{id}/invite emails them a passwordless link.

Feature 1: Listings health

The first screen customers want is "is my business information right everywhere". GET /api/v1/listings/summary answers it for a whole client in one call: a health percentage, a per-location table, the weakest publishers and ranked "needs attention" cards, each with a filter key you can turn into a drill-down. Per location, GET /api/v1/listings lists every publisher's status and the Google profile completeness score. Editing the profile is the listings guide.

curl "https://ai.synup.com/api/v1/listings/summary?clientId=CLIENT_ID" \
  -H "Authorization: Bearer $SYNUP_API_KEY"

Feature 2: Reviews

Reviews are two calls. GET /api/v1/reviews with status=unreplied is the inbox, and POST /api/v1/reviews/reply posts the reply. The reviews guide covers filters, notes and analytics, and the reputation platform guide covers invites and widgets if reviews become a module of their own.

Feature 3: Posts

POST /api/v1/posts publishes an announcement, event or offer to a location's connected platforms. It publishes real content immediately unless scheduledFor is set or draft is true. Event and offer types are Google-only. A platform with no active connection doesn't fail the call. The post is saved as a draft and the response names the missing platforms, so your UI can prompt the customer to connect.

curl -X POST https://ai.synup.com/api/v1/posts \
  -H "Authorization: Bearer $SYNUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "locationId": "LOCATION_ID",
    "name": "September whitening offer",
    "postType": "offer",
    "platforms": ["google"],
    "messageGoogle": "20% off teeth whitening this September. Book online.",
    "ctaType": "book",
    "ctaUrl": "https://grovestreetdental.example/book",
    "eventTitle": "September whitening offer",
    "eventStartAt": "2026-09-01T09:00:00Z",
    "eventEndAt": "2026-09-30T17:00:00Z",
    "scheduledFor": "2026-09-01T09:00:00Z"
  }'
async function schedulePost(locationId, post) {
  const data = await call("/posts", { method: "POST", body: JSON.stringify({ locationId, ...post }) });
  // data.status: draft | scheduled | active | error
  // data.missingPlatforms: platforms with no connection; data.missingFields: what a draft still needs
  return data;
}
def schedule_post(location_id, post):
    data = call("POST", "/posts", {"locationId": location_id, **post})
    # data["status"]: draft | scheduled | active | error
    # data.get("missingPlatforms"), data.get("missingFields")
    return data

Feature 4: Profile analytics

Two analytics calls, one wide and one deep. GET /api/v1/analytics/summary rolls up profile views, actions, website visits, calls, direction requests and button clicks across a client's locations, with a per-location leaderboard, period comparison and daily, weekly or monthly granularity. GET /api/v1/analytics is the per-location version, and it adds Google search keywords and the maps-versus-search impression split.

curl "https://ai.synup.com/api/v1/analytics/summary?clientId=CLIENT_ID&comparison=previous_period&granularity=weekly" \
  -H "Authorization: Bearer $SYNUP_API_KEY"

Feature 5: Local rank tracking

POST /api/v1/seo/keywords starts tracking up to 25 keywords per location on a grid of search points around the business (3, 5 or 7 per side, a configurable distance apart, set the first time a location is configured). A scan runs in the background for each new keyword. GET /api/v1/seo/keywords returns the tracked keywords, the monthly rank trend, the grid and the stat cards once results land, and GET /api/v1/seo/rollup gives one row per location for a client dashboard.

curl -X POST https://ai.synup.com/api/v1/seo/keywords \
  -H "Authorization: Bearer $SYNUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "locationId": "LOCATION_ID", "keywords": ["dentist san francisco", "teeth whitening near me"], "gridSize": 5, "distanceKm": 1.5 }'

# later
curl "https://ai.synup.com/api/v1/seo/keywords?locationId=LOCATION_ID" \
  -H "Authorization: Bearer $SYNUP_API_KEY"
async function trackKeywords(locationId, keywords) {
  return call("/seo/keywords", {
    method: "POST",
    body: JSON.stringify({ locationId, keywords, gridSize: 5, distanceKm: 1.5 }),
  }); // { keywords: [{ id, keyword }] }; results land asynchronously
}

async function rankOverview(locationId) {
  return call(`/seo/keywords?locationId=${locationId}`); // keywords, months, grid, stats, statCards
}
def track_keywords(location_id, keywords):
    return call("POST", "/seo/keywords",
                {"locationId": location_id, "keywords": keywords, "gridSize": 5, "distanceKm": 1.5})

def rank_overview(location_id):
    return call("GET", "/seo/keywords", params={"locationId": location_id})

Feature 6: AI visibility (AEO)

AI visibility is a background report. POST /api/v1/aeo/reports/enqueue starts one, measuring how ChatGPT, Gemini and Perplexity describe and recommend a location for its tracked prompts. There's no job id, so poll GET /api/v1/aeo/reports and watch generating until it flips to false and generatedAt updates. GET /api/v1/aeo/rollup lists the latest overall and per-engine scores for every location in a client, with portfolio history and the systemic fixes shared across locations.

curl -X POST https://ai.synup.com/api/v1/aeo/reports/enqueue \
  -H "Authorization: Bearer $SYNUP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "locationId": "LOCATION_ID" }'

# poll until generating is false
curl "https://ai.synup.com/api/v1/aeo/reports?locationId=LOCATION_ID" \
  -H "Authorization: Bearer $SYNUP_API_KEY"

Report generation runs several model calls per engine and prompt, so treat it as a monthly job, not a page-load one. Enqueue it on onboarding and on a schedule, and show the rollup in between.

Rollout order

Ship listings health first. It needs no customer action beyond onboarding, and it's the screen that proves the integration is working. Reviews come next, since the Google connection from onboarding already brings them in. Posts, analytics, rankings and AEO each add one tab and one or two calls, all reading from the same client and location ids you stored on day one.

Edge cases across the features

SituationWhat happensWhat to do
A tenant renames their business to a name another client hasCreate fails with code: duplicateClient names are unique per agency. Suffix yours or reuse.
403 on a tenant's locationThe key doesn't cover that clientCheck the key's client access setting.
A post to a platform with no connectionSaved as a draft, missingPlatforms lists itPrompt to connect, then publish with POST /posts/{id}/publish.
More than 25 keywords for a locationCapped; extras aren't trackedLet the customer choose their 25.
AEO enqueue on a native-only locationNot availableReport generation needs a location that predates the legacy cache; show the rollup only.
Analytics with no connected sourceEmpty seriesShow the Google connect prompt instead of an empty chart.