Oscar Property API
Look up a property by address. Get our measurements of it, and a map of the property with those areas highlighted to show on your own website.
Guide
Get a key and make your first call. The rules here apply to every route.
Getting started
-
Create an API key. In Oscar, open Company → API keys and create a key. A company admin or head can do this. Copy the key: it is shown once. If your company has no admin in Oscar, ask Oscar support for a key.
-
Call the API from your server. Send the key in the
Authorizationheader. These examples read it from theOSCAR_API_KEYenvironment variable.curl -G https://api.dev.oscardata.com/api/v1/property \ -H "Authorization: Bearer $OSCAR_API_KEY" \ --data-urlencode "address=123 Example Street, Anytown, NY 10001" \ -d fields=lawn \ -d layers=lawn,property// Node.js 18 or later, as an ES module. const params = new URLSearchParams({ address: "123 Example Street, Anytown, NY 10001", fields: "lawn", layers: "lawn,property", }); const res = await fetch(`https://api.dev.oscardata.com/api/v1/property?${params}`, { headers: { Authorization: `Bearer ${process.env.OSCAR_API_KEY}` }, }); if (!res.ok) throw new Error(`Oscar API returned ${res.status}`); const result = await res.json(); console.log(result.metrics.lawn.area, result.embed.url);# pip install requests import os import requests res = requests.get( "https://api.dev.oscardata.com/api/v1/property", headers={"Authorization": f"Bearer {os.environ['OSCAR_API_KEY']}"}, params={ "address": "123 Example Street, Anytown, NY 10001", "fields": "lawn", "layers": "lawn,property", }, timeout=10, ) res.raise_for_status() result = res.json() print(result["metrics"]["lawn"]["area"], result["embed"]["url"])The response:
{ "property_id": "3f9c2a51-7d4e-4b8a-9c1f-2e6d8b0a4c17", "matched_address": "123 EXAMPLE ST, Anytown, NY 10001", "match": { "quality": "exact", "reasons": [] }, "metrics": { "lawn": { "area": 5200, "unit": "sqft", "percent_of_property": 38.5 } }, "embed": { "url": "https://dev.oscardata.com/embed/3f9c2a51-…?t=…", "expires_at": "2026-10-06T17:00:00Z" } } -
Show the map. Put
embed.urlfrom the response in an iframe on your page:<iframe src="EMBED_URL" width="640" height="420" title="Property map"></iframe>If you add a
sandboxattribute, includeallow-scripts allow-same-origin. Withoutallow-same-originthe map cannot load.
API keys
- Keep keys on your server. Never put a key in a web page or an app.
- A key can read the properties in your company's ZIP codes, nothing else.
- Revoke a key on the same page. It stops working at once, and so do the map links it made.
- A key works only on
https://api.dev.oscardata.com.
Rate limits
| Limit | Counts | Over the limit |
|---|---|---|
| 60 requests a minute | Each API key | 429 with a
Retry-After header: the number of seconds to wait. |
| 300 requests a minute | Each IP address | 429 without Retry-After. |
| 120 loads a minute | Each map link (embed.url) |
The map shows an error and sends an error message with
status: 429. |
Plan for 60 requests a minute per key. Do not depend on getting more.
Handling a 429
- If the response has
Retry-After, wait that many seconds, then send the same request again. - If it has no
Retry-After, wait 1 second, then 2, then 4, and so on, up to 30 seconds. Add a random part to each wait, so that your servers do not all try again at the same time. - Try again on
429and503only. Other errors do not go away when you try again: fix the request. - Stop after a few tries and show your user a message.
# --retry tries again on 429 and 503, and waits for Retry-After.
curl -G https://api.dev.oscardata.com/api/v1/property \
--retry 4 --retry-max-time 60 \
-H "Authorization: Bearer $OSCAR_API_KEY" \
-d property_id=PROPERTY_ID \
-d fields=lawn
async function getProperty(params, tries = 5) {
const url = `https://api.dev.oscardata.com/api/v1/property?${new URLSearchParams(params)}`;
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.OSCAR_API_KEY}` },
});
const retry = res.status === 429 || res.status === 503;
if (!retry || attempt === tries) {
if (!res.ok) throw new Error(`Oscar API returned ${res.status}`);
return res.json();
}
const retryAfter = Number(res.headers.get("Retry-After"));
const backoff = Math.min(30, 2 ** (attempt - 1)) * (0.5 + Math.random());
const seconds = retryAfter > 0 ? retryAfter : backoff;
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
}
import os
import random
import time
import requests
def get_property(params, tries=5):
for attempt in range(1, tries + 1):
res = requests.get(
"https://api.dev.oscardata.com/api/v1/property",
headers={"Authorization": f"Bearer {os.environ['OSCAR_API_KEY']}"},
params=params,
timeout=10,
)
if res.status_code not in (429, 503) or attempt == tries:
res.raise_for_status()
return res.json()
retry_after = res.headers.get("Retry-After", "")
if retry_after.isdigit():
seconds = int(retry_after)
else:
seconds = min(30, 2 ** (attempt - 1)) * random.uniform(0.5, 1.5)
time.sleep(seconds)
Staying under the limits
- Store
property_idafter the first lookup, and send it next time instead ofaddress. - Call the API when your user asks for a property, not on every page view.
- An
embed.urlworks for one hour. Reuse it in that hour instead of asking for a new one. - For a batch job, send at most one request a second per key.
Errors
An error returns an HTTP status and a JSON body with detail. Usually
detail is a sentence; for some errors it is an object with a
code you can check and a message:
{ "detail": "Invalid API key" }
{ "detail": { "code": "account_archived", "message": "Your company's Oscar account is archived." } }
These can come from any route. Each route lists its own errors too.
| Status | Meaning |
|---|---|
401 | The API key is missing, wrong or revoked. |
403 | Your company's Oscar account is
archived (account_archived). |
429 | Too many requests. Wait, then try again. See Rate limits. |
503 | A service we depend on is not available. Try again later. |
Routes
Each route: what you send, what comes back, and its own errors.
GET /property
One property: the measurements you ask for in fields, and a map link
for the layers you ask for in layers.
Request
This route takes a header and query parameters. It has no path parameters and no body.
Headers
| Name | Description |
|---|---|
Authorizationrequired |
Bearer and your API key: Authorization: Bearer osk_….
See API keys. |
Query parameters
Give exactly one of address or property_id, and at least one
of fields or layers.
| Name | Description |
|---|---|
addressstring |
A US street address, for example
123 Example Street, Anytown, NY 10001. |
property_idstring |
property_id from an earlier response. |
fieldsstring |
Comma-separated measurements to return. Available: lawn. See
Fields. |
layersstring |
Comma-separated layers for the map. Available: lawn,
property. See Layers. |
unitsstring |
Units for areas: sqft (default) or m2. |
Fields
fields chooses which measurements come back as JSON data in the response
body. Each name you send comes back as its own object in metrics:
{
...
"metrics": {
"FIELD_NAME": { /* the field's properties, listed below */ }
}
}
We add fields over time. These are available today.
lawn
If we have no lawn measurement for the property, the request fails with
422.
| Response property | Description |
|---|---|
areanumber |
The lawn area of the property, in unit: the property minus buildings, driveways, sidewalks and other paving. It includes grass under trees, so the true grass area can be smaller. Whole square feet, or square metres to one decimal place. |
unitstring |
The unit of area: sqft (square feet) or m2 (square metres). It is what you sent in units, or sqft if you did not send units. |
percent_of_propertynumber |
How much of the property is lawn, as a percent from 0 to 100, to one decimal place. For example, 38.5 means the lawn covers 38.5 % of the property's area. |
Layers
layers chooses what the map shows. Layers do not come back as data: they
are drawn on the map. When you send layers, the response has
embed, a link to a map of the property with those layers on it. See
Embedded map. To get a measurement as data, use
Fields.
We add layers over time. These are available today.
lawn
The lawn, as a filled lime-green area. If we have no lawn measurement for the
property, the request fails with 422.
property
The property boundary, as a magenta outline. It can include the strip of land out to the road.
Response
Body
A JSON object. property_id and matched_address always come
back. The other sections come back only when you ask for them, as each row says. For
address=…&fields=lawn&layers=lawn,property:
{
"property_id": "3f9c2a51-7d4e-4b8a-9c1f-2e6d8b0a4c17",
"matched_address": "123 EXAMPLE ST, Anytown, NY 10001",
"match": { "quality": "exact", "reasons": [] },
"metrics": {
"lawn": {
"area": 5200,
"unit": "sqft",
"percent_of_property": 38.5
}
},
"embed": {
"url": "https://dev.oscardata.com/embed/3f9c2a51-…?t=…",
"expires_at": "2026-10-06T17:00:00Z"
}
}
| Field | Description |
|---|---|
property_idstring (UUID) |
Our ID for the property. It never changes for the same property. Store it and send it as property_id next time: you get the same property back, without an address lookup. |
matched_addressstring |
The property's address in our records, for example 123 EXAMPLE ST, Anytown, NY 10001. It can be written differently from the address you sent (capitals, abbreviations, town name). Show it to your user so they can check we found the right property. |
matchobject |
How well the address you sent matches the property we found. Only when you send address, not property_id. |
match.qualitystring |
exact, approximate or low. With exact, every check passed. With approximate or low, ask your user to confirm the property. See Match quality. |
match.reasonsarray of strings |
The checks that failed, for example ["number_mismatch"]. Empty when match.quality is exact. See Match reasons for each value. |
metricsobject |
The measurements you asked for in fields, one object for each name. Only when you send fields. Each field in Fields lists the properties of its object. |
embedobject |
A link to a map of the property that shows the layers you asked for. Only when you send layers. See Embedded map. |
embed.urlstring (URL) |
The map page. Put it in the src of an <iframe> on your page. It needs no API key, so it is safe to use in a browser. |
embed.expires_atstring (date-time, UTC) |
When embed.url stops working: one hour after your request, for example 2026-10-06T17:00:00Z. After that the map shows "This map link has expired." Call the API again for a new link. |
Match quality
When you send address, we always return our best match in your ZIP codes
and grade it in match.quality:
| Quality | Meaning |
|---|---|
exact | Every check passed. |
approximate | We found a property, but some checks failed.
match.reasons lists them. It is often a neighbouring property: ask your
user to confirm before you use the numbers. |
low | Found by our own address search. Ask your user to confirm before you use the numbers. |
Show matched_address so your user can check it. Store
property_id and send it instead of address next time: it
always returns the same property, and it skips the address lookup.
Match reasons
| Reason | Meaning |
|---|---|
partial_match | Only part of the address matched. |
interpolated | The address is placed along the street, not on a building. |
no_house_number | The address has no house number. |
number_mismatch | The house number of the property we found is different. |
postal_mismatch | The ZIP code of the property we found is different. |
nearest_lot | The address is not inside a property, so we took the nearest one (within 50 m). |
text_search | Found by our own address search, with the same
house number. Always comes with low, and with
postal_mismatch when the ZIP differs. |
unit | The address is a unit in a building, such as an apartment or condo. A building can hold many units, so we may have found another unit in the same building. A unit has no lawn of its own. |
Embedded map
When you send layers, the response has embed.url. It shows one
property, read-only, for one hour. Call the API again for a new link. The map shows the
layers you asked for, with a legend.
Messages to your page
The map page sends messages to your page with window.postMessage:
window.addEventListener("message", (event) => {
if (event.data?.source !== "oscar") return;
// { source: "oscar", v: 1, type: "ready", propertyId }
// { source: "oscar", v: 1, type: "measurement", layer: "lawn", area, unit }
// { source: "oscar", v: 1, type: "error", status } (410: the link expired)
});
A map load ends in one ready or one error, so you can hide a
loading state on either. ready comes once the map has drawn. Browsers draw
an iframe only while it is on screen, so for a map below the fold, in a closed tab or
hidden, ready comes when your user first sees it.
measurement does not wait for that: it comes as soon as the map's data has
loaded, before ready. status is 410 when the link
expired, another HTTP status when the map data could not load, and 0 when
the link is broken or the map itself could not load.
Errors
Besides the errors every route can return:
| Status | Meaning |
|---|---|
400 | Bad parameters: an unknown field, layer
or unit, both or neither of address and property_id, or
no fields and no layers. |
404 | We found no property in your company's ZIP codes. |
422 | We found the property, but we have no
measurement for a field or layer you asked for. detail.code is
not_measured, detail.missing lists the names, and
detail.property_id is the property we found. |
For API tools such as Postman, the OpenAPI schema is at /api/v1/openapi.json.