Townline
Data API

API docs

The Townline data API is JSON over HTTPS. Each dataset is a feed with a cursor: your first pull returns everything, and every pull after that returns only what’s new or changed.

Get started

Once your account is active, sign in at townline.app/a/signin with the email address on your account. Open Settings and make a key under API keys. A key is shown once, when you make it, so copy it then. You can have up to 10 keys, and deleting one turns it off right away.

Send the key in the x-api-key header with every request to https://api.townline.app. Keep it on your servers: anyone with the key can pull your data.

Your first call: restaurants, then permits
curl -H "x-api-key: $TOWNLINE_KEY" \
  "https://api.townline.app/v1/restaurants?limit=1"

curl -H "x-api-key: $TOWNLINE_KEY" \
  "https://api.townline.app/v1/permit-events?limit=1"

Pull the restaurant feed

GET /v1/restaurants returns new restaurants, bars, cafés and bakeries in the counties your key covers, from their first filing, one record per place.

sinceSeq

Where to start: the nextSeq your last call returned. Leave it out, or send 0, to start from the beginning of the feed.

limit

How many records a call returns, 1 to 1,000. The default is 200.

regionId

Only these counties: one or more ids from GET /v1/regions, separated by commas. Leave it out for every county your key covers.

Each call returns a page:

{
  "data": [ { "venueId": "v_3f9c2a7e01b44d58c6a1", … } ],
  "nextSeq": "48733",
  "hasMore": true
}

Save nextSeq and call again with sinceSeq=nextSeq while hasMore is true. Don’t stop on a short or empty page: a page can hold fewer places than limit, even none, and still move the cursor. When hasMore is false, you’re caught up. Next time, start from the nextSeq you saved.

New records arrive every day, so pulling daily or weekly works. The feed holds back places that changed in the last 10 minutes, so it never moves past one that’s still being written; those come on your next pull.

These scripts pull everything new or changed since the last run. Set TOWNLINE_KEY to your key first.

Bash
#!/usr/bin/env bash
# Appends every place that's new or changed since the last run to
# places.jsonl, and keeps the cursor in cursor.txt. Needs curl and jq.
set -euo pipefail
seq=$(cat cursor.txt 2>/dev/null || echo 0)
while :; do
  page=$(curl -sf -H "x-api-key: $TOWNLINE_KEY" \
    "https://api.townline.app/v1/restaurants?sinceSeq=$seq&limit=1000")
  echo "$page" | jq -c '.data[]' >> places.jsonl
  seq=$(echo "$page" | jq -r '.nextSeq')
  echo "$seq" > cursor.txt
  [ "$(echo "$page" | jq -r '.hasMore')" = true ] || break
done
Python
import os
import requests

KEY = os.environ["TOWNLINE_KEY"]
URL = "https://api.townline.app/v1/restaurants"

places = {}  # venueId -> place. Load the copy you saved last time.
seq = "0"    # Load the nextSeq you saved last time.

while True:
    response = requests.get(
        URL,
        headers={"x-api-key": KEY},
        params={"sinceSeq": seq, "limit": 1000},
        timeout=60,
    )
    response.raise_for_status()
    page = response.json()
    for place in page["data"]:
        for old_id in place["replaces"]:
            places.pop(old_id, None)
        places[place["venueId"]] = place
    seq = page["nextSeq"]  # Save it for next time.
    if not page["hasMore"]:
        break

print(len(places), "places; the next pull starts at", seq)
JavaScript
// Node 18 or later.
const KEY = process.env.TOWNLINE_KEY;
const places = new Map(); // venueId -> place. Load the copy you saved last time.
let seq = "0";            // Load the nextSeq you saved last time.

for (;;) {
  const url = `https://api.townline.app/v1/restaurants?sinceSeq=${seq}&limit=1000`;
  const response = await fetch(url, { headers: { "x-api-key": KEY } });
  if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
  const page = await response.json();
  for (const place of page.data) {
    for (const oldId of place.replaces) places.delete(oldId);
    places.set(place.venueId, place);
  }
  seq = page.nextSeq; // Save it for next time.
  if (!page.hasMore) break;
}

console.log(places.size, "places; the next pull starts at", seq);

Keep your copy in sync

A place comes back whole whenever any of its records changes. Store places by venueId and overwrite the one you have. The same place always keeps the same id.

Sometimes two places we sent turn out to be one business. The place that stays lists the other ids in replaces; delete those from your copy.

seq is the place’s latest position in the feed and changedAt is when it last changed.

The restaurant record

An example record. The values are made up.

{
  "venueId": "v_3f9c2a7e01b44d58c6a1",
  "replaces": [],
  "seq": "48733",
  "changedAt": "2026-10-28T06:12:09.114Z",
  "regionId": "queens-ny",
  "name": "Golden Wok Kitchen",
  "operator": "GOLDEN WOK KITCHEN LLC",
  "cuisine": "Chinese",
  "licenseType": null,
  "address": "41-12 Main St",
  "city": "Flushing",
  "zip": "11354",
  "latitude": 40.7571,
  "longitude": -73.8297,
  "bbl": "4012340056",
  "stage": "health_permit_issued",
  "stageDate": "2026-08-09",
  "operating": false,
  "firstSignalDate": "2026-05-14",
  "filings": [
    {
      "kind": "health_permit",
      "publisher": "Health department",
      "businessName": "GOLDEN WOK KITCHEN",
      "operatorName": "GOLDEN WOK KITCHEN LLC",
      "category": "Chinese",
      "status": "Permit issued",
      "address": "41-12 MAIN ST, QUEENS, NY 11354",
      "filedDate": "2026-07-02",
      "issuedDate": "2026-08-09",
      "firstInspectionDate": null,
      "firstSeenAt": "2026-07-03T05:40:11.000Z",
      "presentAtLaunch": false,
      "removedAt": null
    }
  ],
  "permits": [
    {
      "id": "p_8d21c0b9e47f3a6c5b10",
      "issuedDate": "2026-05-14",
      "permitType": "Alteration",
      "description": "Install kitchen exhaust hood and duct.",
      "status": "Issued",
      "valuation": 85000,
      "contractor": "EXAMPLE MECHANICAL CORP",
      "address": "41-12 MAIN STREET",
      "matchMethod": "bbl"
    }
  ],
  "owners": [
    { "name": "RIVERA, PAT", "role": "Permit operator", "publisher": "Health department" }
  ],
  "previousAtAddress": [
    {
      "name": "MAIN ST NOODLE HOUSE",
      "category": "Chinese",
      "firstInspectionDate": "2019-03-11",
      "lastInspectionDate": "2025-12-02",
      "publisher": "Health department"
    }
  ]
}

The place

venueIdstring

Stable id of this business at this address. Upsert on it.

replacesarray of strings

Venue ids served earlier that turned out to be this business. Delete them.

seqstring

This venue's latest position in the feed.

changedAtstring

When this place last changed, as an ISO 8601 timestamp.

regionIdstring

The county the place is in, as an id from GET /v1/regions (kings-ny, bergen-nj, fairfield-ct, ...).

namestring or null

Trade name: a liquor record's DBA, else the health permit's name; the legal entity only when nothing else names the place (a company registration carries the company name only).

operatorstring or null

The company behind the place: the licensee on a liquor application or license, else the company a health permit names, else the company registered for it (in NYC, a company registered at the place's street address under a name sharing a word with the place's), else a health permit's name when that is a company (…LLC, …INC). null when no record names one.

cuisinestring or null

Cuisine as the health department records it at the first inspection; NYC only.

licenseTypestring or null

Liquor license class (Restaurant, Food & Beverage Business, Cafe Liquor, ...).

addressstring or null

Street address.

citystring or null

Town or city.

zipstring or null

ZIP code.

latitudenumber or null

Latitude (WGS 84). null when the address could not be placed on the map.

longitudenumber or null

Longitude (WGS 84). null when the address could not be placed on the map.

bblstring or null

NYC tax lot (10 digits), in the five boroughs.

stagestring

The furthest step any record shows. See Stages below.

stageDatestring or null

The date of the step in stage, YYYY-MM-DD. Which date it is depends on the stage (see Stages below).

operatingboolean or null

true once a record shows the place serving; false while a record shows it not yet open; null when no record says.

firstSignalDatestring or null

Earliest date any record or linked permit shows, YYYY-MM-DD: how early the business was visible.

filingsarray

The public records that name this business.

permitsarray

Food-service building permits Townline holds for the property (the build-out, kitchen hood, sign), from 18 months before a record to 9 months after. A permit on a property shared with other new businesses appears only on the one it can be attributed to.

phonestring or null (contacts add-on)

The business phone on the place's health permit (NYC only for now), as +1XXXXXXXXXX; null when no record lists one. Present only for keys with the contacts add-on.

ownersarray

The people behind the business, each once: those its own records name first (a license or permit held in a person's own name, a permit's operator, the owner on an inspection report), then those the state company register lists for its company (its chief executive, the person who receives its legal papers, its members and officers). People only, never a company, a registered agent or a law firm, and never a home address. Empty when no record or register names one.

previousAtAddressarray

What was at this street address before: food businesses the health department inspected there, last inspected before this business first showed (a company registered for it earlier doesn't count), other than this business. Newest first, at most two. NYC only for now; empty where none is listed, and where the address holds more than two other food businesses (a mall, food hall or office tower), since the address can't say which space this one took. Places drop off the health department's list some time after they close, so this reaches back a few years at most.

Stages

stage is the furthest step any record shows, and stageDate is the date of that step.

liquor_application_pending
liquor_application_conditionally_approved
liquor_application_closed — Left the pending list.
liquor_license_pending
liquor_license_provisional — Final inspection due.
liquor_license_active
liquor_license_inactive
food_license_pending — stageDate is the preliminary approval.
food_license_active
food_license_inactive
health_permit_pending — Health permit applied for, not yet issued; stageDate is the application date.
health_permit_issued — Permitted, not yet inspected.
inspected — First health inspection done.
building_permit_issued — A building permit names the place being fitted out or taking over the space, before any other record; stageDate is that permit's issue date.
business_registered — Only a company registration so far; stageDate is the registration date.
business_registration_inactive — The company was dissolved.

The records behind it

Each entry in filings is a public record that names the place.

filings[].kindstring

What the record is. See Record kinds below.

filings[].publisherstring

The kind of agency the record comes from: State liquor authority, Health department, Licensing agency, State company register or Building department.

filings[].businessNamestring or null

Trade name (DBA) as the record gives it.

filings[].operatorNamestring or null

The legal name the record gives for who runs the place: the licensee, the permit holder or the registered company.

filings[].categorystring or null

License class, establishment type, industry or cuisine, as the record gives it (Restaurant Liquor, Bakery, Delicatessen, Full-Service Restaurants, ...).

filings[].statusstring or null

The status as the record gives it.

filings[].addressstring or null

The address as the record gives it.

filings[].filedDatestring or null

Application received, license preliminarily approved, or company registered, YYYY-MM-DD.

filings[].issuedDatestring or null

License or permit issued, finally approved, in effect or changed, YYYY-MM-DD; for a building_permit, the first permit naming the place. null while an application is pending, and for license lists that carry no dates: a record that is not presentAtLaunch is then dated by firstSeenAt.

filings[].firstInspectionDatestring or null

First health inspection on record, YYYY-MM-DD: for a place this new, usually the pre-opening one (some agencies list only their latest inspection, or don't say whether it came before opening). null = not yet inspected.

filings[].firstSeenAtstring

When Townline first read this record.

filings[].presentAtLaunchboolean

Already on the agency's list when Townline started reading it, so firstSeenAt is not when it appeared.

filings[].removedAtstring or null

When the record left the agency's list. A liquor application leaves the pending list when it is approved or withdrawn; the agency does not say which.

Record kinds

liquor_application — A liquor license application.
liquor_license — A liquor license issued, transferred, moved or back in use.
health_permit — A health department food permit, from application through the first inspection.
food_license — A food license with no liquor in it: a bakery, food store, deli or restaurant license.
business_registration — A company registered for the place, weeks before any license.
building_permit — A building permit that names the place being fitted out or taking over the space, often weeks to months before any other record. Townline keeps the name it gives, never its text.

Permits

permits[].idstring

Stable id of this permit, the same one the permit feeds use.

permits[].issuedDatestring or null

The permit's date, YYYY-MM-DD: the date it was issued or, in some towns, the date it was applied for.

permits[].permitTypestring or null

The type of permit, as the building department gives it.

permits[].descriptionstring or null

The work description, with phone numbers and email addresses replaced by [phone removed] and [email removed].

permits[].statusstring or null

The permit status, as the building department gives it.

permits[].valuationnumber or null

The declared cost of the work in US dollars. null when the permit gives none.

permits[].contractorstring or null

The contractor named on the permit, with any phone number or email address cut out.

permits[].addressstring or null

The address as the permit gives it.

permits[].matchMethodstring

bbl (same NYC tax lot), address (same street address and town), or nearby (same street address within ~150 m, town names differing).

People

owners[].namestring

As the record or register prints it: "RIVERA, PAT", "Pat Rivera".

owners[].rolestring

What the record or register calls them: Licensee (a license held in a person's own name), Permit operator, Owner or Business owner (on an inspection report), Permit holder, Chief executive, Receives legal papers (the person a company names to receive its legal papers: for a small company usually an owner, sometimes its lawyer or accountant), or a company officer's title as the register gives it (Member, Manager, Officer, Director; Principal when it gives none).

owners[].publisherstring

Where the name comes from: the kind of agency whose record names them, or State company register.

What was there before

previousAtAddress[].namestring or null

Its name, as the health department lists it.

previousAtAddress[].categorystring or null

The health department's cuisine (Mexican, Coffee/Tea, ...).

previousAtAddress[].firstInspectionDatestring or null

Its earliest inspection on the health department's list, YYYY-MM-DD.

previousAtAddress[].lastInspectionDatestring or null

Its latest inspection, YYYY-MM-DD: about when it was last open.

previousAtAddress[].publisherstring

Health department.

Pull the permit feed

GET /v1/permit-events returns building permits in the counties your key covers, each one once, in the order we first found them. Home jobs come in it too: see Home jobs.

sinceSeq

Where to start: the nextSeq your last call returned. Leave it out, or send 0, to start from the beginning of the feed.

limit

How many records a call returns, 1 to 1,000. The default is 500.

regionId

Only these counties: one or more ids from GET /v1/regions, separated by commas. Leave it out for every county your key covers.

Pages work as in the restaurant feed: save nextSeq, call again with sinceSeq=nextSeq while hasMore is true, and don’t stop on a short or empty page. Starting from 0 walks every permit we hold, so a first pull takes many pages.

Each permit comes as it stands when you pull it. What changes on it later comes through the change feed, never as a new row here. The feed holds back permits we found in the last 10 minutes; those come on your next pull.

Permit ids start with p_ and match the ids in a restaurant’s permits. This script keeps a copy of your permits up to date from both feeds, the permit feed first.

Python
import os
import requests

KEY = os.environ["TOWNLINE_KEY"]
API = "https://api.townline.app/v1"

permits = {}  # id -> permit. Load the copy you saved last time.
cursors = {"permit-events": "0", "permit-changes": "0"}  # Load the ones you saved.

def pull(feed):
    while True:
        response = requests.get(
            f"{API}/{feed}",
            headers={"x-api-key": KEY},
            params={"sinceSeq": cursors[feed], "limit": 1000},
            timeout=60,
        )
        response.raise_for_status()
        page = response.json()
        yield from page["data"]
        cursors[feed] = page["nextSeq"]  # Save it for next time.
        if not page["hasMore"]:
            break

for permit in pull("permit-events"):
    permits[permit["id"]] = permit

for change in pull("permit-changes"):
    if change["event"] == "deleted":
        permits.pop(change["id"], None)
        continue
    permit = permits.get(change["id"])
    if permit is None:
        continue  # Not pulled yet: the permit feed will bring it.
    for field, value in change["changes"].items():
        if value["after"] is None:
            permit.pop(field, None)
        else:
            permit[field] = value["after"]

print(len(permits), "permits")

The permit record

An example record. The values are made up. A field with no value is left out: permit records never send null.

{
  "id": "p_4b7e19c2d05a8f3e6c21",
  "seq": "1048213",
  "regionId": "westchester-ny",
  "muni": "Lewisboro",
  "permitType": "Solar",
  "description": "Install 7.6 kW roof-mounted solar array, 19 panels.",
  "status": "Issued",
  "address": "100 Example Rd",
  "blockLot": "1-2-3",
  "issuedDate": "2026-10-27",
  "valuation": 32000,
  "contractor": "EXAMPLE SOLAR LLC",
  "contractorLicense": {
    "credential": "HOME IMPROVEMENT CONTRACTOR LICENSE",
    "licenseNo": "000000",
    "status": "Active",
    "active": true,
    "expirationDate": "2027-06-30"
  },
  "ownerName": "LEE, JORDAN",
  "ownerAddress": "100 EXAMPLE RD, SOUTH SALEM, NY 10590",
  "ownerAbsentee": false,
  "latitude": 41.2551,
  "longitude": -73.5903,
  "firstSeenAt": "2026-10-20T05:12:44.000Z",
  "statusChangedAt": "2026-10-28T05:40:02.000Z",
  "changedAt": "2026-10-28T05:40:02.000Z",
  "statusEvents": [
    {
      "id": "e_9a03f6b1c87d42e5a0b3",
      "oldStatus": "Under review",
      "newStatus": "Issued",
      "observedAt": "2026-10-28T05:40:02.000Z"
    }
  ],
  "trades": [{ "id": "solar", "name": "Solar", "confidence": 0.9 }]
}
idstring

Stable id of this permit: p_ and 20 characters. Store permits by it. The change feed, search and GET /v1/permits/{id} use the same id.

seqstring

This permit's position in the permit feed, as a string of digits. It never changes. Compare seq values as numbers, not as text.

regionIdstring

The county the permit is in, as an id from GET /v1/regions (westchester-ny, kings-ny, fairfield-ct, ...).

munistring

The city, town, village or borough the work is in.

permitTypestring

The type of permit, as the building department gives it (Building, Electrical, Plumbing, ...).

descriptionstring

The work description, as the building department gives it. Phone numbers and email addresses in it are replaced with [phone removed] and [email removed].

statusstring

The permit's current status, as the building department gives it. The wording varies from town to town.

addressstring

The street address of the work, written the way the town writes it. When the permit gives none, we fill it in from the description or from property records where we can.

blockLotstring

The property's tax block and lot, or parcel number, as the permit gives it. The format varies by area.

issuedDatestring

The permit's date, YYYY-MM-DD. Depending on the town, it's the date the permit was issued or the date it was applied for.

valuationnumber

The declared cost of the work in US dollars, as the permit gives it.

contractorstring

The contractor named on the permit, with any phone number or email address cut out.

applicantNamestring

Who applied for the permit: often the contractor, but not always. Any phone number or email address is cut out.

ownerNamestring

The property owner as the permit names them or, when it names none, the owner of record from property tax records. Any phone number or email address is cut out.

ownerAddressstring

The owner's mailing address.

ownerAbsenteeboolean

true when the owner's mailing address is a different street address from the property's, false when it's the same. Left out when either one is missing.

contractorLicenseobject

The contractor license that best matches the contractor's name, from license lists in the same state: an active license first, then the latest expiry. See Licenses below.

applicantLicenseobject

The same for the applicant's name.

latitudenumber

Latitude of the work site (WGS 84): from the permit when it gives one, otherwise from the address or parcel.

longitudenumber

Longitude of the work site (WGS 84).

firstSeenAtstring

When we first found this permit, as an ISO 8601 timestamp.

statusChangedAtstring

When we last saw the status change, as an ISO 8601 timestamp. Left out until we see one.

changedAtstring

When a field the change feed reports last changed on this permit, as an ISO 8601 timestamp. Left out if none has. It can be earlier than the change feed, which starts on October 28, 2026.

statusEventsarray

Each status change we've seen on this permit, oldest first; empty if none. See Status history below.

tradesarray

Our trade tags for the work, from the permit's type and description: up to 5, most confident first. See Trades below.

Licenses

contractorLicense and applicantLicense have the same fields. They’re matched by name.

contractorLicense.credentialstring

What the license is, as the license list names it (a home improvement or electrical contractor license, for example).

contractorLicense.licenseNostring

The license number.

contractorLicense.statusstring

The license's status as the license list gives it (Active, Expired, ...).

contractorLicense.activeboolean

true when the license was active in our latest copy of the license list.

contractorLicense.expirationDatestring

When the license expires, YYYY-MM-DD.

Status history

statusEvents[].idstring

Stable id of this status change: e_ and 20 characters.

statusEvents[].oldStatusstring

The status before the change. Left out when it was blank.

statusEvents[].newStatusstring

The status after the change. Left out when it became blank.

statusEvents[].observedAtstring

When we saw the change, as an ISO 8601 timestamp. This can be a few days after the town made it.

Trades

GET /v1/trades lists every trade id and name. It needs no key.

trades[].idstring

Trade id from GET /v1/trades: solar, roofing-siding-gutters, hvac-heating-cooling, electrical, plumbing-piping, pool-installation-service, ...

trades[].namestring

Trade name for display: Solar, Roofing / siding / gutters, ...

trades[].confidencenumber

0.9 when the permit type or its fee breakdown names the trade, 0.75 when only the description does. 0.5 marks the general-contractor tag, given when nothing else matched.

Permit changes

GET /v1/permit-changes sends what changed on a permit after the permit feed sent it: each changed field, before and after, in the order we saw the changes. It takes the same parameters as the permit feed but has its own cursor, so save its nextSeq separately. It holds back changes from the last 30 minutes, and it starts on October 28, 2026.

{
  "id": "p_4b7e19c2d05a8f3e6c21",
  "seq": "88412",
  "regionId": "westchester-ny",
  "event": "changed",
  "observedAt": "2026-10-28T05:40:02.000Z",
  "changes": {
    "status": { "before": "Under review", "after": "Issued" },
    "issuedDate": { "before": null, "after": "2026-10-27" }
  }
}
idstring

The permit's id (p_...), the same one the permit feed gives it.

seqstring

This change's position in the change feed. It's this feed's own cursor, not the permit's seq.

regionIdstring

The county the permit is in.

eventstring

changed, or deleted when the permit was taken down. A deleted event has no changes.

observedAtstring

When we recorded the change, as an ISO 8601 timestamp.

changesobject

Each changed field under its name on the permit record, as {"before": ..., "after": ...}. null means the field was empty before, or was cleared.

These fields can change: muni, permitType, description, status, address, blockLot, issuedDate, valuation, contractor, applicantName, ownerName, ownerAddress, latitude, longitude. Their values are cleaned the same way as on the permit record. Other fields, such as statusEvents and trades, don’t come as changes: read the permit again with GET /v1/permits/{id} to refresh them.

Right after we find a permit, we fill in what it lacks, such as its coordinates or its owner. Those fill-ins come as changes with before set to null, so some repeat values your copy already has. A change can name a permit you haven’t pulled yet; the permit feed will bring it.

Home jobs

Solar, roofing, HVAC, electrical, plumbing and pool jobs on houses are building permits. They come in the permit feed with every other permit, with the same key.

To pick out a kind of work, filter on trades[].id. To check that a permit is on a house, read it with GET /v1/permits/{id}: propertyContext gives the property’s use class, year built, size and value. Search does both in one call, with tradeId and propertyType=single-family, as in the example above.

Regions

GET /v1/regions lists the county ids, such as kings-ny, bergen-nj and fairfield-ct. It needs no key. Every record carries its county in regionId.

A key covers every county or a list of them. Asking for a county outside your key returns 403.

Errors

400

A parameter isn't valid, such as a limit over 1,000.

401

No key, a key we don't know, or a deleted key.

403

The key doesn't include this feed or county, or your account is off.

404

GET /v1/permits/{id} with an id we don't have.

5xx

Something failed on our side. Wait a minute and try the same call again.

Every error comes with a message saying what went wrong.

Help

Questions, or need another county or dataset? Email [email protected]. A real person reads it.