Bulk metaobject import
POST /custom-data/metaobjects/{type}/entries/bulk — scope write_metaobjects, Idempotency-Key required.
The bulk sibling of PUT …/entries/{handle}. Use it to load a whole content type at once
— a town guide’s 1,500 town pages, a breed directory’s 350 breed pages — and to re-sync it later. Every row goes
through the same validation as the single-entry PUT, so a bulk row can never store something an edit could not.
Request
Section titled “Request”{ "dryRun": false, "mode": "merge", "entries": [ { "handle": "abbots-langley", "fields": { "name": "Abbots Langley", "county": "Hertfordshire", "population": "10472", "hero": "https://images.example.org/towns/abbots-langley.jpg" }, "seoTitle": "Abbots Langley — local guide", "seoDescription": "Schools, transport and things to do in Abbots Langley.", "publish": "published", "externalKey": "ons:E04012345" } ]}| Field | Meaning |
|---|---|
entries |
1 to 500 rows. A handle may appear only once per request. |
entries[].handle |
The entry’s key and its URL segment: lowercase letters, digits, - and _, at most 128 characters. Upsert key: an existing handle is updated, a new one is created. |
entries[].fields |
Field key → value. See Field values below. |
entries[].seoTitle, seoDescription |
Omitted keeps the stored value (merge mode); "" clears it. At most 256 / 512 characters. |
entries[].publish |
published (live now — an entry already live keeps its original date), draft, or omitted to keep the current state. A new entry starts as a draft. Only for publishable types. |
entries[].publishedAt |
An explicit live date (ISO 8601); a future date schedules the entry. Wins over publish. |
entries[].externalKey |
Your own id for the source record (unique per type, never rendered). Omitted keeps the stored key. |
dryRun |
true validates every row exactly as the write would and writes nothing, imports no image. |
mode |
merge (default): a field you do not send keeps its stored value. replace: the fields you send are the entry’s whole field set, and omitted SEO fields are cleared. |
Field values
Section titled “Field values”Values may be real JSON or the text a spreadsheet cell holds — the server converts text to the field’s type:
| Field type | Accepted text |
|---|---|
number_integer, number_decimal |
"12", "-3", "3.25" (a dot, never a comma) |
boolean |
true/false, yes/no, 1/0, evet/hayır |
list.* |
a JSON array, or one cell with the values split by a vertical bar, a|b|c (or one value per line) |
json, money, rating, dimension, weight, volume |
the JSON text |
file_reference, list.file_reference |
a media asset id, or an https:// image URL (see below) |
null or a blank string means no value: in merge mode it clears that field. A value the server cannot convert is
sent to the field’s validator unchanged and the row fails with the field’s own reason — nothing is guessed.
Image URLs
Section titled “Image URLs”An https:// URL in a file field is fetched into the store’s media library through the
media importer — SSRF-vetted, de-duplicated by URL and by content — and the entry stores
the asset id. This needs the app to hold write_media as well. Limits per request: 100 distinct image URLs
(the media route’s own ceiling); URLs repeated across rows count once and are fetched once. A failed image fails
its row only. A dry run checks that each URL is one the importer would fetch, without fetching it.
Response — 200 for a valid request
Section titled “Response — 200 for a valid request”{ "dryRun": false, "requested": 2, "created": 1, "updated": 0, "unchanged": 0, "failed": 1, "imagesImported": 1, "results": [ { "index": 0, "handle": "abbots-langley", "status": "created", "id": "5b1e…", "errorCode": null, "error": null, "imagesImported": 1 }, { "index": 1, "handle": "bushey", "status": "failed", "id": null, "errorCode": "invalid_entry", "error": "Field 'population' must be a whole number (type number_integer).", "imagesImported": 0 } ]}status per row: created, updated, unchanged (identical to what is stored — nothing written), failed. In a
dry run created/updated say what would happen. Re-sending the same file is therefore free: every row comes back
unchanged.
Row errorCode |
Meaning |
|---|---|
invalid_entry |
The single-entry validation refused it: handle grammar, unknown field key, a value its type rejects, a required field missing, a dangling reference, an over-long text, a publish date on a non-publishable type, or an external key another entry holds. error names it. |
invalid_publish |
publish is not published or draft. |
invalid_handle |
The row has no handle. |
image_import_unavailable |
A file field holds an image URL but the token lacks write_media. |
invalid_image_url / importer codes |
The image could not be fetched (see Media imports). |
conflict |
Another write to the same entry landed at the same moment; retry the row. |
Request-level refusals — 400 with { "code", "error" }, nothing written: no_entries, too_many_entries
(over 500), duplicate_handles, invalid_mode, too_many_images (over 100 distinct URLs). 404: the store has no
metaobject type {type}. 403: the token lacks write_metaobjects.
curl -X POST https://www.example-store.com/custom-data/metaobjects/town/entries/bulk \ -H "Authorization: Bearer ciqra_at_..." \ -H "Idempotency-Key: towns-2026-11-01-batch-1" \ -H "Content-Type: application/json" \ -d '{"dryRun":true,"entries":[{"handle":"whitby","fields":{"name":"Whitby","population":"13213"},"publish":"published"}]}'Python — a CSV in batches of 500
Section titled “Python — a CSV in batches of 500”import csv, uuid, requests
STORE = "https://www.example-store.com"TOKEN = "ciqra_at_..." # from POST /apps/token (scopes: write_metaobjects, write_media)TYPE = "town"BATCH = 500
def rows(path): with open(path, newline="", encoding="utf-8") as f: for r in csv.DictReader(f): yield { "handle": r["slug"], "fields": {"name": r["name"], "county": r["county"], "population": r["population"], "hero": r["image_url"]}, "seoTitle": r["meta_title"], "seoDescription": r["meta_description"], "publish": "published", }
def send(batch, dry_run): # One key per batch and per attempt: a retry of the SAME batch reuses its key and gets the first answer back. key = f"{TYPE}-{'dry' if dry_run else 'run'}-{uuid.uuid5(uuid.NAMESPACE_URL, repr(batch))}" response = requests.post( f"{STORE}/custom-data/metaobjects/{TYPE}/entries/bulk", headers={"Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key}, json={"entries": batch, "dryRun": dry_run}, timeout=300, ) response.raise_for_status() return response.json()
entries = list(rows("towns.csv"))for dry_run in (True, False): # check everything first, then write failed = 0 for start in range(0, len(entries), BATCH): result = send(entries[start:start + BATCH], dry_run) failed += result["failed"] for r in result["results"]: if r["status"] == "failed": print(start + r["index"], r["handle"], r["errorCode"], r["error"]) if dry_run and failed: raise SystemExit(f"{failed} rows would fail; fix them and run again")