Import API
The import endpoint loads GeoJSON features, or CSV/TSV rows that you send as JSON, into one type of a project. Every row becomes a draft. This page is for developers who load data by script; editors can use the import wizard in the web app, described on Import data.
Placeholders: $PROJECT_ID is the UUID in the project’s address, and jar.txt holds the session cookie from signing in. The import is part of the Content Management API, the web app’s own interface, which can change without notice.
At a glance
| Endpoint | POST https://api.spatibase.com/api/projects/$PROJECT_ID/import |
| Formats | a GeoJSON FeatureCollection or single Feature; CSV/TSV parsed by you into an array of row objects |
| Size | 10,000 rows per request (extra rows are ignored, with a warning; the MCP tool import_features takes 1,000); JSON body up to 50 MB |
| Location | GeoJSON geometry; CSV latitude and longitude columns, or an address column that Spatibase looks up with OpenStreetMap Nominatim (up to 250 different addresses per import) |
| Result | drafts; mark them ready and publish afterwards, or send publish: true to do both in the same request |
| Execution | synchronous: the request returns when the import is done, and all rows are written in one transaction |
| Who | project owners and editors |
Cells that don’t fit an element’s kind are left empty and reported as warnings; they do not fail the import.
Request
| Field | Required | Meaning |
|---|---|---|
typeId |
yes | UUID of the type to import into. It must be switched on |
format |
yes | geojson, csv or tsv (tsv is treated exactly like csv) |
data |
yes | a GeoJSON object, or [{column: cell, …}, …] for CSV/TSV |
mapping |
no | which property or column feeds what (below) |
matchBy |
no | apiKey of an identifier element of the type: rows with a matching value update that entry |
groupIds |
no | groups that every created or updated entry is appended to |
locale |
no | a non-default language of the project: the rows are translations of entries matched by matchBy |
publish |
no | true marks exactly the imported and updated entries ready and runs a project publish in the same request, as one new version. That publish also ships anything others had already marked ready. Entries that fail the publish checks stay drafts. Not with locale: publish translations afterwards. An assistant needs the Publish tier to send it |
dryRun |
no | true checks everything and returns the result without writing anything |
mapping key |
Meaning |
|---|---|
title |
Property or column for the title. Left out, the first non-empty one of title, name, Title, Name, TITLE, NAME is used. A missing title is allowed for drafts |
latitude, longitude |
CSV only: coordinate columns. A single comma is read as a decimal comma ("41,3787") |
address |
CSV only: looked up when a row has no usable latitude and longitude |
elements |
{elementApiKey: column}. An element you don’t list takes the column named exactly like its apiKey. To ignore such a column, map the element to "" |
Location rules
What happens to rows without a location depends on the type’s locationMode:
locationMode |
Row without a location | CSV request without location columns |
|---|---|---|
required |
skipped, with a warning | 400 “Choose the latitude and longitude columns, or an address column, so entries can be placed on the map.” |
optional |
created without a location, with a warning | imported without locations, with a warning |
none |
location ignored | location ignored |
A GeoJSON geometry must fit the type’s geometryKinds, otherwise the row is skipped (“Trail entries can only be a line on the map”). A row that updates an existing entry through matchBy and has no location keeps the entry’s current one.
Example: CSV
Parse the file yourself, for example with Papa Parse and header: true, and send the rows:
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/import" \
-H 'Content-Type: application/json' -d @import.json
{
"typeId": "b81bb7e3-f47a-4050-8afb-0d80cbffdda8",
"format": "csv",
"mapping": {
"title": "name", "latitude": "lat", "longitude": "lng",
"elements": { "code": "code", "sites": "sites", "dogs-allowed": "dogs", "difficulty": "difficulty",
"surface": "surface", "opens-at": "opens", "marker-color": "color", "regions": "region" }
},
"data": [
{ "name": "Gold Bluffs Beach", "code": "GBB", "lat": "41,3787", "lng": "-124.0897", "sites": "26", "dogs": "yes",
"difficulty": "Easy", "surface": "Dirt; Gravel", "opens": "7:30 pm", "color": "#0a0", "region": "Redwood Coast" },
{ "name": "Elk Prairie", "code": "EP", "lat": "41.3579", "lng": "-124.0217", "sites": "seventy-five", "dogs": "maybe",
"difficulty": "Expert", "opens": "9am", "color": "teal", "region": "Atlantis" },
{ "name": "Mill Creek", "code": "GBB", "sites": "145" }
]
}
{
"created": 3, "updated": 0, "skipped": 0,
"warnings": [
"1 row imported without a location: no location (latitude/longitude or address is missing) (row 3)",
"Sites: 1 value couldn’t be used and was left empty (not a number) — row 2",
"Dogs allowed: 1 value couldn’t be used and was left empty (not yes/no) — row 2",
"Difficulty: 1 value couldn’t be used and was left empty (not one of the options) — row 2",
"Marker color: 1 value couldn’t be used and was left empty (not a color) — row 2",
"Regions: 1 value couldn’t be used and was left empty (no matching entry) — row 2",
"Code: 1 value couldn’t be used and was left empty (Already used by “Gold Bluffs Beach”) — row 3"
]
}
The first row’s draft, read back with GET /entries/:entryId:
{ "title": "Gold Bluffs Beach", "status": "draft", "geometry": { "type": "Point", "coordinates": [-124.0897, 41.3787] },
"values": { "code": "GBB", "sites": 26, "dogs-allowed": true, "difficulty": "easy", "surface": ["dirt", "gravel"],
"opens-at": "19:30", "marker-color": "#00aa00", "regions": ["e52152c3-9c8c-44fa-b33b-e026ca9b42d5"] } }
Row 2 got "opens-at": "09:00". Row 3 was created without a location and without a code, because GBB was already used.
Value conversion per kind
Each cell is first converted to the element’s kind, then checked with the same rules as a draft save (values per kind). A value that fails either step is left empty and named in a warning. required, minCount and minSelected are not checked, because imported entries are drafts.
kind |
Accepted from a text cell | Also accepted (GeoJSON properties, JSON values) | Left empty, for example |
|---|---|---|---|
text, markdown |
any text; numbers and booleans become text | objects and arrays, only for editor: json (stored as JSON text) |
objects in other text fields; invalid JSON for editor: json; too long |
identifier |
trimmed text | numbers | spaces or symbols; a value another entry already uses, including an earlier row of the same file |
number |
26, 1.6, 12,5, 1,234, 1,234.5, 1 234,5 |
numbers | seventy-five; ambiguous commas such as 1,2345 or 1.234,5; decimals for integer; outside min or max |
boolean |
true yes y 1 on x and false no n 0 off, any case |
booleans | maybe |
date (date) |
ISO 2024-05-01, or text a JavaScript Date can parse (May 1, 2024 → 2024-05-01) |
2024-02-30 |
|
date (datetime) |
ISO text as is; other parseable text → a UTC ISO string | unparseable text | |
date (time) |
9:30, 09:30:15, 7:30 pm → 19:30, 2pm → 14:00 |
25:00 |
|
date (range) |
not accepted from text (May-Oct is not parsed) |
{"start": …, "end": …} |
any text |
choice |
option value or label, any case, anywhere in the tree; multiple splits on , ; | |
arrays | any unknown option (the whole value is dropped) |
link |
a URL → {url} |
{url, label} |
text without http(s):// |
embed |
a URL → {url} |
{url} |
a provider the element does not allow |
images, files |
asset ids separated by , ; | |
arrays of ids | URLs, unknown ids, a non-image in images (the whole list is dropped) |
contact |
text becomes {email}, {website}, {phone} or {name}, by its shape |
a contact object | an invalid email |
color |
#0a0, 0a0, #00AA00 → #00aa00 |
teal |
|
reference |
an entry id, or the title of an entry of a target type (case-insensitive); multiple tries the whole text first, then splits on , ; | |
arrays | no entry with that title |
geo |
GeoJSON written as text | GeoJSON objects | text that is not GeoJSON |
location |
not accepted from text (-124.08,41.37 is not parsed) |
{"lng": …, "lat": …, "address": …} |
any text |
phone |
trimmed text or a number | letters, fewer than 5 digits |
Things that surprise people:
- Numbers: a dot is always the decimal point. A comma followed by groups of exactly three digits is a thousands separator (
1,234→1234). A single comma followed by one or two digits, with no dot, is a decimal comma (12,5→12.5). Any other comma is ambiguous, and the value is left empty with a warning rather than guessed. Spaces between digit groups are removed. - References by title match only entries that existed before the request, so a row cannot reference an entry created by the same import. Import the targets first.
- Columns that match no element are ignored. The type’s other elements stay empty.
Update existing entries with matchBy
With matchBy set to the apiKey of an identifier element, a row whose value matches an entry of the import type, or of one of its subtypes, updates that entry’s draft. Other rows are created as usual.
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/import" -H 'Content-Type: application/json' -d '{
"typeId": "b81bb7e3-f47a-4050-8afb-0d80cbffdda8", "format": "csv", "matchBy": "code",
"groupIds": ["7786b57d-26a2-47e7-b96d-c072aedc04b0"],
"mapping": {"title":"name","latitude":"lat","longitude":"lng","elements":{"code":"code","sites":"sites"}},
"data": [ {"code":"GBB","sites":"30"}, {"code":"GBB","sites":"31"},
{"code":"NEW1","name":"Freshwater Lagoon","lat":"41.27","lng":"-124.09","sites":"12"} ] }'
{ "created": 1, "updated": 1, "skipped": 1, "warnings": [ "1 row skipped: same Code as an earlier row in this file (row 2)" ] }
Gold Bluffs Beach kept its title, location and other values, got "sites": 30, and was added to the group.
- An update merges the imported values into the draft. This differs from
PATCH /entries/:entryId, wherevaluesreplaces everything. A row without a title or location keeps the entry’s current one. The entry keeps its type. - A published entry that is updated becomes
changed. The Content Delivery API shows the change only after you mark it ready and publish. - A second row matching the same entry is skipped. A row whose identifier belongs to an entry of an unrelated type is skipped too.
matchBythat is not an identifier element of the type answers422 errors.matchBy.- Without
matchBy, running the same import twice creates duplicates.
Languages
In a project with languages an import can fill in translations. There are two ways.
Language columns. Without locale, a column or GeoJSON property named <field>_<code>, <field>:<code> or <field>.<code> (for example description_de, description:de or description.de) is read as the German text of that field. For the title, use title_de, name_de or the name of your mapped title column plus the language. Case doesn’t matter, and only the project’s non-default languages count. The texts are saved as translations of the created or matched entries; an empty cell changes nothing. A dry run lists what it found as languageColumns:
{ "created": 0, "updated": 2, "skipped": 0, "dryRun": true,
"languageColumns": [ {"column": "name_de", "locale": "de", "field": "title"},
{"column": "description_de", "locale": "de", "field": "description"} ] }
A translation file. With locale and matchBy, every row updates only the translations of the entry it matches, and the row’s title and text columns are the texts in that language:
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/import" -H 'Content-Type: application/json' -d '{
"typeId": "b81bb7e3-f47a-4050-8afb-0d80cbffdda8", "format": "csv", "matchBy": "code", "locale": "de",
"mapping": {"title": "name", "elements": {"code": "code", "description": "description"}},
"data": [ {"code": "GBB", "name": "Goldbuckel-Strand", "description": "Ein langer Strand ..."} ] }'
localemust be a non-default language of the project, otherwise422 errors.locale. It needsmatchBy(422 errors.matchBy).- A row that matches no entry is skipped with a warning, because there is nothing to translate. Nothing is created, and locations and other values stay as they are.
- Translations are drafts: a published entry becomes
changeduntil you mark it ready and publish. - Export writes the same information as
title:deand<apiKey>:deproperties, so an export can be edited and imported back.
Example: GeoJSON
The feature geometry becomes the main location. Properties are matched like CSV columns, with the same automatic mapping by apiKey. Here the type Trail requires a line:
{ "typeId": "74497dfa-e594-4897-8bc5-d46b3334755b", "format": "geojson", "matchBy": "slug",
"mapping": { "elements": { "length-km": "km" } },
"data": { "type": "FeatureCollection", "features": [
{ "type": "Feature", "geometry": { "type": "LineString", "coordinates": [[-124.07, 41.21], [-124.06, 41.22]] },
"properties": { "Name": "Tall Trees Grove", "slug": "tall-trees", "km": 6.4, "difficulty": "Moderate",
"open-season": { "start": "2026-06-01", "end": "2026-09-30" }, "trailhead": { "lng": -124.07, "lat": 41.21 } } },
{ "type": "Feature", "geometry": { "type": "Point", "coordinates": [-124.0, 41.3] }, "properties": { "name": "Viewpoint", "slug": "viewpoint" } },
{ "type": "Feature", "geometry": null, "properties": { "name": "Somewhere", "slug": "somewhere" } },
{ "type": "Feature", "geometry": null, "properties": { "slug": "fern-canyon-loop", "km": "1,7" } } ] } }
{ "created": 1, "updated": 1, "skipped": 2,
"warnings": [ "1 row skipped: Trail entries can only be a line on the map (feature 2)", "1 row skipped: no geometry (feature 3)" ] }
Feature 1 was created with its range and location values from the JSON properties. Feature 4 had no geometry but matched an existing entry, so it updated length-km to 1.7 and kept the existing line.
Address geocoding
When a CSV row has an address column but no usable latitude and longitude, Spatibase looks the address up with OpenStreetMap Nominatim.
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/import" -H 'Content-Type: application/json' -d '{
"typeId":"b81bb7e3-f47a-4050-8afb-0d80cbffdda8","format":"csv","mapping":{"title":"name","address":"address"},
"data":[{"name":"Crescent City visitor center","address":"1111 2nd St, Crescent City, CA 95531"},
{"name":"Nowhere","address":"zzzz qqqq xxxx 99999 nonexistent"}]}'
{ "created": 2, "updated": 0, "skipped": 0,
"warnings": [ "1 row imported without a location: the address couldn’t be found on the map (row 2)" ] }
- Lookups run at about one per second, so 250 addresses take about 4 minutes, and the HTTP request stays open that long.
- Each different address is looked up once per import (case and spacing are ignored), so rows that share an address cost one lookup.
- Up to 250 different addresses per import. Rows with further addresses are imported without a location, with a warning; in a type whose location is
requiredthey are skipped. - A row with usable latitude and longitude is never looked up.
- Lookups share one queue with every other import running on Spatibase, so a busy moment can make yours slower. When the lookup service does not answer, the address counts as not found.
- A dry run does not look addresses up: they count as found, and a warning says how many would be looked up.
Result and warnings
A successful import answers 200 {created, updated, ids, skipped, warnings: [string]}, plus dryRun: true for a dry run.
idslists the id of the entry every row created or updated, in row order, withnullfor a row that was skipped. Use it to mark them ready, group or link the entries afterwards.- With
publish: truethe answer also haspublished(how many went live),publishFailed(the entries that stayed drafts, with the reason) andversion(the number of the version the publish created). They are marked ready and published in the same transaction as the import. - A longitude outside -180 to 180 is moved around the globe to fit and reported in a warning (“2 rows had a longitude outside -180 to 180 and were moved around the globe to fit: check that longitude and latitude aren’t swapped or broken”). Read it: swapped or broken coordinates still import.
skippedcounts rows that were not written at all. Values left empty do not count as skipped: the row is still created.- Warnings are grouped by reason, so 10,000 bad rows produce a handful of lines. Each line names up to 5 rows (
row Nfor CSV,feature Nfor GeoJSON, counted from 1 indata). - Order: notes (such as “Only the first 10,000 of 10,001 rows were imported.”), skipped rows, rows without a location, then values left empty per element.
| Request problem | Response |
|---|---|
unknown or missing typeId |
422 errors.typeId “Choose which type to import into.” |
| type switched off | 422 errors.typeId “Entries can’t be imported into “…” because the type is switched off.” |
format not geojson, csv or tsv |
400 format must be "geojson" or "csv". |
GeoJSON data is not a FeatureCollection or Feature |
400 “The file isn’t a GeoJSON FeatureCollection or Feature.” |
CSV data is not an array (for example raw CSV text) |
400 “CSV data must be a list of rows.” |
required type, CSV without location columns |
400 (see location rules) |
invalid matchBy, groupIds or dryRun |
422 errors.matchBy, errors.groupIds or errors.dryRun |
Export drafts as GeoJSON
GET /api/projects/$PROJECT_ID/export?type=$TYPE_ID returns the draft of every entry of that type, whatever its status. Leave out type to export all types. For published content, use the Content Delivery API’s entries.geojson (GeoJSON and tiles).
curl -s -b jar.txt "https://api.spatibase.com/api/projects/$PROJECT_ID/export?type=c9f0346b-3962-40ab-a8c2-d24d6e1597c3"
{ "type": "FeatureCollection", "features": [ { "type": "Feature", "id": "e52152c3-9c8c-44fa-b33b-e026ca9b42d5",
"geometry": { "type": "Polygon", "coordinates": [[[-124.2, 41], [-123.8, 41], [-123.8, 41.5], [-124.2, 41.5], [-124.2, 41]]] },
"properties": { "id": "e52152c3-9c8c-44fa-b33b-e026ca9b42d5", "type": "region", "title": "Redwood Coast", "status": "published" } } ] }
propertiesholdsid,type(the type’sapiKey),title,statusand the stored values: asset and entry ids, raw objects, and values of archived or unassigned elements too.typetakes a type UUID and matches that type only, not its subtypes.?type=regionanswers400 {"message":"Unknown type"}.- Entries without a location are included with
"geometry": null. The response hasContent-Disposition: attachment. - Owners and editors can export. Exporting a type and importing the file back with
matchByupdates the entries that have an identifier and creates the others again.
Reproduce the web app wizard with the API
The import wizard in the web app parses the file in the browser and can create a new type made from this file. It guesses an element (field) per column, lets the editor adjust it, then calls the normal Content Management API. No server endpoint guesses fields, so a script does the same three steps itself.
How the wizard guesses fields
Each column except the title and location columns is checked against every row, in this order:
| Guess | When | Element created |
|---|---|---|
| Structured data (JSON) | any value is an object or array (GeoJSON properties) | text, editor: json |
| Yes / No | all values are true/false/yes/no/y/n/on/off (at most 2 distinct); 1/0 only when the column name starts with is, has, can, allow, enabled, active, open, available, visible or featured, or ends in flag or ? |
boolean |
| ID / slug | the name is one of, or ends in _, - or a space followed by one of, id, uuid, guid, slug, code, ref, key, identifier, sku (site_code), and every row has a unique value of up to 64 letters, digits, ., -, _ |
identifier |
| Number | all numeric, none with a leading zero (ZIP codes stay text) | number (integer: true when all are whole) |
| Date, Date and time | all YYYY-MM-DD, or all ISO date-times |
date with mode: date or datetime |
| Color | all #rgb or #rrggbb |
color |
| Web link | all http(s)://… |
link |
| Phone | the name contains phone, mobile or fax (or ends in tel), and the values look like phone numbers |
phone |
| Choice | at least 6 values, 2 to 12 distinct (up to 20 with 100 or more values), values repeated, each up to 40 characters | choice; options are the distinct values, most common first |
| Multi-line text | any value over 200 characters or with a line break | text, editor: multi |
| Text | otherwise | text, editor: single |
Labels are tidied column names (site_code → Site code) and apiKeys are slugs (site-code). The type is named after the file (campsites.csv → Campsites).
Location: for GeoJSON, geometryKinds are the shape kinds present, and locationMode is required when every feature has a shape, optional when some do, none when none do. CSV latitude and longitude give Point, required only when every row has valid coordinates. An address column gives optional. No location gives none.
The three calls
API=https://api.spatibase.com/api/projects/$PROJECT_ID
# 1. One element per column you keep (repeat for each)
curl -s -b jar.txt -X POST "$API/elements" -H 'Content-Type: application/json' \
-d '{"label":"Category","apiKey":"category","kind":"choice","config":{"multiple":false,"options":[{"value":"tent","label":"Tent"},{"value":"rv","label":"RV"}]}}'
# → {"id":"14124f0e-…","apiKey":"category", …}
# 2. The type, with those elements in column order
curl -s -b jar.txt -X POST "$API/types" -H 'Content-Type: application/json' \
-d '{"name":"Campsites","locationMode":"required","geometryKinds":["Point"],"elementIds":["45d8cb64-…","14124f0e-…"]}'
# → {"id":"401fcf4d-…","apiKey":"campsites", …}
# 3. The import, mapping each apiKey to its column
curl -s -b jar.txt -X POST "$API/import" -H 'Content-Type: application/json' -d '{
"typeId":"401fcf4d-…","format":"csv",
"mapping":{"title":"name","latitude":"lat","longitude":"lon","elements":{"site-code":"site_code","category":"category"}},
"data":[{"name":"Mill Creek A1","lat":"41.701","lon":"-124.093","site_code":"MC-A1","category":"Tent"}]}'
# → {"created":1,"updated":0,"skipped":0,"warnings":[]}
The label Tent is matched to the option value tent. If creating an element returns 409 because the apiKey exists (an archived element keeps its key too), the wizard retries with key-2, key-3 and key-4. If the type cannot be created, it deletes the elements it just made with DELETE /elements/:elementId?permanent=true.
What is not supported
- Other formats: the API takes GeoJSON or parsed CSV/TSV rows, not raw CSV text or files. Excel, KML/KMZ, GPX, Shapefile and GeoParquet files, Google Sheets, Google My Maps and PostGIS tables are read by the web app’s import, which sends the result here; from a script, convert them to GeoJSON first. GeoPackage cannot be imported.
- Images and files from URLs:
imagesandfilestake ids of assets already uploaded. Upload first (POST /assets, or theupload_assettool of the MCP server or the--imagesoption of thespatibasecommand), then reference the ids. - Background imports: there is no progress report and no import above 10,000 rows or 250 different addresses per request. Split large files.
- CSV export: export is GeoJSON only.
- Translations of other fields: only the title,
textandmarkdownfields have language columns; choice option labels are set in the element, not by import. - Ranges and locations from text cells: date ranges and
locationvalues need JSON objects, which only GeoJSON properties can carry. - The web app wizard does not offer image, file,
geoorlocationcolumns, although the API accepts asset ids and GeoJSON text for them.
Related
- The
spatibasecommand: reads a file, imports it 1,000 rows at a time and publishes it - Content Management API
- Import data
- Field values
- Limits
Updated