Fold for Craft CMS

JSON endpoint

GET /fold/search.json?q=28202&radius=25

The same search the built-in locator runs, as JSON, for anyone building their own front end. It is read-only, anonymous, and GET-shaped, which is what lets it be cached at the edge.

It answers in JSON whatever the request's Accept header says, so it can be pasted into a browser or curled as it is:

curl 'https://example.com/fold/search.json?q=28202&radius=25'

The action URL, /actions/fold/search/index, answers the same way; /fold/search.json is the stable, documented one.

Parameters

ParameterMeaning
qA town, postcode, address, or a lat,lng pair. Cut to 100 characters.
lat, lngCoordinates, which skip geocoding entirely and win over q
radiusDefaults to Default radius. Clamped to the widest radius the site offers — see below.
unitmi or km
limit, offsetPaging. limit is clamped to Maximum results (200 by default), however much is asked for.
groupRestrict to a location group, by handle. group[]=retail&group[]=outlet for several.
openNowOnly locations open at the moment of the search
inStockOfA purchasable's ID — Pro, with Commerce installed
countryA two-letter code to bias geocoding; defaults to Default country. Anything that is not two letters is ignored.

The query string belongs to whoever is sending it, so it is checked before it is searched with:

  • Array values are ignored (except group), rather than becoming a server error.
  • The radius is clamped to the widest of the Radius options and the Default radius — 100 miles out of the box. radius=0, or a negative, means that maximum, not "no limit": an unlimited public search would be a distance calculation over every row, which is exactly what the bounding box exists to prevent. Unlimited searches remain available from Twig and PHP, which are trusted.
  • q is capped and country validated because both are part of the geocode cache key — every distinct value would otherwise be a fresh provider request.

craft.fold.locator() reads q, lat, lng and radius from the page's query string through the same checks.

Rate limit

Each visitor IP may make Searches per visitor per minute requests (30 by default). Past that, the endpoint answers 429 with a JSON error until the minute is up. The same budget limits how many uncached geocoder lookups a visitor's searches can cause anywhere on the front end. Set it to 0 to turn it off if you rate-limit at a CDN instead.

inStockOf is ignored unless Commerce is installed and the edition is Pro. Rather than pretending to filter, the response then simply carries no inStock on its locations — so a front end can tell the difference between "filtered by stock" and "not filtered".

An ID only resolves to a purchasable a visitor could buy: enabled, on the current site, and available for purchase. Anything else — a disabled or unreleased product — matches no locations.

Response

{
    "term": "28202",
    "origin": { "lat": 35.2271, "lng": -80.8431, "formatted": "Charlotte, Mecklenburg County, North Carolina, 28202, United States" },
    "radius": 25,
    "unit": "mi",
    "total": 4,
    "count": 4,
    "bounds": [35.0613, -80.9432, 35.4121, -80.7194],
    "originNotFound": false,
    "matchedByName": false,
    "locations": [
        {
            "id": 1042,
            "title": "Uptown",
            "url": "https://example.com/stores/uptown",
            "group": "retail",
            "color": "#d9480f",
            "lat": 35.2269,
            "lng": -80.8433,
            "distance": 0.02,
            "address": "200 S Tryon St\nCharlotte, NC 28202\nUnited States",
            "addressLines": ["200 S Tryon St", "Charlotte, NC 28202", "United States"],
            "phone": "704-555-0100",
            "email": "uptown@example.com",
            "websiteUrl": null,
            "directionsUrl": "https://www.google.com/maps/dir/?api=1&destination=35.2269%2C-80.8433",
            "hours": { "mon": [{ "open": "09:00", "close": "17:30" }], "sat": [{ "open": "10:00", "close": "16:00" }] },
            "openNow": true,
            "timezone": "America/New_York"
        }
    ]
}
FieldMeaning
originWhere the search was measured from, or null. formatted is the provider's name for the place it matched — worth showing back to the visitor ("Showing shops near Charlotte, NC").
totalMatches before paging (and before openNow)
countLocations in this response
bounds[south, west, north, east] around the results and the origin — the order Leaflet's fitBounds and Google's LatLngBounds both take
originNotFoundThe term could not be placed and matched no location names
matchedByNameThe term was not a place, but matched a location's name
colorThe location's group Marker colour as #rrggbb, or null when the group has none
distanceIn unit, rounded to two places; null when there was no origin
openNowtrue or false, or null for a location with no hours
inStockOnly when inStockOf was honoured: whether that location has it available
availableStockOnly when inStockOf was honoured and Publish exact stock counts is on: the available quantity there

Tell "not found" from "nothing near"

A front end that only looks at count will tell a visitor who misspelt their own town that there are no shops near them. Check originNotFound first:

if (data.originNotFound) {
    show(`We couldn't find “${data.term}”. Try a town, a postcode, or a full address.`);
} else if (!data.count) {
    show('No shops within that distance. Try a wider radius.');
}

The shape is a contract

The response is composed field by field, not by serializing location elements. Adding a custom field to a location group cannot change the endpoint's output, and nothing that happens to be public on an element in PHP leaks into a public response. If you need a custom field in your own front end, render it in Twig — or build a small endpoint of your own on top of craft.fold.search().