Skip to content

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.

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

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.

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.

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

Terminal window
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"}]}'
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")