LocalBusiness Schema: Generate JSON-LD From a Real Record

LocalBusiness schema needs two fields to be valid and about eight more to be useful, and the useful ones are the ones people get wrong: an address flattened into a single string, opening hours that say the shop is closed all week, an aggregateRating copied from somewhere else. This walks through each field, then generates the whole block from a real business record instead of a template.

What LocalBusiness schema is for

A LocalBusiness block is a machine-readable copy of the facts already printed on your contact page: who you are, where you are, when you are open, how to reach you. Search engines read it to build map cards and knowledge panels. Assistants increasingly read it too, and an assistant that cannot parse your hours will answer from whatever else it can find.

Use the JSON-LD form in a <script type="application/ld+json"> tag. Microdata sprinkled through the HTML still works, but it ties the data to the layout, so a redesign breaks it silently. JSON-LD sits in one block you can regenerate.

Required fields versus recommended ones

Schema.org defines hundreds of properties on LocalBusiness and marks almost none of them mandatory. Two fields decide whether the block is usable at all; the rest decide how much a search engine or an assistant has to guess.

PropertyStatusWhat goes wrong without it
nameRequiredNothing to attach the record to
addressRequiredThe block validates as a business with no location, which is the one thing a local result needs
telephoneRecommendedCall buttons have nothing to dial
urlRecommendedThe record cannot be tied to the canonical page
geoRecommendedPlacement depends entirely on parsing the address text
openingHoursSpecificationRecommendedNo "open now" answer anywhere
imageRecommendedCards render without a picture
priceRange, sameAs, aggregateRatingRecommendedNothing breaks; each is a fact left unstated

address is where most markup fails a check. It has to be a nested PostalAddress object with streetAddress, addressLocality, addressRegion, postalCode and addressCountry as separate properties. A single line — the whole address as one string — parses as JSON and then reports as an invalid address type. Country goes in as the two-letter ISO code, PT rather than Portugal.

The recommended list is not decoration. A validator reports missing recommended fields separately from required ones precisely so you can decide which are worth filling; for a business with a physical door, geo and openingHoursSpecification are worth more than the rest combined.

How openingHoursSpecification works

Each entry is an OpeningHoursSpecification object with a dayOfWeek (a schema.org day URL or its short name), an opens time and a closes time, both in 24-hour HH:MM. Days that share the same hours can be listed together as an array inside one entry, which is why a typical business needs two or three entries rather than seven.

"openingHoursSpecification": [
  {
    "@type": "OpeningHoursSpecification",
    "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
    "opens": "09:00",
    "closes": "18:30"
  },
  {
    "@type": "OpeningHoursSpecification",
    "dayOfWeek": "Saturday",
    "opens": "09:00",
    "closes": "13:00"
  }
]

Four mistakes turn up repeatedly in hand-written hours blocks:

  • Listing a closed day with 00:00 to 00:00. Omit the day instead. A day that is not mentioned is closed; a day written as midnight to midnight is ambiguous and some parsers read it as open all day.
  • Splitting an overnight shift across two days. A bar open Friday 18:00 to 02:00 is one Friday entry with closes: "02:00". Writing a second entry for Saturday 00:00 to 02:00 double-counts it.
  • Forgetting the lunch break. A business open 09:00 to 13:00 and again 15:00 to 19:00 needs two entries for the same day. One entry from 09:00 to 19:00 says you are open through the closed hours.
  • Using 12-hour times or local formats. "9am" and "09.00" are strings, not times. Only HH:MM parses.

Hours are a data problem, not a markup problem. Correctly formatted hours that are eighteen months out of date are worse than no hours at all, because a card now confidently tells someone to drive over on a Sunday. Whatever you generate, regenerate it when the source changes.

sameAs and aggregateRating, honestly

sameAs takes an array of URLs that identify the same organisation elsewhere: your Facebook page, LinkedIn company page, Instagram profile, X account. It is one of the easiest properties to fill correctly and one of the easiest to get wrong by listing a personal profile, a share link, or a page that belongs to a different branch. Use the profile URL only.

aggregateRating is different, and it deserves a warning rather than a tip. It should carry a ratingValue and a reviewCount for reviews that you collected, that are genuine, and that a visitor can see on the same page. Marking up a rating that appears nowhere on the page is exactly the kind of thing search engines treat as a violation, and the consequence is not a missing star — it is structured data on the whole site being distrusted.

So: never invent a rating, never round one up, and never lift the star average from a third-party listing into your own page's markup. If you have no reviews of your own, leave the property out. The block is entirely valid without it.

Generating LocalBusiness JSON-LD from a place_id

Writing all of this by hand for one location is an afternoon. For forty locations it is a project, and the hours will be wrong within a quarter. POST /v1/schema/generate takes a place_id and fills the block from the live record: name, address in parts, coordinates, phone in international format, website, rating with its review count, all seven days of hours, and the social profiles found on the business website.

Get the place_id from a search first. It is the ChIJ… identifier on every result from the search endpoint, and it is stable, so you can store it and regenerate the markup later without searching again.

curl "https://api.locomint.io/v1/places/search?q=dentist&location=Lisbon,%20Portugal&limit=3" \
  -H "X-API-Key: $LOCOMINT_KEY"

curl -X POST "https://api.locomint.io/v1/schema/generate" \
  -H "X-API-Key: $LOCOMINT_KEY" -H "Content-Type: application/json" \
  -d '{"place_id": "ChIJPy04-ZkzGQ0RvdLuGmOMygg"}'

The same thing in Python, from a search straight through to a file you can paste into a template:

import os

import httpx

API = "https://api.locomint.io/v1"
KEY = os.environ["LOCOMINT_KEY"]

with httpx.Client(base_url=API, headers={"X-API-Key": KEY}, timeout=60) as client:
    search = client.get("/places/search", params={
        "q": "dental clinic",
        "location": "Lisbon, Portugal",
        "limit": 3,
    })
    search.raise_for_status()

    for place in search.json()["data"]:
        resp = client.post("/schema/generate", json={"place_id": place["place_id"]})
        resp.raise_for_status()
        body = resp.json()

        jsonld = body["data"]          # the JSON-LD document itself
        check = body["check"]          # our validator run over that document

        print(place["name"], "->", jsonld["@type"],
              "valid:", check["valid"],
              "missing:", [f for i in check["items"] for f in i["missing_recommended"]])

        with open(f"{place['place_id']}.html", "w", encoding="utf-8") as fh:
            fh.write(body["script"])

Three keys sit at the top level of the response, not inside data: data is the JSON-LD document, script is that document already wrapped in a script tag, and check is the validator's verdict on it. Reading body["data"]["script"] is the mistake people make on the first attempt, and it fails with a KeyError rather than a bad file.

Always send the country with the city. In our own daily canary runs "Warsaw" on its own resolved to Warsaw, Indiana; "Warsaw, Poland" cannot. Check meta.geocoded_location on the search response the first time you use a new city, because a search that found the wrong town still charges a credit per place it returned.

Each generate call costs one credit, and the search that found the id costs one credit per place returned. Forty locations is roughly eighty credits, inside the free plan's 200 a month. The plans on the pricing page matter only when you regenerate hundreds of locations on a schedule.

How the generator picks Dentist over plain LocalBusiness

Schema.org defines dozens of LocalBusiness subtypes, and the more specific one is better when it exists. The generator reads the business categories on the record and picks the closest documented type: a dentist becomes Dentist, a cafe becomes CafeOrCoffeeShop. When no subtype fits, it stays with plain LocalBusiness rather than guessing.

The subtype does not change what is required. A Dentist is judged against the LocalBusiness ruleset, because that is the ruleset search engines document requirements for, and the validator response says so in its checked_against field. The benefit of the specific type is descriptive: it tells a reader what kind of business this is without them parsing your category text.

If you already have a page and no place_id, pass a url instead and the generator builds Organization markup from what the page says about itself. That is the weaker of the two paths, because it can only repeat what is already published, but it is the right one for a business without a public listing.

Pasting the script tag into your page

The response includes both the raw JSON-LD and a ready-made script field: the same document already wrapped in <script type="application/ld+json">. Paste that into the page's <head>, or anywhere in the <body> — position does not affect how it is read.

Three things to check after pasting. Put it on the page the markup describes, not on every page of the site: a location block belongs on that location's page. Make sure url matches the canonical URL of that page. And if your CMS already emits a LocalBusiness block from a theme or a plugin, remove one of them, because two conflicting descriptions of the same business is worse than one incomplete description.

The generated markup is run back through the validator before it is returned, so the check block tells you what is missing before you paste anything. Fields the source record does not carry appear there as missing_recommended: image on a business with no photos, openingHoursSpecification on a service-area business with no storefront, aggregateRating on a place with no reviews. Those are yours to supply, and the generator will not invent them.

Where to start this week

Take one location, run the two calls above, and diff the generated block against whatever your site emits today. The differences are usually the same three: a flattened address, missing geo, and hours for one day instead of seven.

If you have many locations, store the place_id for each one next to the page it belongs to, then regenerate on a schedule — quarterly is enough for hours — so the markup follows reality instead of drifting from it. The Structured Data API page shows the full response for both validate and generate, and the details endpoint is where the same record comes from, including the seven days of hours and the socials that feed sameAs.

Frequently asked questions

Which LocalBusiness schema fields are actually required?

Name and address are the fields a LocalBusiness cannot do without, and address has to be a PostalAddress object with its parts separated rather than one string. Everything else, including telephone, url, geo, openingHoursSpecification and image, is recommended: leaving one out will not invalidate the markup, but each one you fill in is a fact a search engine or an assistant no longer has to guess.

How do I mark up a business that is open past midnight?

Write the closing time as it is, on the day the shift starts. A bar open Friday from 18:00 until 02:00 gets one openingHoursSpecification entry for Friday with opens 18:00 and closes 02:00. Do not split it into two entries across two days, and do not write 24:00 as 00:00, because 00:00 to 00:00 reads as closed rather than open all day.

Can I add an aggregateRating I collected myself?

Only if the reviews are genuine, were collected by you, and are visible to a visitor on the same page. An aggregateRating that no reader can verify is the fastest way to get structured data ignored or penalised. If you do not have your own reviews, leave the property out entirely rather than borrowing a number from a third-party listing.