Shapefiles (Map Layers) API Reference
The Shapefiles API manages the custom map layers shown on the ComboCurve map, such as DSU outlines, lease boundaries or pipeline routes. You can create a layer, read its features back, share it with more projects, and delete it, all without the ComboCurve app.
These endpoints work differently from most of the API:
- Creating a layer is asynchronous. A create request returns
202 Acceptedwith ajobId. You poll the job until the layer is ready. - Zip files are uploaded straight to cloud storage. The API gives you a signed URL. You
PUTthe zip to that URL, then ask the API to import it. The file never passes through the API itself. - Names must be unique where the layer appears. Creating a layer with a name that is already in use returns
409 Conflict. There is no replace-in-place: to replace a layer, delete it and create it again. - One layer can belong to several projects. Deleting it from a project route only removes it from that project if other projects still use it.
Key Concepts
Scope
Every layer has a scope:
scope | Where the layer appears |
|---|---|
"company" | On the map in every project |
"project" | Only in the projects listed in its projectIds (one or more) |
projectIds always lists every project the layer belongs to, even when you read the layer through a single project's route. Check it before deleting a layer to see who else uses it.
Top-level and project routes
Each operation is available on two sets of routes:
Top level: /v1/shapefiles | Project: /v1/projects/{projectId}/shapefiles | |
|---|---|---|
| List and read | Every layer in the company: company layers and every project's. Filter with scope, projectId and name. | Only the layers in this project. Company layers are not included. |
| Create | Body sets scope, plus projectIds when the scope is project | Always creates a layer in this project. The body has no scope or projectIds. |
Update (PATCH) | Name, description, color, scope and projects | Not available |
| Delete | Deletes the layer everywhere | Removes the layer from this project only (see Deleting a Layer) |
| Jobs | Any upload or delete job you started | Only jobs that involved this project |
Use the top-level routes to share a layer across projects or to manage company layers. Use project routes when an integration only ever works inside one project.
Jobs
Creating a layer, and deleting one, run in the background. The request returns 202 Accepted:
{ "jobId": "6703f2a0b1c2d3e4f5a6b7c8" }
Poll GET /v1/shapefiles/jobs/{jobId} (or the project route's /jobs/{jobId}) until status is successful or failed. See Polling a Job.
Choosing How to Create a Layer
There are two ways to create a layer. Both produce the same kind of layer, and both read back the same way.
| Zip upload | GeoJSON | |
|---|---|---|
| Best for | Shapefiles you already have, large layers, data in a projected coordinate system | Data your integration generates, or layers read from another system |
| Calls | 3: reserve an upload, upload the file, import it | 1 |
| Coordinate system | Any, as long as the zip includes a .prj file. Layers are reprojected to WGS84 on import. | WGS84 longitude/latitude only |
| Size limit | 260 MB zip | 10,000 features, and a 24 MB request body |
| Validation | The zip's file list is checked up front. Problems with the shapes themselves fail the job. | Every feature is checked up front. Errors return 400 with the location of each problem. |
Workflow A: Upload a Shapefile Zip
1. POST /v1/shapefiles/uploads → uploadId and a signed uploadUrl
2. PUT <uploadUrl> → the zip goes to cloud storage
3. POST /v1/shapefiles/uploads/{uploadId} → 202 { jobId }
4. GET /v1/shapefiles/jobs/{jobId} → poll until successful
What the zip must contain
- Exactly one shapefile: a
.shpfile with a matching.shxand.dbffile (the same base name). - A
.prjfile is optional but recommended. Without one, coordinates are read as WGS84 longitude/latitude. - Files can sit at the root of the zip or in a folder. macOS metadata (
__MACOSX/,._*files) is ignored. - At most 260 MB.
Step 1: Reserve an upload
POST /v1/shapefiles/uploads
Content-Type: application/json
{ "contentType": "application/zip" }
The body is optional. contentType can be application/zip (the default) or application/x-zip-compressed, which some Windows tools send.
201 Created
{
"uploadId": "6703f1c2a9e4b5d6e7f80912",
"uploadUrl": "https://storage.googleapis.com/...",
"method": "PUT",
"headers": { "Content-Type": "application/zip" },
"expiresAt": "2026-10-12T15:30:00.000Z",
"maxBytes": 272629760
}
| Field | Description |
|---|---|
uploadId | Identifies this upload. You use it in step 3. |
uploadUrl | Signed URL to upload the zip to. It expires at expiresAt, 30 minutes after it was issued. |
method | Always PUT. |
headers | Headers the upload request must send, exactly as given. |
maxBytes | The largest zip that can be imported (260 MB). |
An upload is tied to the route that reserved it. An upload reserved on /v1/shapefiles/uploads must be imported on /v1/shapefiles/uploads/{uploadId}, and one reserved on a project route must be imported on that same project route.
Step 2: Upload the zip
Send the file's bytes to uploadUrl with the method and headers from step 1:
curl -X PUT "<uploadUrl>" \
-H "Content-Type: application/zip" \
--data-binary @dsu_outlines.zip
This request goes to Google Cloud Storage, not to the ComboCurve API. Send only the headers from step 1, and do not send your ComboCurve API key or token. If the Content-Type doesn't match the one in step 1, storage rejects the upload.
If the URL expires before the upload finishes, reserve a new upload and start again.
Step 3: Import the upload
The body holds the layer's details. The file is already in storage.
POST /v1/shapefiles/uploads/6703f1c2a9e4b5d6e7f80912
Content-Type: application/json
{
"name": "DSU Outlines",
"description": "Q3 DSUs",
"color": "#a0a0a0",
"scope": "project",
"projectIds": ["66aa1b2c3d4e5f6a7b8c9d0e", "66bb1b2c3d4e5f6a7b8c9d0f"]
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | See Layer Names. |
description | string | No | Free text. |
color | string | No | Hex color such as #a0a0a0 (the default). |
scope | string | Yes, top level only | "company" or "project". |
projectIds | string[] | When scope is "project" | One or more existing project ids. Must be omitted when scope is "company". |
On a project route, leave out scope and projectIds. The layer is created in that project.
202 Accepted
{ "jobId": "6703f1c2a9e4b5d6e7f80912" }
Before starting the job, the API checks that:
- The upload exists and is no larger than 260 MB. Otherwise it returns
404, or400if the file is too large. - The zip contains one complete shapefile. Otherwise it returns
400with the reason, such as a missing.dbf. - The name is available. Otherwise it returns
409(see Layer Names).
Import an upload within 24 hours of uploading it. Uploads that are never imported are deleted after that.
Each upload can be imported once. Repeating the import returns 409 with the original job's id in details.jobId. If your import request timed out and you don't know whether it went through, retry it: either it starts the job, or the 409 tells you which job to follow.
Always poll the jobId from the response (or from details.jobId). Don't assume it equals the uploadId.
Workflow B: Create a Layer from GeoJSON
Send the layer's details and a GeoJSON FeatureCollection in one request:
POST /v1/shapefiles
Content-Type: application/json
{
"name": "DSU Outlines",
"description": "Q3 DSUs",
"color": "#a0a0a0",
"scope": "company",
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[[-100.10, 35.00], [-100.00, 35.00], [-100.00, 35.10], [-100.10, 35.10], [-100.10, 35.00]]]
},
"properties": { "NAME": "DSU 1", "ACRES": 1280 }
}
]
}
202 Accepted
{ "jobId": "6703f2a0b1c2d3e4f5a6b7c8" }
name, description, color, scope and projectIds follow the same rules as a zip import. On a project route, leave out scope and projectIds.
GeoJSON rules
The layer is stored as a shapefile, so shapefile limits apply on top of the GeoJSON format:
| Rule | Detail |
|---|---|
| Coordinates | WGS84 [longitude, latitude]. Longitude between -180 and 180, latitude between -90 and 90. |
| Features | 1 to 10,000, and a request body under 24 MB. Upload larger layers as a zip. |
| One kind of geometry | Every feature must be points (Point, MultiPoint), lines (LineString, MultiLineString) or polygons (Polygon, MultiPolygon). You can mix single and multi geometries of the same kind. |
| Polygons | Each ring is closed (first and last positions are equal) and has at least 4 positions. |
| Lines | At least 2 positions. |
| Property names | 1 to 10 letters, digits or _. Names that differ only by case, such as Name and NAME, are rejected. Names are never truncated, so rename long ones before sending. |
| Property values | A string, number, boolean or null. No nested objects or arrays. |
| Strings | At most 254 bytes (UTF-8). |
| Numbers | Finite, smaller than 1015, with at most 15 decimal places. |
| One type per property | A property that is a number in one feature and a string in another is rejected. null is allowed anywhere. |
Validation errors return 400 with a location for each problem, such as features[12].geometry.coordinates[0] or features[3].properties.LEASE_NAME. Up to 50 errors are reported at once.
Features keep the order you send them in. Feature ids are not used.
Polling a Job
GET /v1/shapefiles/jobs/6703f2a0b1c2d3e4f5a6b7c8
200 OK
{
"jobId": "6703f2a0b1c2d3e4f5a6b7c8",
"type": "upload",
"status": "successful",
"createdAt": "2026-10-12T15:01:00.000Z",
"completedAt": "2026-10-12T15:01:40.000Z",
"result": { "shapefileId": "6703e9a1b2c3d4e5f6a7b8c9" }
}
| Field | Description |
|---|---|
type | "upload" (a create from either workflow) or "delete". |
status | See below. |
completedAt | Present once the job has finished. |
result.shapefileId | The new layer's id, on a successful upload. |
error | The reason, when status is "failed". |
status | Meaning |
|---|---|
"pending" | Queued and not started yet. |
"running" | In progress. An upload reports running from the moment it is accepted until it finishes. No progress percentage is available. |
"successful" | Done. For an upload, the layer is ready and result.shapefileId is its id. |
"failed" | The job failed. See error. |
Uploads usually take from a few seconds to a few minutes, depending on the size of the layer. Polling every 5 to 10 seconds is reasonable.
An upload that hasn't finished after 30 minutes is marked failed with the error Processing timed out. Please retry the upload. To retry a zip upload, reserve a new upload and repeat the workflow. To retry a GeoJSON create, send the request again.
Only jobs started through the API can be read here; uploads made in the ComboCurve app are not available. A project route only returns jobs that involved its project.
Layer Names
Format
A name is 2 to 64 characters: letters, digits, _, - and spaces, with no leading or trailing space. Wells is reserved.
Where a name must be unique
A name must not already be used anywhere the new layer will appear:
| New layer | Must not match |
|---|---|
| Company layer | Any company layer |
| Project layer in projects P1…Pn | Any company layer, or any layer in P1…Pn |
The comparison is exact and case-sensitive: DSU Outlines and dsu outlines are different names. As in the ComboCurve app, a new company layer is only checked against other company layers, so it can take a name that a project layer already uses.
A conflict returns 409 Conflict. The body tells you what's in the way:
{
"name": "ShapefileConflictError",
"message": "A layer named `DSU Outlines` already exists in this scope.",
"details": { "location": "name", "conflictingId": "6703e9a1b2c3d4e5f6a7b8c9" }
}
details contains | Cause | What to do |
|---|---|---|
conflictingId | An existing layer has the name | Choose another name, or delete that layer and try again (see Replacing a Layer) |
jobId | Another upload with the name is still in progress | Wait for that job to finish, or choose another name |
| Neither | A heatmap layer has the name. Heatmaps can't be read or deleted through the API. | Rename or delete the heatmap in ComboCurve, or choose another name |
Replacing a Layer
Layers can't be replaced in place. To upload a new version under the same name:
- Find the existing layer, for example with
GET /v1/shapefiles?name=DSU%20Outlines. - Delete it with
DELETE /v1/shapefiles/{id}. This returns202with a deletejobId. - Poll the delete job until
statusissuccessful. The name is in use until then, so creating the new layer earlier returns409. - Create the new layer with either workflow.
The new layer has a new id. As in the ComboCurve app, deleting and re-creating a layer loses what referred to the old one: users' map settings (whether the layer is switched on, and its display settings) don't carry over, and inventory plans that use the old layer for DSUs keep pointing at the deleted layer.
To change only the name, description, color or projects, use PATCH instead. It keeps the layer's id.
Sharing and Updating a Layer
PATCH /v1/shapefiles changes a layer's details without re-uploading it. It is only available on the top-level route.
PATCH /v1/shapefiles
Content-Type: application/json
[
{ "id": "6703e9a1b2c3d4e5f6a7b8c9", "projectIds": ["66aa1b2c3d4e5f6a7b8c9d0e", "66bb1b2c3d4e5f6a7b8c9d0f", "66cc1b2c3d4e5f6a7b8c9d10"] }
]
207 Multi-Status
{
"results": [{ "status": "Success", "code": 200, "id": "6703e9a1b2c3d4e5f6a7b8c9" }],
"successCount": 1,
"failedCount": 0
}
The body is an array of up to 200 records, each with an id. A single object is also accepted. Each record gets its own result, so some can succeed while others fail. Send each layer at most once per request.
| Field | Description |
|---|---|
name | Renames the layer. The new name gets the same uniqueness check as a new layer, against every project the layer will belong to. |
description | null clears it. |
color | Hex color. |
scope | "company" or "project". Moving to "company" clears projectIds, so don't send both. |
projectIds | The complete new list of projects. It replaces the old list rather than adding to it. |
Only the fields you send are changed. shapeType, bbox and fields are accepted and ignored, so you can send back a record from GET.
Common changes:
| To | Send |
|---|---|
| Share a project layer with another project | The full projectIds, with the new project added |
| Remove a layer from one of its projects | The full projectIds, without that project. A project layer must keep at least one project; use DELETE to remove it completely. |
| Make a project layer a company layer | "scope": "company" |
| Make a company layer a project layer | "scope": "project" and projectIds |
Deleting a Layer
What DELETE does depends on the route:
| Route | Layer | Result |
|---|---|---|
DELETE /v1/shapefiles/{id} | Any layer | The layer is deleted everywhere. 202 with a delete jobId. |
DELETE /v1/projects/{projectId}/shapefiles/{id} | Belongs only to this project | The layer is deleted. 202 with a delete jobId. |
| Also belongs to other projects | The layer is removed from this project only and stays in the others. 200, immediately. | |
| A company layer, or not in this project | 404 |
When a shared layer is removed from a project, the response lists the projects that still have it:
{
"id": "6703e9a1b2c3d4e5f6a7b8c9",
"result": "unlinked",
"remainingProjectIds": ["66bb1b2c3d4e5f6a7b8c9d0f"]
}
The layer's name becomes available in that project straight away. After a 202, the name stays in use until the delete job succeeds.
Deleting a layer removes it from users' map settings. Inventory plans that use the layer for DSUs keep a reference to it.
Reading Layers
List and get
GET /v1/shapefiles returns a page of layers. GET /v1/shapefiles/{id} returns one layer.
[
{
"id": "6703e9a1b2c3d4e5f6a7b8c9",
"name": "DSU Outlines",
"description": "Q3 DSUs",
"color": "#a0a0a0",
"scope": "project",
"projectIds": ["66aa1b2c3d4e5f6a7b8c9d0e", "66bb1b2c3d4e5f6a7b8c9d0f"],
"shapeType": "POLYGON",
"bbox": [-100.10, 35.00, -100.00, 35.10],
"fields": [{ "name": "NAME", "fieldType": "string" }]
}
]
| Field | Description |
|---|---|
shapeType | POINT, POLYLINE, POLYGON or MULTIPOINT. |
bbox | [minLongitude, minLatitude, maxLongitude, maxLatitude]. |
fields | The layer's attribute columns. |
The list takes skip and take (default 25, at most 200), with paging links in the Link header. At the top level you can filter by:
| Filter | Description |
|---|---|
scope | company or project. project without projectId returns every project layer. |
projectId | Only layers in this project. Can't be combined with scope=company. |
name | Exact name. |
HEAD /v1/shapefiles (or GET /v1/shapefiles/head, for clients that can't send HEAD) returns the number of matching layers in X-Query-Count.
Heatmap layers are not included in any of these.
Features
A layer's shapes come from a separate, paginated endpoint:
GET /v1/shapefiles/6703e9a1b2c3d4e5f6a7b8c9/features?skip=0&take=200
[
{
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [[[-100.10, 35.00], [-100.00, 35.00], [-100.00, 35.10], [-100.10, 35.10], [-100.10, 35.00]]]
},
"properties": { "NAME": "DSU 1", "ACRES": 1280 }
}
]
- The response is an array of GeoJSON
Featureobjects in WGS84, in their original order. takedefaults to 200, which is also the maximum. Follow thenextlink in theLinkheader until it is gone.X-Query-Countis the layer's number of features.- A layer uploaded as a zip reads back as GeoJSON, reprojected to WGS84. The original zip can't be downloaded.
Copying a layer
What the API returns can be sent back to create a layer. To copy a layer, for example to another tenant or into a new layer with a different name:
GET /v1/shapefiles/{id}for itsname,descriptionandcolor.- Read every page of
GET /v1/shapefiles/{id}/features. POST /v1/shapefileswith those details, ascope(andprojectIds),"type": "FeatureCollection", and the collected features asfeatures.
This works for layers of up to 10,000 features. Copy larger layers by uploading the original zip.
Limits
| Limit | Value |
|---|---|
| Zip size | 260 MB |
| Signed upload URL lifetime | 30 minutes |
| Time to import an upload | 24 hours after uploading |
| GeoJSON features per layer | 10,000 |
| Request body | 24 MB |
| Upload job timeout | 30 minutes, after which the job is marked failed |
Records per PATCH | 200 |
Layers per page (GET) | 25 by default, 200 at most |
| Features per page | 200 |
Error Reference
| HTTP Status | Condition |
|---|---|
400 | Invalid request body, such as a bad name, color or GeoJSON feature. details.location points to the problem. |
400 | The upload is not a zip, doesn't contain exactly one complete shapefile, or is larger than 260 MB |
400 | PATCH would leave a project layer with no projects, or sends projectIds with scope: "company" |
404 | Layer, job or project not found, or the layer isn't in the route's project |
404 | Upload not found: not uploaded yet, older than 24 hours, or reserved on a different route |
409 | The name is already in use where the layer would appear (see Layer Names) |
409 | The upload was already imported. details.jobId is the original job. |
409 | The uploaded file changed while it was being checked. Retry the import. |
502 | The upload could not be started. Retry the request. |