Polygon Search with RealtyAPI: A Practical Developer Guide

You've drawn a clean neighborhood boundary, sent it to the listings API, and received results that don't match what the map appears to show. A parcel touching the line is missing. A second request returns a different page. A saved search produces duplicates after a retry. The map interaction was easy. The production contract behind it wasn't.
Polygon search turns a user-drawn area into a spatial query over property records. To ship it reliably, you need to decide which predicate the endpoint applies, how the GeoJSON payload is validated and transported, and how result order and pagination remain stable across retries. This guide builds those pieces around RealtyAPI, with a validated GeoJSON Polygon, REST and GraphQL request shapes, Python and JavaScript clients, and a map-based QA workflow. The relevant API context is outlined in the RealtyAPI introduction.
What Polygon Search Actually Means for a Developer
A polygon search starts with a coordinate set and ends with a set of property records. The user may see a freehand boundary, but the backend sees a geometry object that must be parsed, indexed, compared, and joined to listing data. The query only becomes meaningful after the service applies a spatial predicate such as intersects, contains, or within.
Those predicates aren't interchangeable. Intersects generally returns records whose geometry overlaps the search area, including records that may only touch or partially cross the boundary. Within is stricter. It asks whether the searched object is fully contained by the polygon. A property point, parcel polygon, and listing coordinate can therefore produce different answers for the same drawn region.
Practical rule: Put the predicate in the API contract, not in the map UI. A user can draw the same shape while the backend changes from overlap logic to full containment.
Three decisions deserve explicit documentation before the first client ships:
- Predicate behavior: Decide whether the endpoint uses intersects or within, and document how boundary-touching records are classified.
- Payload shape: Define whether the request accepts a GeoJSON Polygon, a raw coordinate array, or a Feature wrapper. Avoid accepting several ambiguous formats unless you normalize them immediately.
- Result stability: Decide how pagination, sorting, cursors, retries, and inventory changes interact. A polygon query that returns plausible first-page results can still be operationally unreliable.
Spatial databases make this distinction concrete. PostGIS documentation describes an indexed bounding-box check followed by exact geometry tests such as ST_Intersects, ST_Contains, and ST_Within, because the index narrows candidates before the expensive geometric comparison. That approach is why polygon search is an indexed database capability rather than a text-search filter. PostGIS spatial query documentation also makes clear that the exact predicate is part of the query's meaning.
The implementation artifacts should be equally concrete: a validated GeoJSON Polygon, one REST request, one GraphQL request, client snippets in Python and JavaScript, and a visual result layer that lets you compare returned properties with the submitted boundary. Polygon search isn't a UI feature with an API attached. It's a contract between coordinates and an indexed property table.
Defining and Validating a GeoJSON Polygon
A minimal GeoJSON Polygon has a type and a coordinates member. The coordinates contain an array of linear rings, and the first ring is the exterior boundary. Each position is an [longitude, latitude] pair, not [latitude, longitude].
A four-sided neighborhood boundary can look like this:
{
"type": "Polygon",
"coordinates": [[
[-73.9910, 40.7350],
[-73.9810, 40.7350],
[-73.9810, 40.7420],
[-73.9910, 40.7420],
[-73.9910, 40.7350]
]]
}
The closing coordinate is deliberate. The first and last coordinate pairs must be identical, or the linear ring is open. Naive clients often collect map vertices and forget to append the first point at submission time.
Ring winding also matters. Use counterclockwise winding for the exterior ring and clockwise winding for holes. Normalize winding in one place rather than trusting every map library or hand-built fixture to produce the same orientation. Microsoft's Polygon geometry documentation describes a polygon as an exterior bounding ring with zero or more interior rings. It also specifies that interior rings may touch at tangent points but cannot cross, and that a geography instance larger than a hemisphere is invalid.
Fail before the network call
Client-side validation saves a round trip and produces errors developers can understand. In JavaScript, Turf provides a validity check:
import { booleanValid } from "@turf/turf";
const polygon = {
type: "Polygon",
coordinates: [[
[-73.9910, 40.7350],
[-73.9810, 40.7350],
[-73.9810, 40.7420],
[-73.9910, 40.7420],
[-73.9910, 40.7350]
]]
};
if (!booleanValid(polygon)) {
throw new Error("Polygon geometry is invalid");
}
Python clients can use Shapely's is_valid predicate:
from shapely.geometry import shape
polygon = {
"type": "Polygon",
"coordinates": [[
[-73.9910, 40.7350],
[-73.9810, 40.7350],
[-73.9810, 40.7420],
[-73.9910, 40.7420],
[-73.9910, 40.7350],
]],
}
if not shape(polygon).is_valid:
raise ValueError("Polygon geometry is invalid")
Keep the original user geometry for audit and derive a normalized copy for querying. The normalized copy should close rings, enforce winding, reject self-intersections, and preserve coordinate precision consistently.

For a deeper integration reference, keep RealtyAPI's OpenAPI integration documentation beside the validation code.
Multipolygon is where many implementations lose inventory. If a search spans non-contiguous districts, islands, or separated service areas, use a MultiPolygon whose coordinates contain multiple polygon coordinate arrays. If the area needs an interior exclusion, such as a park inside a neighborhood, use an interior ring in the Polygon rather than subtracting records after the query. The geometry should express the intended area before it reaches the API.
Making Polygon Search Requests to RealtyAPI
Once the geometry is valid, transport it without renaming fields or changing nesting. RealtyAPI's polygon workflow uses the field name polygon. It isn't geo, geometry, or shape. A client that changes the name while preserving the coordinates still sends the wrong contract.
A REST request places the GeoJSON Polygon in the request body, alongside pagination controls:
POST /v1/properties/polygon
Content-Type: application/json
Authorization: Bearer YOUR_API_KEY
{
"polygon": {
"type": "Polygon",
"coordinates": [[
[-73.9910, 40.7350],
[-73.9810, 40.7350],
[-73.9810, 40.7420],
[-73.9910, 40.7420],
[-73.9910, 40.7350]
]]
},
"limit": 25,
"cursor": null
}
A GraphQL request carries the same object as a variable:
query PolygonSearch($polygon: GeoJSONPolygon!, $after: String, $first: Int) {
propertySearchByPolygon(polygon: $polygon, after: $after, first: $first) {
properties {
id
address
coordinates {
latitude
longitude
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
The response envelope matters. REST clients should read the property array and the next-page cursor from the documented response object. GraphQL clients should read properties and pageInfo.endCursor, then use hasNextPage to determine whether another request is needed.
| Aspect | REST | GraphQL |
|---|---|---|
| Geometry field | polygon in the JSON body |
polygon argument and variable |
| Pagination | Request and response cursor | Relay-style after and pageInfo |
| Returned fields | Endpoint response schema | Explicit selection set |
| Best fit | Simple services and direct integrations | Clients needing controlled field selection |
A Python client can remain intentionally small:
import requests
polygon = {
"type": "Polygon",
"coordinates": [[
[-73.9910, 40.7350],
[-73.9810, 40.7350],
[-73.9810, 40.7420],
[-73.9910, 40.7420],
[-73.9910, 40.7350],
]],
}
response = requests.post(
"https://api.realtyapi.io/v1/properties/polygon",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"polygon": polygon, "limit": 25, "cursor": None},
timeout=10,
)
response.raise_for_status()
payload = response.json()
properties = payload["properties"]
next_cursor = payload.get("nextPage")
The equivalent JavaScript request uses fetch:
const response = await fetch("https://api.realtyapi.io/v1/properties/polygon", {
method: "POST",
headers: {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json"
},
body: JSON.stringify({
polygon,
limit: 25,
cursor: null
})
});
if (!response.ok) {
throw new Error(`Polygon request failed: ${response.status}`);
}
const payload = await response.json();
const properties = payload.properties;
const nextCursor = payload.nextPage;
If you're comparing product workflows before implementing the backend, AppLighter's real estate app example is useful context for seeing how map-driven listing experiences fit into a broader application.
The endpoint itself is documented in RealtyAPI's search by polygon reference. Treat that schema as authoritative, especially for envelope names and cursor behavior.
Handling Pagination, Retries, and Reliability
A polygon query can be spatially correct and still fail as a product feature if page two changes the meaning of page one. Save the normalized polygon hash, the selected predicate, the sort order, and the cursor together. A retry must replay the same query state, not reconstruct it from the current map viewport.
REST commonly exposes a next-page token after the initial call. Send that token unchanged on the next request. GraphQL Relay-style pagination uses pageInfo, typically checking hasNextPage and passing endCursor as the next after value.
| Aspect | REST /v1/properties/polygon |
GraphQL polygonSearch |
|---|---|---|
| Continuation value | Response next-page cursor | pageInfo.endCursor |
| Completion check | Cursor is absent or exhausted | hasNextPage is false |
| Retry input | Same body plus saved cursor | Same variables plus saved after |
| Main failure risk | Restarting from the first page | Reusing a stale cursor after query changes |
Use bounded concurrency. For a workload that requests several overlapping market polygons, cap concurrent polygon calls at 3, add jitter between 500ms and 8s for retry delays, and resume from the last saved cursor instead of restarting. Those values are client policy choices, not universal server guarantees. RealtyAPI's rate-limit and reliability guidance is the place to align implementation behavior with the platform contract.
A compact Python retry wrapper might look like this:
import random
import time
import requests
def fetch_page(url, body, headers, attempts=5):
for attempt in range(attempts):
response = requests.post(url, json=body, headers=headers, timeout=10)
if response.status_code != 429 and response.status_code < 500:
response.raise_for_status()
return response.json()
if attempt == attempts - 1:
response.raise_for_status()
delay = min(8.0, 0.5 * (2 ** attempt)) + random.uniform(0, 0.5)
time.sleep(delay)
raise RuntimeError("Unreachable")
In JavaScript, pair a per-page timeout with AbortController so a stalled request doesn't consume a worker indefinitely:
async function fetchPage(url, body, apiKey, timeoutMs = 10000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} finally {
clearTimeout(timer);
}
}
Performance Tips for Polygon-Heavy Workloads
Treat polygon endpoints as their own performance class. Point-in-polygon retrieval and polygon-on-polygon intersection don't exercise the same geometry work, so a fast point lookup isn't evidence that a complex listing boundary will behave similarly.
A benchmark using Geofabrik OSM England landuse data and 10,000 random query shapes recorded materially different throughput by operation and engine. Geotools reached 13.64 point-intersects operations per second but 0.101 polygon-intersects operations per second. The same benchmark measured Lucene at 0.108 versus 0.092, MongoDB at 0.095 versus 0.028, and PostGIS at 0.091 versus 0.065 operations per second. These results come from the geospatial polygon index benchmark, and they're a warning to benchmark point and polygon intersections separately.
Three optimizations consistently make the request easier to execute:
- Prefilter by extent: Send the polygon's bounding box alongside the geometry when the API supports it. The box is a cheap candidate filter, while the exact predicate handles correctness.
- Simplify at high map detail: Above zoom level 13, apply Douglas-Peucker simplification to remove redundant vertices while preserving the boundary shape needed by the user.
- Cache normalized requests: Build a stable key from a normalized coordinate ring, selected predicate, market filters, and sort. Hashing sorted coordinates alone isn't safe if ring order or geometry structure carries meaning, so normalize the ring deliberately before hashing.
Don't simplify blindly. A reduced boundary can move a parcel across the inclusion line, especially around narrow corridors and irregular parcel edges. Store the submitted geometry and the simplified query geometry separately so support teams can explain why a result was included.
Spatial Accuracy and Edge Cases That Bite in Production
Intersects is a useful default for listing discovery, but it isn't automatically the correct business rule. A parcel that touches a boundary may intersect the polygon under one precision model and fall outside it under another. A listing coordinate may sit inside while the parcel footprint crosses the edge, creating a semantic mismatch between point and polygon data.
Three cases deserve explicit tests:
- Boundary contact: Rounding longitude and latitude can move a point across the edge. Repeated requests can appear inconsistent if different clients serialize coordinates at different precision.
- Sliver geometry: A very narrow polygon can lose its effective area during simplification or normalization and return no records, even though the user can see it on the map.
- Predicate mismatch: Intersects can return a partially overlapping parcel. Within should exclude it unless the parcel is fully contained. Neither predicate is “more accurate” without a defined product requirement.
PostGIS documentation separates bounding-box filtering from exact predicates such as ST_Intersects, ST_Contains, and ST_Within, which is the right mental model for RealtyAPI integrations too. First determine which records are candidates, then apply the documented semantic rule. Don't infer containment from an intersects response after the fact.
RealtyAPI property polygons use intersects as the default behavior for broad listing discovery, while stricter workflows can use an explicit containment flag. That distinction should travel through your request model and response mapping. A school-zone search, for example, may need a different inclusion rule from a neighborhood discovery map.
Before submission, run a validation pass that rejects self-intersecting rings, flags degenerate areas below your chosen floor, closes rings, and normalizes winding. Reliability research on point-in-polygon methods found that implementations that behave well on random points can fail on degenerate or nearly degenerate inputs, so adversarial geometry belongs in automated tests, not only in a bug report. The point-in-polygon reliability study supports that testing approach.
Testing and Visualizing Polygon Search Results
Treat the polygon as a QA artifact. A useful fixture set includes an obviously interior property, a boundary-touching parcel, an outside record, a narrow corridor, a self-intersecting ring, and a polygon with a hole. Each fixture should assert the documented predicate, expected page progression, and deterministic ordering.
The visual smoke test should render the submitted GeoJSON as its own layer. Add returned properties, excluded boundary cases, and rejected geometries as separate layers, then compare each property point or parcel centroid with the expected outcome. Keep the original user shape visible, provide a control to zoom to its extent, and allow basemap switching. The map should expose inverted winding, systematic edge exclusions, and results clustering on only one side of the boundary.
Test invalid payloads deliberately:
- Missing coordinates: The API should return a clear schema error.
- Unclosed rings: The client should reject the geometry before submission.
- Excessive vertices: The client should enforce an application limit and report why.
- Unsupported geometry types: The integration should fail clearly rather than converting the shape without explicit feedback.
Pagination tests should verify that retries don't duplicate records and that changing the cursor doesn't alter the query geometry. Log a stable polygon hash, response count, page identifier, and latency. Avoid storing sensitive location data when the hash and operational metadata are enough to diagnose the request.
The release checklist is short but essential: validate geometry, confirm the response envelope, persist cursors across retries, enforce a bounded retry policy, and visualize submitted versus returned geometry. A map review catches patterns, but deterministic assertions decide whether the feature is trustworthy.
RealtyAPI.io provides REST, GraphQL, and webhook access to public real estate listings and market data, including polygon-based property searches for irregular boundaries. Build your validated geometry and reliability tests against the API, then visit RealtyAPI.io to get an API key and connect polygon search to your application.