Zors AI - Franchise Territory Management Platform Logo
Zors
v1

Territory REST API

Read your organisation's franchise territories programmatically — names, status, type, centre points, the census geographies they are built from, and boundary polygons as GeoJSON. Authenticate with an API key you create in Zors; the key determines which organisation's data you see.

Quickstart

Three steps from nothing to a working request.

1

Create an API key

In the Zors app, open the account menu → Workspace Settings → API Keys, under the Management heading. Give the key a name and create it. You must be an organisation admin.

The key is shown once. Copy it immediately — it cannot be retrieved later, only revoked and replaced.

2

Store it as an environment variable

Keys are long-lived secrets. Depending on the permissions you give one, a key can read your territories and change your records. Keep them server-side, never in client-side code or a public repository.

3

Make your first request

export ZORS_API_KEY="zors_live_your_key_here"

curl -H "Authorization: Bearer $ZORS_API_KEY" \
  "https://public-api.zors.ai/v1/territories"

Authentication

Every request needs an API key. Two header forms are accepted — use whichever suits your client. If both are present, the Authorization header wins.

Authorization: Bearer zors_live_your_key_here

# or

X-API-Key: zors_live_your_key_here
About keys
  • Keys start with zors_live_ and are 42 characters long.
  • A key is tied to one organisation. That organisation is resolved from the key alone — there is no organisation parameter, and no key can read another tenant's data.
  • Keys are stored hashed. Zors cannot show you a key again after creation, and cannot recover a lost one.
  • There is no rotation operation for API keys — webhook signing secrets can be rotated, but keys cannot. To rotate a key, create a new one, move your integration over, then revoke the old one.
  • Revoking takes effect immediately; anything still using that key starts receiving 401 responses.

Endpoints

All endpoints are relative to:

https://public-api.zors.ai

List territories

Returns every territory belonging to the organisation the API key was issued for.

GET
/v1/territories
Request
curl -H "Authorization: Bearer $ZORS_API_KEY" \
  "https://public-api.zors.ai/v1/territories"

Response
FieldTypeNullableDescription
territoriesobject[]never nullArray of territory summaries. See the fields below.
countnumbernever nullNumber of territories in this response.
Each item in territories
FieldTypeNullableDescription
idstringnever nullUnique territory identifier. Use this as the {id} path segment on the detail endpoint.
namestring
nullable
Territory name as entered in Zors.
statusstring
nullable
One of the status values listed below. Unrecognised values are returned unchanged rather than mapped, so treat this as an open set.
typestring
nullable
One of the type values listed below. Like status, unmapped values pass through unchanged.
centerPointnumber[]
nullable
Centroid as [longitude, latitude] — GeoJSON axis order, longitude first. Null if the territory has no stored centroid.
lastBoundaryUpdatestring
nullable
ISO-8601 timestamp of the last boundary edit. Null if the boundary has never been edited since the territory was created.
Example response
{
  "territories": [
    {
      "id": "aBc123XyZ",
      "name": "North Dallas",
      "status": "Available",
      "type": "Brick and Mortar",
      "centerPoint": [
        -96.8089,
        32.9483
      ],
      "lastBoundaryUpdate": "2026-07-14T16:22:41.000Z"
    }
  ],
  "count": 1
}

Retrieve a territory

Returns a single territory including its boundary polygon and the census geographies it is built from.

GET
/v1/territories/{id}
Path parameters
FieldTypeRequiredDescription
idstringYesThe territory id, as returned by the list endpoint.
Request
curl -H "Authorization: Bearer $ZORS_API_KEY" \
  "https://public-api.zors.ai/v1/territories/aBc123XyZ"

Response
FieldTypeNullableDescription
territoryobjectnever nullThe territory object. See the fields below.
The territory object
FieldTypeNullableDescription
idstringnever nullUnique territory identifier. Use this as the {id} path segment on the detail endpoint.
namestring
nullable
Territory name as entered in Zors.
statusstring
nullable
One of the status values listed below. Unrecognised values are returned unchanged rather than mapped, so treat this as an open set.
typestring
nullable
One of the type values listed below. Like status, unmapped values pass through unchanged.
centerPointnumber[]
nullable
Centroid as [longitude, latitude] — GeoJSON axis order, longitude first. Null if the territory has no stored centroid.
lastBoundaryUpdatestring
nullable
ISO-8601 timestamp of the last boundary edit. Null if the boundary has never been edited since the territory was created.
descriptionstring
nullable
Free-text description entered in Zors.
centerPointGeoJSONobject
nullable
The centroid as a GeoJSON Point: {"type":"Point","coordinates":[lng,lat]}. Null whenever centerPoint is null.
boundaryobject
nullable
The territory outline as a GeoJSON Polygon or MultiPolygon. Null if the territory has no stored boundary yet.
tractsarraynever nullCensus tracts making up the territory. Always an array — empty when the territory was not built from tracts. Element shape varies; treat elements as opaque.
zipCodesarraynever nullZIP codes making up the territory. Always an array. Elements are objects for ZIP-built territories and plain strings for tract-built ones, so check the type before reading.
statestring
nullable
Two-letter state code, e.g. "TX".
stateFipsstring
nullable
Census state FIPS code.
countyFipsstring
nullable
Census county FIPS code.
Example response
{
  "territory": {
    "id": "aBc123XyZ",
    "name": "North Dallas",
    "status": "Available",
    "type": "Brick and Mortar",
    "centerPoint": [
      -96.8089,
      32.9483
    ],
    "lastBoundaryUpdate": "2026-07-14T16:22:41.000Z",
    "description": "Territory covering north Dallas suburbs.",
    "centerPointGeoJSON": {
      "type": "Point",
      "coordinates": [
        -96.8089,
        32.9483
      ]
    },
    "boundary": {
      "type": "Polygon",
      "coordinates": [
        [
          [
            -96.8712,
            32.9104
          ],
          [
            -96.7433,
            32.9104
          ],
          [
            -96.7433,
            32.9861
          ],
          [
            -96.8712,
            32.9861
          ],
          [
            -96.8712,
            32.9104
          ]
        ]
      ]
    },
    "tracts": [],
    "zipCodes": [
      "75080",
      "75081",
      "75082"
    ],
    "state": "TX",
    "stateFips": "48",
    "countyFips": "113"
  }
}

Update a territory

Changes a territory's details. Only the fields you send are touched.

PATCH
/v1/territories/{territoryId}
territories:write

Needs the territories:write permission. Add it to a key in Zors under Workspace settings, API Keys. A key without it gets a 403 naming what is missing.

Path parameters
FieldTypeRequiredDescription
territoryIdstringYesThe territory's id.
Request body
FieldTypeDescription
namestringUp to 200 characters. Cannot be blank.
notesstring|nullUp to 2000 characters.
statusstringOne of the documented status values.
operatorContactIdstring|nullContact operating this territory.
operatorCompanyIdstring|nullCompany operating this territory.
salesRepIdstring|nullContact acting as sales rep.
isExclusivebooleanWhether this is an exclusive territory.
isNotInCompliancebooleanWhether the operator is out of compliance.
outletTypestring|nullFranchised or Company-Owned.
franchiseTypestring|nullBrick and Mortar, Mobile, Remote or Other.
colorstring|nullHex colour for this territory on the map.
franchiseeInfoobject|nullAgreement and opening dates, storefront address, additional locations.
public*string|nullThe public display fields, e.g. publicDisplayName, publicPhone, publicBookingUrl.
Request
curl -X PATCH "https://public-api.zors.ai/v1/territories/$TERRITORY_ID"   -H "Authorization: Bearer $ZORS_API_KEY"   -H "Content-Type: application/json"   -d '{"name": "North Bath", "notes": "Agreement signed 2026-09-01"}'

Response
FieldTypeNullableDescription
territoryobjectnever nullThe fields you can change, as they now stand.
changedbooleannever nullFalse when the request matched what was already stored.
Example response
{
  "territory": {
    "id": "5c5b88ff-85a0-5bf7-aef9-c9f821a78753",
    "name": "North Bath",
    "status": "Sold (Open)",
    "notes": "Agreement signed 2026-09-01",
    "operatorContactId": "k2Jf0s9dQ1",
    "isExclusive": false,
    "isNotInCompliance": false
  },
  "changed": true
}

Create or update a contact

Creates a contact, or updates the one it matches.

POST
/v1/contacts
contacts:write

Needs the contacts:write permission. Add it to a key in Zors under Workspace settings, API Keys. A key without it gets a 403 naming what is missing.

Request body
FieldTypeDescription
firstNamestringCannot be blank if sent.
lastNamestring
emailstringStored lowercased, and used for matching.
emailsobject[]{address, type} where type is primary, work, personal or other. Up to four.
phoneNumberstring
jobTitlestring
companyIdstringA company in Zors this contact belongs to.
addressobject{street, city, state, postalCode, country}.
tagsstring[]
externalIdstringThis contact's id in your system.
externalSourcestringWhich system that id came from, e.g. "hubspot".
Request
curl -X POST "https://public-api.zors.ai/v1/contacts"   -H "Authorization: Bearer $ZORS_API_KEY"   -H "Content-Type: application/json"   -d '{
    "firstName": "Ada",
    "lastName": "Lovelace",
    "email": "ada@example.com",
    "externalId": "hs-1",
    "externalSource": "hubspot"
  }'

Response
FieldTypeNullableDescription
contactobjectnever nullThe stored contact.
actionstringnever null`created` or `updated`.
matchedBystring
nullable
What it matched on, or null for a new contact.
Example response
{
  "contact": {
    "id": "SYyQ8qDGbpDwp4e4UdtA",
    "firstName": "Ada",
    "lastName": "Lovelace",
    "email": "ada@example.com",
    "emails": [
      {
        "address": "ada@example.com",
        "type": "primary"
      }
    ],
    "address": {
      "street": "1 High St",
      "city": "Bath",
      "state": null,
      "postalCode": "BA1 1AA",
      "country": "UK"
    },
    "externalId": "hs-1",
    "externalSource": "hubspot",
    "detailUrl": "https://public-api.zors.ai/v1/contacts/SYyQ8qDGbpDwp4e4UdtA"
  },
  "action": "created",
  "matchedBy": null
}

Get a contact

Returns one contact.

GET
/v1/contacts/{contactId}
contacts:read

Needs the contacts:read permission. Add it to a key in Zors under Workspace settings, API Keys. A key without it gets a 403 naming what is missing.

Path parameters
FieldTypeRequiredDescription
contactIdstringYesThe contact's id.
Request

Response
FieldTypeNullableDescription
contactobjectnever nullThe contact.
Example response
{
  "contact": {
    "id": "SYyQ8qDGbpDwp4e4UdtA",
    "firstName": "Ada",
    "lastName": "Lovelace",
    "email": "ada@example.com"
  }
}

Update a contact

Changes a contact by id. Only the fields you send are touched.

PATCH
/v1/contacts/{contactId}
contacts:write

Needs the contacts:write permission. Add it to a key in Zors under Workspace settings, API Keys. A key without it gets a 403 naming what is missing.

Path parameters
FieldTypeRequiredDescription
contactIdstringYesThe contact's id.
Request

Response
FieldTypeNullableDescription
contactobjectnever nullThe stored contact.
actionstringnever nullAlways `updated`.
Example response
{
  "contact": {
    "id": "SYyQ8qDGbpDwp4e4UdtA",
    "jobTitle": "Analyst"
  },
  "action": "updated"
}

Create or update a company

Creates a company, or updates the one it matches.

POST
/v1/companies
companies:write

Needs the companies:write permission. Add it to a key in Zors under Workspace settings, API Keys. A key without it gets a 403 naming what is missing.

Request body
FieldTypeDescription
namestringRequired when creating. Cannot be blank.
websitestringFull URL. Used to derive the domain.
domainstringSend this to control matching yourself.
industrystring
phoneNumberstring
emailstring
numberOfEmployeesnumberA numeric string is accepted.
annualRevenuenumber
addressobject{street, city, state, postalCode, country}.
externalIdstring
externalSourcestring
Request
curl -X POST "https://public-api.zors.ai/v1/companies"   -H "Authorization: Bearer $ZORS_API_KEY"   -H "Content-Type: application/json"   -d '{"name": "Acme Ltd", "website": "https://www.acme.com", "numberOfEmployees": 42}'

Response
FieldTypeNullableDescription
companyobjectnever nullThe stored company.
actionstringnever null`created` or `updated`.
matchedBystring
nullable
What it matched on, or null for a new company.
Example response
{
  "company": {
    "id": "VkNfl1nOJEgguUDQh7Vf",
    "name": "Acme Ltd",
    "domain": "acme.com",
    "website": "https://www.acme.com/about",
    "numberOfEmployees": 42
  },
  "action": "created",
  "matchedBy": null
}

Get a company

Returns one company.

GET
/v1/companies/{companyId}
companies:read

Needs the companies:read permission. Add it to a key in Zors under Workspace settings, API Keys. A key without it gets a 403 naming what is missing.

Path parameters
FieldTypeRequiredDescription
companyIdstringYesThe company's id.
Request

Response
FieldTypeNullableDescription
companyobjectnever nullThe company.
Example response
{
  "company": {
    "id": "VkNfl1nOJEgguUDQh7Vf",
    "name": "Acme Ltd",
    "domain": "acme.com"
  }
}

Update a company

Changes a company by id. Only the fields you send are touched.

PATCH
/v1/companies/{companyId}
companies:write

Needs the companies:write permission. Add it to a key in Zors under Workspace settings, API Keys. A key without it gets a 403 naming what is missing.

Path parameters
FieldTypeRequiredDescription
companyIdstringYesThe company's id.
Request

Response
FieldTypeNullableDescription
companyobjectnever nullThe stored company.
actionstringnever nullAlways `updated`.
Example response
{
  "company": {
    "id": "VkNfl1nOJEgguUDQh7Vf",
    "industry": "Retail"
  },
  "action": "updated"
}

Permissions

Each key carries the permissions you give it when you create it, and you can change them afterwards without reissuing the key. Reading territories needs no permission: every key can do that, which is how keys behaved before permissions existed, so nothing you already use has changed.

FieldTypeDescription
territories:writeUpdate a territory's details.Cannot change ZIP codes, boundary or anything else derived from geometry.
contacts:readRead a contact.Contacts contain personal data, so this is never implied.
contacts:writeCreate and update contacts.Implies contacts:read.
companies:readRead a company.
companies:writeCreate and update companies.Implies companies:read.

Webhooks

Rather than polling, you can have Zors tell you when something changes. Add an endpoint in Zors under Workspace settings, API Keys, choose the events you want, and copy the signing secret it shows you once.

Events
FieldTypeDescription
territory.createdA territory was created.
territory.updatedA territory changed. Carries its ZIP codes and map image URLs.
contact.createdA contact was created.
contact.updatedA contact changed.
company.createdA company was created.
company.updatedA company changed.
What we send

A POST with this envelope. The resource sits under data, keyed by its name, so related objects can be added later without breaking you.

FieldTypeNullableDescription
idstringnever nullUnique per event, e.g. "evt_2f8c…". Use it to ignore an event you have already handled.
typestringnever nullOne of the event names above.
apiVersionstringnever nullVersion of the envelope and payload shapes. Currently "v1".
createdAtstringnever nullWhen the event was raised, ISO-8601.
occurredAtstringnever nullWhen the change itself happened, ISO-8601.
resourceVersionnumbernever nullIncreases with each change to a record. Use it to discard an event that arrives after a newer one.
organizationIdstringnever nullThe organisation the change belongs to.
sourcestringnever nullWhat caused it: user, api, zapier, zoho or system.
dataobjectnever nullThe resource, keyed by name: territory, contact or company.
{
  "id": "evt_2f8c91a4b7d3e5f60a1b2c3d",
  "type": "territory.updated",
  "apiVersion": "v1",
  "createdAt": "2026-09-26T10:04:11.221Z",
  "occurredAt": "2026-09-26T10:04:11.180Z",
  "resourceVersion": 1758880751180,
  "organizationId": "X5wTALYzJhZDxBHUJDq1",
  "source": "user",
  "data": {
    "territory": {
      "id": "5c5b88ff-85a0-5bf7-aef9-c9f821a78753",
      "name": "Destin",
      "status": "Sold (Open)",
      "territoryType": "unit",
      "isExclusive": false,
      "isNotInCompliance": false,
      "notes": "Agreement signed 2026-09-01",
      "state": "12",
      "centerPoint": [
        -86.4958,
        30.3935
      ],
      "zipCodes": [
        "32541",
        "32550",
        "32459"
      ],
      "zipCodeCount": 3,
      "zipCodesTruncated": false,
      "tractCount": 0,
      "operatorContactId": "k2Jf0s9dQ1",
      "operatorCompanyId": null,
      "salesRepId": null,
      "mapImages": [
        {
          "url": "https://firebasestorage.googleapis.com/v0/b/zors-ai.appspot.com/o/…jpg?alt=media&token=…",
          "createdAt": "2026-09-21T19:02:11.176Z"
        }
      ],
      "mapImageUrl": "https://firebasestorage.googleapis.com/v0/b/zors-ai.appspot.com/o/…jpg?alt=media&token=…",
      "createdAt": "2026-09-21T19:02:11.176Z",
      "lastBoundaryUpdate": "2026-09-21T19:02:11.176Z",
      "detailUrl": "https://public-api.zors.ai/v1/territories/5c5b88ff-85a0-5bf7-aef9-c9f821a78753"
    }
  }
}
Headers
FieldTypeDescription
Zors-SignatureTimestamp and signature, e.g. "t=1758880751,v1=<hex>". Verify this before trusting the body.
Zors-Event-IdSame as `id` in the body. Stable across retries, so it is the key to deduplicate on.
Zors-Event-TypeSame as `type` in the body, so you can route without parsing.
Zors-Delivery-IdIdentifies this delivery record, useful when asking us about one.
Zors-Delivery-AttemptWhich attempt this is, starting at 1.
Verifying a request
# There is nothing to run here — verification happens in your own code.
# The value you need is in the header:
#
#   Zors-Signature: t=1758880751,v1=5257a869e7ecebeda32affa62cdca3fa793c515b0d0b41e7b9a0d1d1e9ab1b3f
#
# Take the timestamp (t) and the signature (v1), compute
# HMAC-SHA256("<t>.<raw request body>") with your signing secret, and compare.
Delivery and retries
  • Up to 6 attempts: immediately, then after 1 minute, then 5 minutes, then 30 minutes, then 2 hours, then 6 hours.
  • Retried on a timeout, HTTP 408, 425 or 429, any 5xx, a connection or DNS failure. Stopped on any other 4xx — the request itself is wrong, so repeating it will not help.
  • We wait 10 seconds for a response. Answer as soon as you have stored the event and do your work afterwards.
  • Delivery is at least once. The same event can arrive twice, and a retry reuses its id, so treat Zors-Event-Id as the key you deduplicate on. Order is not guaranteed either; use resourceVersion to discard an event that arrives after a newer one.
  • After 10 consecutive failures an endpoint is switched off, and Workspace settings says why. Fix it and switch it back on.
  • Each endpoint accepts 60 events per 5 minutes. Beyond that they are recorded and not sent.
  • We keep 30 days of delivery history, visible in Workspace settings with the response we got back.
Requirements for your endpoint
  • https only, on a hostname that resolves to a public address. We check again immediately before each send, so repointing a hostname at a private address stops delivery.
  • Answer with any 2xx as soon as you have stored the event. Do your work afterwards — we give up waiting after 10 seconds and treat that as a failure worth retrying.
  • Redirects are not followed; a 3xx counts as a failure.
When an event is deliberately not sent

Some changes raise no event on purpose. Worth reading before reporting a missing webhook.

FieldTypeDescription
The change made no visible differenceIf the event would carry exactly what the last one did, it is not sent. This is what stops two systems trading the same value back and forth.
You made the change yourselfPair a webhook with an API key in Workspace settings, and changes made with that key are not sent back to it. Recommended if you both read and write.
It came from a bulk jobImports and other bulk work raise no events, so loading 500 territories does not arrive as 500 webhooks. Fetch the affected records instead.
Only bookkeeping fields changedA sync writing its own record id back, or a geocoder filling in coordinates, is not a change you need to hear about.
The endpoint was over its rate capAn endpoint accepts 60 events per 5 minutes. Beyond that they are recorded and not sent, and Workspace settings says so. Nearly always a loop.

Status and type values

Territories carry a status and a type. Both are returned as human-readable strings. Treat them as an open set: a value the API does not recognise is passed through unchanged rather than replaced, so your code should handle unexpected strings without failing.

status
statusMeaning
AvailableOpen for sale, no commitment in place.
PendingA sale is in progress but not yet closed.
Sold (Not Open)Awarded to a franchisee; the unit is not trading yet.
Sold (Open)Awarded and operating.
For SaleAn existing unit being resold.
ProspectUnder consideration; not yet available.
ClosedNo longer operating.
type
typeMeaning
Brick and MortarFixed premises.
MobileService travels to the customer.
RemoteDelivered without a physical location.
OtherAnything not covered above.

Errors

Errors return a matching HTTP status and a JSON body with a single error key. There are no machine-readable error codes, so branch on the HTTP status rather than on the message text — wording may change.

{
  "error": "Invalid API key."
}
StatuserrorWhen it happens
401
Missing API key. Provide it as 'Authorization: Bearer <key>' or 'X-API-Key: <key>'.No API key was sent, or the Authorization header was not in the exact form "Bearer <key>".
401
Invalid API key.The key does not match any issued key.
401
This API key has been revoked.The key was valid but has since been revoked in Zors. Create a new key.
404
Not foundThe path is not one of the documented endpoints.
404
Territory not foundNo territory with that id exists in your organisation, it has been deleted, or it belongs to a different organisation.
400
ZIP codes are part of the territory's boundary and are changed in the app.A field was sent that cannot be changed through the API, or a value was the wrong type or outside the documented set. The response carries `code` and a `details` array naming each field and what is wrong with it.
403
This API key does not have the 'territories:write' scope.The key is valid but lacks the permission this endpoint needs. Add it to the key in Workspace settings, under API Keys.
405
Method not allowed. This path accepts: GET, PATCH.The path exists but not with that method. The response carries an `Allow` header. Authentication runs first, so an invalid key returns 401 whatever method you used.
409
More than one contact matches this email. Update one directly by its id instead.An upsert matched two or more records, so it refused rather than guess which one you meant. `details.ids` lists them.
429
Rate limit exceeded. Maximum 1000 requests per hour. Retry after N seconds.The key exceeded its hourly quota. The response carries a Retry-After header in seconds, as well as the X-RateLimit-* headers.
500
Internal server errorAn unexpected error. Safe to retry with backoff; contact support if it persists.

Rate limits

Requests are limited to 1,000 per hour, per API key. The window is fixed rather than rolling: it starts on your first request and resets an hour later. Because the limit is per key, issuing a second key gives that integration its own separate quota.

Response headers
FieldTypeDescription
X-RateLimit-LimitnumberYour quota — currently 1000.
X-RateLimit-RemainingnumberRequests left in the current window. Approximate under concurrency.
X-RateLimit-ResetnumberUnix timestamp (seconds) when the window resets. Not sent on a 429 response.

Using it from a browser

The API sends permissive CORS headers, so browser requests will technically succeed from any origin. That is a convenience for local experimentation, not an invitation to call it from your front end — doing so exposes a long-lived key with read access to every territory you own. Call it from your server and pass the results on.

FieldTypeDescription
Access-Control-Allow-Origin*Any origin may call the API.
Access-Control-Allow-MethodsGET, POST, PATCH, OPTIONSReads and writes are both supported.
Access-Control-Allow-HeadersContent-Type, Authorization, X-API-KeyHeaders you may send.
Access-Control-Expose-HeadersX-RateLimit-*Rate-limit headers are readable from browser JavaScript.

Stability

The API is versioned in the path. Everything documented here is v1. Additive changes — new fields on a response, new endpoints — can happen at any time, so parse responses tolerantly and ignore fields you do not recognise.

Two things are worth designing around today. The list endpoint returns every territory in one response with no pagination; if you have a large network, expect that response to be large, and expect pagination parameters to be added in future. And boundary polygons are simplified slightly for transport — they are suitable for mapping and analysis, but the authoritative description of a territory is the one in your franchise agreement, not this API.

Need something that isn't here?

Filtering, pagination, deletion and resources beyond territories, contacts and companies are not part of v1. If your integration needs them, get in touch — and if you are automating workflows rather than building against an API, the Zapier integration may already do what you need.