Add STR rules to your platform
This guide takes you from your first call to a production integration: look up the rules for each property, show them to your users, and keep them current as cities change their ordinances. Every code sample on this page is tested against the API.
Before you start
- An API key. Get a free sandbox key to build against 20 sample markets, including Denver, the city used in this guide. For every location, buy a plan on the API pricing page, or contact us for Enterprise. Keys start with
lf_live_. - Base URL:
https://lawfulstay.com/api/v1. Requests and responses are JSON in UTF-8. - Keep the key on your server. Read it from an environment variable (the samples use
LAWFULSTAY_API_KEY). Never put it in a browser page or mobile app. - Prefer generated clients or Postman? Import the OpenAPI 3.1 spec. The API reference lists every parameter and field.
How the data is organized
Every place we track is a jurisdiction with a stable id, such as us-colorado-denver. Rules come in layers: a city ordinance sits inside a county, a state and a country, and more than one layer can apply at once (a city license plus a state lodging tax, for example). Each record has a status:
| Status | Meaning |
|---|---|
Active | Short-term rentals allowed with registration. |
Restricted | Allowed, with limits such as a primary-residence rule, a night cap or zoning. |
Banned | Prohibited in most or all zones. |
Pending | New rules proposed but not yet adopted. |
None | No specific regulation on record. |
1. Make your first call
curl -H "X-API-Key: $LAWFULSTAY_API_KEY" \
"https://lawfulstay.com/api/v1/resolve?city=Denver&state=CO&country=US"
The response, shortened:
{
"ok": true,
"confidence": "medium",
"match_method": "admin-city",
"governing": {
"jurisdiction_id": "us-colorado-denver",
"level": "city",
"jurisdiction_label": "Denver, Colorado",
"status": "Restricted",
"status_label": "Restricted — rules apply",
"license_required": "Yes — requires both a general business license and a short-term rental license ...",
"rental_day_cap": "None for a primary residence (no night cap, but must be the host's actual home)",
"primary_residence_required": "Yes — STRs are restricted to the host's primary residence ...",
"tax_rate": "Denver Lodger's Tax of 10.75% on taxable lodging ...",
"last_checked": "2026-06-23",
"source_url": "https://www.denvergov.org/...",
"single_source_warning": false
},
"stack": [
{ "jurisdiction_id": "us-colorado-denver", "level": "city", ... },
{ "jurisdiction_id": "us-colorado-state", "level": "state", ... }
]
}
governing is the most specific match. stack holds every layer that applies, most specific first.
2. Look up a property
Most platforms do this when a listing is created or its address changes. The helper below wraps every call with sensible retries (more on that in step 6). rules_for returns what you need to store, or nothing when we don't track the place.
import os
import time
import requests
API = os.environ.get("LAWFULSTAY_API", "https://lawfulstay.com/api/v1")
HEADERS = {"X-API-Key": os.environ["LAWFULSTAY_API_KEY"]}
def call(path, params=None, tries=4):
"""GET an endpoint. Waits out rate limits and retries server errors."""
for attempt in range(tries):
r = requests.get(f"{API}/{path}", headers=HEADERS, params=params, timeout=30)
if r.status_code == 429 and "Retry-After" in r.headers:
time.sleep(int(r.headers["Retry-After"]))
continue
if r.status_code >= 500:
time.sleep(2 ** attempt)
continue
return r.status_code, r.json()
r.raise_for_status()
return r.status_code, r.json()
def rules_for(city, state, country):
"""Return the rules for a place, or None when we don't track it."""
status, body = call("resolve", {"city": city, "state": state, "country": country})
if status == 404:
return None
if status != 200:
raise RuntimeError(body.get("error"))
return {
"jurisdiction_id": body["governing"]["jurisdiction_id"],
"confidence": body["confidence"],
"layer_ids": [layer["jurisdiction_id"] for layer in body["stack"]],
"governing": body["governing"],
}
def sync_changes(cursor, apply_change, save_cursor):
"""Pull every change after `cursor`, oldest first. Returns the new cursor."""
while True:
status, page = call("changes", {"since": cursor, "limit": 500, "country": "US"})
if status != 200:
raise RuntimeError(page.get("error"))
for change in page["changes"]:
apply_change(change)
cursor = page["next_cursor"]
save_cursor(cursor)
if not page["has_more"]:
return cursorconst API = process.env.LAWFULSTAY_API || "https://lawfulstay.com/api/v1";
const HEADERS = { "X-API-Key": process.env.LAWFULSTAY_API_KEY };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function call(path, params = {}, tries = 4) {
const url = `${API}/${path}?${new URLSearchParams(params)}`;
for (let attempt = 0; attempt < tries; attempt++) {
const res = await fetch(url, { headers: HEADERS });
if (res.status === 429 && res.headers.has("Retry-After")) {
await sleep(Number(res.headers.get("Retry-After")) * 1000);
continue;
}
if (res.status >= 500) {
await sleep(2 ** attempt * 1000);
continue;
}
return { status: res.status, body: await res.json() };
}
throw new Error(`LawfulStay API unavailable: ${url}`);
}
export async function rulesFor(city, state, country) {
const { status, body } = await call("resolve", { city, state, country });
if (status === 404) return null;
if (status !== 200) throw new Error(body.error);
return {
jurisdictionId: body.governing.jurisdiction_id,
confidence: body.confidence,
layerIds: body.stack.map((layer) => layer.jurisdiction_id),
governing: body.governing,
};
}
export async function syncChanges(cursor, applyChange, saveCursor) {
for (;;) {
const { status, body: page } = await call("changes", { since: cursor, limit: 500, country: "US" });
if (status !== 200) throw new Error(page.error);
for (const change of page.changes) await applyChange(change);
cursor = page.next_cursor;
await saveCursor(cursor);
if (!page.has_more) return cursor;
}
}The same file holds the change-feed helper used in step 4.
What to store for each listing
jurisdiction_idof the governing record. You will use it to refresh the record later.- Every id in the stack (
layer_idsabove). A state-level change affects every listing in that state, so you need to know which listings sit under which layers. - The
confidence, so you can label results honestly.
Handle each kind of match
| Result | What it means | What to show |
|---|---|---|
high | Matched by id or boundary map. | The rules, as is. |
medium | Matched by place name. | The rules, as is. Most address lookups land here. |
parent-level | The city itself is not tracked. governing is the state or national record above it. | The rules, labelled as state or national rules, with a note that local rules may also apply. |
| 404 | Nothing matched. | "No rules on record for this location. Check with the local authority." Email us places you need covered. |
Tips
- Always send
stateandcountrywith the city. Many towns share a name. - Listings in the same city get the same answer. Cache results by
jurisdiction_idand refresh them from the change feed rather than calling/resolvefor every listing view. That keeps you well inside your quota. - To refresh one record, call
/resolve?id=us-colorado-denver. An id lookup returns just that record, so use it to update one layer, not to rebuild the whole stack.
3. Show the rules to your users
Fields are plain text written for people, so you can show them without rewording. The ones hosts care about most:
| Field | Shows |
|---|---|
status_label | The headline: permitted, restricted, prohibited, pending. |
license_required | Permits and registrations needed. |
primary_residence_required | Whether only a host's own home qualifies. |
rental_day_cap, min_stay | Night limits and minimum stays. |
tax_rate, fees | Lodging taxes and permit costs. |
source_url, last_checked | Where the rule comes from and when we last verified it. |
- Render values as text, not HTML. Escape them like any other outside data.
- Show more than one layer when the stack has them: the city rules first, then state taxes or national registration.
- Flag weak records. When
single_source_warningistrue, the status rests on one unofficial source. Show a "verify before relying on this" note. - Credit the data as Source: LawfulStay (lawfulstay.com), near the rules.
- Add a short disclaimer: rules change and this is not legal advice. Link to
source_urlso users can confirm.
4. Keep your data current with the change feed
/changes lists every change to our records, oldest first. Each change has an increasing id. Save the next_cursor from each page and send it back as since. Available on every plan.
def sync_changes(cursor, apply_change, save_cursor):
"""Pull every change after `cursor`, oldest first. Returns the new cursor."""
while True:
status, page = call("changes", {"since": cursor, "limit": 500, "country": "US"})
if status != 200:
raise RuntimeError(page.get("error"))
for change in page["changes"]:
apply_change(change)
cursor = page["next_cursor"]
save_cursor(cursor)
if not page["has_more"]:
return cursorexport async function syncChanges(cursor, applyChange, saveCursor) {
for (;;) {
const { status, body: page } = await call("changes", { since: cursor, limit: 500, country: "US" });
if (status !== 200) throw new Error(page.error);
for (const change of page.changes) await applyChange(change);
cursor = page.next_cursor;
await saveCursor(cursor);
if (!page.has_more) return cursor;
}
}Run it on a schedule: hourly is plenty for most platforms, daily is fine for many. Each call returns up to 500 changes, so a sync usually costs one or two calls.
What to do with each change
- Find your listings whose stored layer ids include
change["jurisdiction_id"]. - For
updatedoradded, refresh the record with/resolve?id=...and update your cache. - If
materialistrue, the rules themselves changed (status, caps, fees, tax, licensing and so on). This is the moment to alert affected hosts.changeslists each field with its old and new value. - For
removed, the record was usually merged into another. Look those listings up again by address. - Skip any change
idyou have already processed. Doing the same change twice should be harmless.
First sync: history or not
Starting from since=0 also returns the changes we logged before the feed went live (from June 2026). They have "source": "history" and a plain-English summary. Entries with change_type: "proposed" are rules under discussion, not yet law. If you only want changes from now on, make one call with since=0&limit=1 and start from its latest_cursor.
Filters such as country, state and material=true narrow the feed. Keep the same filters on every call, so your cursor always means the same thing.
5. Get changes pushed with webhooks
On Growth and Enterprise plans you can skip polling: register a URL and we POST new changes to it as soon as we publish them, up to 100 changes per request, in the same shape as the feed.
Register and test
curl -X POST -H "X-API-Key: $LAWFULSTAY_API_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://yourapp.example/lawfulstay", "countries": ["US"]}' \
https://lawfulstay.com/api/v1/webhooks
# Save the "secret" from the response. It is shown only once.
curl -X POST -H "X-API-Key: $LAWFULSTAY_API_KEY" -H "Content-Type: application/json" \
-d '{"id": 7}' https://lawfulstay.com/api/v1/webhooks/test
The test call sends a ping and tells you the status code your server answered with.
Receive them
Your endpoint must check the signature against the raw request body, answer quickly, and do the real work afterwards. Store the secret as LAWFULSTAY_WEBHOOK_SECRET.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["LAWFULSTAY_WEBHOOK_SECRET"]
seen_change_ids = set() # use your database in production
def is_from_lawfulstay(raw_body, header):
try:
parts = dict(p.split("=", 1) for p in header.split(","))
ts = int(parts["t"])
except (KeyError, ValueError):
return False
signed = str(ts).encode() + b"." + raw_body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return abs(time.time() - ts) < 300 and hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/lawfulstay")
def lawfulstay_webhook():
raw = request.get_data()
if not is_from_lawfulstay(raw, request.headers.get("X-LawfulStay-Signature", "")):
abort(401)
event = json.loads(raw)
for change in event["changes"]:
if change["id"] in seen_change_ids:
continue
seen_change_ids.add(change["id"])
print("queued change", change["id"], change["jurisdiction_label"])
return "", 204import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.LAWFULSTAY_WEBHOOK_SECRET;
const seenChangeIds = new Set(); // use your database in production
const app = express();
function isFromLawfulStay(rawBody, header = "") {
const parts = Object.fromEntries(header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i), p.slice(i + 1)];
}));
const ts = Number(parts.t);
if (!ts || !parts.v1 || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const expected = crypto.createHmac("sha256", SECRET)
.update(`${parts.t}.`).update(rawBody).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// express.raw keeps the exact bytes we signed. Do not use express.json() here.
app.post("/lawfulstay", express.raw({ type: "application/json" }), (req, res) => {
if (!isFromLawfulStay(req.body, req.get("X-LawfulStay-Signature"))) return res.sendStatus(401);
const event = JSON.parse(req.body);
for (const change of event.changes) {
if (seenChangeIds.has(change.id)) continue;
seenChangeIds.add(change.id);
console.log("queued change", change.id, change.jurisdiction_label);
}
res.sendStatus(204);
});
app.listen(process.env.PORT || 3000);- Verify every request. Reject a bad signature, or a timestamp older than 5 minutes, with 401.
- Answer within 10 seconds with any 2xx. Queue the changes and process them in the background. We don't follow redirects.
- Expect repeats. If your reply times out, we send the batch again. Skip change ids you already have.
- Pings arrive with
"type": "ping"and no changes. The samples answer them with 204.
Recovering from downtime
Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. If all of them fail, the webhook is disabled and the reason shows in GET /api/v1/webhooks. Nothing is lost: record the highest change id you processed, catch up from /changes?since= that id, then register the URL again.
A good pattern is to use both: webhooks for speed, and a daily change-feed sync from your saved cursor as a safety net.
6. Errors, limits and retries
| Code | Retry? | What to do |
|---|---|---|
400 | No | Fix the parameters. The error message says what is wrong. |
401 | No | Check the key, or replace a revoked one. |
403 | No | Account suspended, or a webhook your plan doesn't include. Contact us. |
404 | No | Not a failure: the place isn't tracked. |
429 with Retry-After | Yes | Per-minute limit. Wait that many seconds. |
429 without it | No | Monthly quota used up. It resets on the 1st (UTC), or switch your account to overage billing. |
5xx | Yes | Back off and retry a few times. |
The helper in step 2 follows these rules. Watch X-Quota-Remaining on responses and alert yourself before it reaches zero.
Estimating your calls
Say you manage 2,000 listings in 300 cities. With results cached by jurisdiction, setup takes about 300 /resolve calls. After that, an hourly feed sync is about 720 calls a month, plus one /resolve?id= call per changed record. The Growth plan's 10,000 calls covers that with room to spare.
Go-live checklist
- API key read from an environment variable on the server, never shipped to browsers or apps.
- Each listing stores its governing
jurisdiction_idand all stack ids. parent-levelresults and 404s have their own wording.- Rule text rendered as plain text, with the source link, last-checked date, credit line and disclaimer.
- Change-feed cursor saved after every page, and repeated change ids skipped.
- Webhook endpoint verifies signatures on the raw body and answers within 10 seconds.
- Retries on
429withRetry-Afterand on5xx, and no retries on other errors. - An alert when
X-Quota-Remainingruns low.
Typical setups
Property managers and channel managers
Look up each property at onboarding, show a compliance panel in the owner dashboard, and alert owners when a material change hits their market.
Listing marketplaces
Check the status before a listing goes live. Ask for a permit number where license_required says one is needed, and pause listings in Banned markets.
Lenders, insurers and investor tools
Call /resolve or /viability during underwriting or deal analysis, and keep a copy of the record and its last_checked date with the file.
Data teams
Mirror the records you need, then apply the change feed hourly to keep them current without re-pulling everything.
Stuck, or need a field or place we don't cover yet? Email support@lawfulstay.com.