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.
Three steps from nothing to a working request.
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.
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.
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"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_herezors_live_ and are 42 characters long.All endpoints are relative to:
https://public-api.zors.aiReturns every territory belonging to the organisation the API key was issued for.
/v1/territoriescurl -H "Authorization: Bearer $ZORS_API_KEY" \
"https://public-api.zors.ai/v1/territories"| Field | Type | Nullable | Description |
|---|---|---|---|
| territories | object[] | never null | Array of territory summaries. See the fields below. |
| count | number | never null | Number of territories in this response. |
| Field | Type | Nullable | Description |
|---|---|---|---|
| id | string | never null | Unique territory identifier. Use this as the {id} path segment on the detail endpoint. |
| name | string | nullable | Territory name as entered in Zors. |
| status | string | nullable | One of the status values listed below. Unrecognised values are returned unchanged rather than mapped, so treat this as an open set. |
| type | string | nullable | One of the type values listed below. Like status, unmapped values pass through unchanged. |
| centerPoint | number[] | nullable | Centroid as [longitude, latitude] — GeoJSON axis order, longitude first. Null if the territory has no stored centroid. |
| lastBoundaryUpdate | string | nullable | ISO-8601 timestamp of the last boundary edit. Null if the boundary has never been edited since the territory was created. |
{
"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
}Returns a single territory including its boundary polygon and the census geographies it is built from.
/v1/territories/{id}| Field | Type | Required | Description |
|---|---|---|---|
| id | string | Yes | The territory id, as returned by the list endpoint. |
curl -H "Authorization: Bearer $ZORS_API_KEY" \
"https://public-api.zors.ai/v1/territories/aBc123XyZ"| Field | Type | Nullable | Description |
|---|---|---|---|
| territory | object | never null | The territory object. See the fields below. |
| Field | Type | Nullable | Description |
|---|---|---|---|
| id | string | never null | Unique territory identifier. Use this as the {id} path segment on the detail endpoint. |
| name | string | nullable | Territory name as entered in Zors. |
| status | string | nullable | One of the status values listed below. Unrecognised values are returned unchanged rather than mapped, so treat this as an open set. |
| type | string | nullable | One of the type values listed below. Like status, unmapped values pass through unchanged. |
| centerPoint | number[] | nullable | Centroid as [longitude, latitude] — GeoJSON axis order, longitude first. Null if the territory has no stored centroid. |
| lastBoundaryUpdate | string | nullable | ISO-8601 timestamp of the last boundary edit. Null if the boundary has never been edited since the territory was created. |
| description | string | nullable | Free-text description entered in Zors. |
| centerPointGeoJSON | object | nullable | The centroid as a GeoJSON Point: {"type":"Point","coordinates":[lng,lat]}. Null whenever centerPoint is null. |
| boundary | object | nullable | The territory outline as a GeoJSON Polygon or MultiPolygon. Null if the territory has no stored boundary yet. |
| tracts | array | never null | Census tracts making up the territory. Always an array — empty when the territory was not built from tracts. Element shape varies; treat elements as opaque. |
| zipCodes | array | never null | ZIP 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. |
| state | string | nullable | Two-letter state code, e.g. "TX". |
| stateFips | string | nullable | Census state FIPS code. |
| countyFips | string | nullable | Census county FIPS code. |
{
"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"
}
}Changes a territory's details. Only the fields you send are touched.
/v1/territories/{territoryId}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.
| Field | Type | Required | Description |
|---|---|---|---|
| territoryId | string | Yes | The territory's id. |
| Field | Type | Description |
|---|---|---|
| name | string | Up to 200 characters. Cannot be blank. |
| notes | string|null | Up to 2000 characters. |
| status | string | One of the documented status values. |
| operatorContactId | string|null | Contact operating this territory. |
| operatorCompanyId | string|null | Company operating this territory. |
| salesRepId | string|null | Contact acting as sales rep. |
| isExclusive | boolean | Whether this is an exclusive territory. |
| isNotInCompliance | boolean | Whether the operator is out of compliance. |
| outletType | string|null | Franchised or Company-Owned. |
| franchiseType | string|null | Brick and Mortar, Mobile, Remote or Other. |
| color | string|null | Hex colour for this territory on the map. |
| franchiseeInfo | object|null | Agreement and opening dates, storefront address, additional locations. |
| public* | string|null | The public display fields, e.g. publicDisplayName, publicPhone, publicBookingUrl. |
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"}'| Field | Type | Nullable | Description |
|---|---|---|---|
| territory | object | never null | The fields you can change, as they now stand. |
| changed | boolean | never null | False when the request matched what was already stored. |
{
"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
}Creates a contact, or updates the one it matches.
/v1/contactsNeeds 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.
| Field | Type | Description |
|---|---|---|
| firstName | string | Cannot be blank if sent. |
| lastName | string | |
| string | Stored lowercased, and used for matching. | |
| emails | object[] | {address, type} where type is primary, work, personal or other. Up to four. |
| phoneNumber | string | |
| jobTitle | string | |
| companyId | string | A company in Zors this contact belongs to. |
| address | object | {street, city, state, postalCode, country}. |
| tags | string[] | |
| externalId | string | This contact's id in your system. |
| externalSource | string | Which system that id came from, e.g. "hubspot". |
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"
}'| Field | Type | Nullable | Description |
|---|---|---|---|
| contact | object | never null | The stored contact. |
| action | string | never null | `created` or `updated`. |
| matchedBy | string | nullable | What it matched on, or null for a new contact. |
{
"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
}Returns one contact.
/v1/contacts/{contactId}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.
| Field | Type | Required | Description |
|---|---|---|---|
| contactId | string | Yes | The contact's id. |
| Field | Type | Nullable | Description |
|---|---|---|---|
| contact | object | never null | The contact. |
{
"contact": {
"id": "SYyQ8qDGbpDwp4e4UdtA",
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada@example.com"
}
}Changes a contact by id. Only the fields you send are touched.
/v1/contacts/{contactId}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.
| Field | Type | Required | Description |
|---|---|---|---|
| contactId | string | Yes | The contact's id. |
| Field | Type | Nullable | Description |
|---|---|---|---|
| contact | object | never null | The stored contact. |
| action | string | never null | Always `updated`. |
{
"contact": {
"id": "SYyQ8qDGbpDwp4e4UdtA",
"jobTitle": "Analyst"
},
"action": "updated"
}Creates a company, or updates the one it matches.
/v1/companiesNeeds 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.
| Field | Type | Description |
|---|---|---|
| name | string | Required when creating. Cannot be blank. |
| website | string | Full URL. Used to derive the domain. |
| domain | string | Send this to control matching yourself. |
| industry | string | |
| phoneNumber | string | |
| string | ||
| numberOfEmployees | number | A numeric string is accepted. |
| annualRevenue | number | |
| address | object | {street, city, state, postalCode, country}. |
| externalId | string | |
| externalSource | string |
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}'| Field | Type | Nullable | Description |
|---|---|---|---|
| company | object | never null | The stored company. |
| action | string | never null | `created` or `updated`. |
| matchedBy | string | nullable | What it matched on, or null for a new company. |
{
"company": {
"id": "VkNfl1nOJEgguUDQh7Vf",
"name": "Acme Ltd",
"domain": "acme.com",
"website": "https://www.acme.com/about",
"numberOfEmployees": 42
},
"action": "created",
"matchedBy": null
}Returns one company.
/v1/companies/{companyId}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.
| Field | Type | Required | Description |
|---|---|---|---|
| companyId | string | Yes | The company's id. |
| Field | Type | Nullable | Description |
|---|---|---|---|
| company | object | never null | The company. |
{
"company": {
"id": "VkNfl1nOJEgguUDQh7Vf",
"name": "Acme Ltd",
"domain": "acme.com"
}
}Changes a company by id. Only the fields you send are touched.
/v1/companies/{companyId}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.
| Field | Type | Required | Description |
|---|---|---|---|
| companyId | string | Yes | The company's id. |
| Field | Type | Nullable | Description |
|---|---|---|---|
| company | object | never null | The stored company. |
| action | string | never null | Always `updated`. |
{
"company": {
"id": "VkNfl1nOJEgguUDQh7Vf",
"industry": "Retail"
},
"action": "updated"
}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.
| Field | Type | Description |
|---|---|---|
| territories:write | Update a territory's details. | Cannot change ZIP codes, boundary or anything else derived from geometry. |
| contacts:read | Read a contact. | Contacts contain personal data, so this is never implied. |
| contacts:write | Create and update contacts. | Implies contacts:read. |
| companies:read | Read a company. | |
| companies:write | Create and update companies. | Implies companies:read. |
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.
| Field | Type | Description |
|---|---|---|
| territory.created | A territory was created. | |
| territory.updated | A territory changed. Carries its ZIP codes and map image URLs. | |
| contact.created | A contact was created. | |
| contact.updated | A contact changed. | |
| company.created | A company was created. | |
| company.updated | A company changed. |
A POST with this envelope. The resource sits under data, keyed by its name, so related objects can be added later without breaking you.
| Field | Type | Nullable | Description |
|---|---|---|---|
| id | string | never null | Unique per event, e.g. "evt_2f8c…". Use it to ignore an event you have already handled. |
| type | string | never null | One of the event names above. |
| apiVersion | string | never null | Version of the envelope and payload shapes. Currently "v1". |
| createdAt | string | never null | When the event was raised, ISO-8601. |
| occurredAt | string | never null | When the change itself happened, ISO-8601. |
| resourceVersion | number | never null | Increases with each change to a record. Use it to discard an event that arrives after a newer one. |
| organizationId | string | never null | The organisation the change belongs to. |
| source | string | never null | What caused it: user, api, zapier, zoho or system. |
| data | object | never null | The 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"
}
}
}| Field | Type | Description |
|---|---|---|
| Zors-Signature | Timestamp and signature, e.g. "t=1758880751,v1=<hex>". Verify this before trusting the body. | |
| Zors-Event-Id | Same as `id` in the body. Stable across retries, so it is the key to deduplicate on. | |
| Zors-Event-Type | Same as `type` in the body, so you can route without parsing. | |
| Zors-Delivery-Id | Identifies this delivery record, useful when asking us about one. | |
| Zors-Delivery-Attempt | Which attempt this is, starting at 1. |
# 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.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.Some changes raise no event on purpose. Worth reading before reporting a missing webhook.
| Field | Type | Description |
|---|---|---|
| The change made no visible difference | If 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 yourself | Pair 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 job | Imports 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 changed | A 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 cap | An endpoint accepts 60 events per 5 minutes. Beyond that they are recorded and not sent, and Workspace settings says so. Nearly always a loop. |
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 | Meaning |
|---|---|
| Available | Open for sale, no commitment in place. |
| Pending | A 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 Sale | An existing unit being resold. |
| Prospect | Under consideration; not yet available. |
| Closed | No longer operating. |
| type | Meaning |
|---|---|
| Brick and Mortar | Fixed premises. |
| Mobile | Service travels to the customer. |
| Remote | Delivered without a physical location. |
| Other | Anything not covered above. |
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."
}| Status | error | When 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 found | The path is not one of the documented endpoints. |
404 | Territory not found | No 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 error | An unexpected error. Safe to retry with backoff; contact support if it persists. |
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.
| Field | Type | Description |
|---|---|---|
| X-RateLimit-Limit | number | Your quota — currently 1000. |
| X-RateLimit-Remaining | number | Requests left in the current window. Approximate under concurrency. |
| X-RateLimit-Reset | number | Unix timestamp (seconds) when the window resets. Not sent on a 429 response. |
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.
| Field | Type | Description |
|---|---|---|
| Access-Control-Allow-Origin | * | Any origin may call the API. |
| Access-Control-Allow-Methods | GET, POST, PATCH, OPTIONS | Reads and writes are both supported. |
| Access-Control-Allow-Headers | Content-Type, Authorization, X-API-Key | Headers you may send. |
| Access-Control-Expose-Headers | X-RateLimit-* | Rate-limit headers are readable from browser JavaScript. |
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.