API Integration Guide

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

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:

StatusMeaning
ActiveShort-term rentals allowed with registration.
RestrictedAllowed, with limits such as a primary-residence rule, a night cap or zoning.
BannedProhibited in most or all zones.
PendingNew rules proposed but not yet adopted.
NoneNo 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.

Python
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 cursor
JavaScript (Node 18+)
const 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

Handle each kind of match

ResultWhat it meansWhat to show
highMatched by id or boundary map.The rules, as is.
mediumMatched by place name.The rules, as is. Most address lookups land here.
parent-levelThe 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.
404Nothing matched."No rules on record for this location. Check with the local authority." Email us places you need covered.

Tips

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:

FieldShows
status_labelThe headline: permitted, restricted, prohibited, pending.
license_requiredPermits and registrations needed.
primary_residence_requiredWhether only a host's own home qualifies.
rental_day_cap, min_stayNight limits and minimum stays.
tax_rate, feesLodging taxes and permit costs.
source_url, last_checkedWhere the rule comes from and when we last verified it.

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.

Python
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 cursor
JavaScript (Node 18+)
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;
  }
}

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

  1. Find your listings whose stored layer ids include change["jurisdiction_id"].
  2. For updated or added, refresh the record with /resolve?id=... and update your cache.
  3. If material is true, the rules themselves changed (status, caps, fees, tax, licensing and so on). This is the moment to alert affected hosts. changes lists each field with its old and new value.
  4. For removed, the record was usually merged into another. Look those listings up again by address.
  5. Skip any change id you 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.

Python
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 "", 204
JavaScript (Node 18+)
import 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);

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

CodeRetry?What to do
400NoFix the parameters. The error message says what is wrong.
401NoCheck the key, or replace a revoked one.
403NoAccount suspended, or a webhook your plan doesn't include. Contact us.
404NoNot a failure: the place isn't tracked.
429 with Retry-AfterYesPer-minute limit. Wait that many seconds.
429 without itNoMonthly quota used up. It resets on the 1st (UTC), or switch your account to overage billing.
5xxYesBack 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

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.