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:

bash
curl -s -b jar.txt -X POST "https://api.spatibase.com/api/projects/$PROJECT_ID/import" \
  -H 'Content-Type: application/json' -d @import.json
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" }
  ]
}
json
{
  "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:

json
{ "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.

bash
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"} ] }'
json
{ "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, where values replaces 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.
  • matchBy that is not an identifier element of the type answers 422 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:

json
{ "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:

bash
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 ..."} ] }'
  • locale must be a non-default language of the project, otherwise 422 errors.locale. It needs matchBy (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 changed until you mark it ready and publish.
  • Export writes the same information as title:de and <apiKey>:de properties, 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:

json
{ "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" } } ] } }
json
{ "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.

bash
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"}]}'
json
{ "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 required they 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.

  • ids lists the id of the entry every row created or updated, in row order, with null for a row that was skipped. Use it to mark them ready, group or link the entries afterwards.
  • With publish: true the answer also has published (how many went live), publishFailed (the entries that stayed drafts, with the reason) and version (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.
  • skipped counts 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 N for CSV, feature N for GeoJSON, counted from 1 in data).
  • 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).

bash
curl -s -b jar.txt "https://api.spatibase.com/api/projects/$PROJECT_ID/export?type=c9f0346b-3962-40ab-a8c2-d24d6e1597c3"
json
{ "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" } } ] }
  • properties holds id, type (the type’s apiKey), title, status and the stored values: asset and entry ids, raw objects, and values of archived or unassigned elements too.
  • type takes a type UUID and matches that type only, not its subtypes. ?type=region answers 400 {"message":"Unknown type"}.
  • Entries without a location are included with "geometry": null. The response has Content-Disposition: attachment.
  • Owners and editors can export. Exporting a type and importing the file back with matchBy updates 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

bash
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: images and files take ids of assets already uploaded. Upload first (POST /assets, or the upload_asset tool of the MCP server or the --images option of the spatibase command), 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, text and markdown fields have language columns; choice option labels are set in the element, not by import.
  • Ranges and locations from text cells: date ranges and location values need JSON objects, which only GeoJSON properties can carry.
  • The web app wizard does not offer image, file, geo or location columns, although the API accepts asset ids and GeoJSON text for them.

Updated

Docs support

How can we help?

A person on our team reads every message. We include the page you are on, so you don't have to explain where.

Or write to hello@spatibase.com. For outages, check System status.

Talk to us

Not sure it’s a fit? Let’s find out

Tell us about your use case and what you need, from data model to security and deployment. In 30 minutes we’ll look at it together and tell you honestly if we can help.

Or write to hello@spatibase.com.