Skip to content

Imports ​

The Imports API is the async, streaming alternative to POST /products. You hand Humind a pre-signed upload URL, push an NDJSON file straight to blob storage, and Humind ingests the file in the background using the same validation and upsert pipeline as the synchronous endpoints.

Use it when you have a large catalog to push (initial backfill, daily mirror of your ERP, full re-sync after a schema change) and the synchronous batch endpoint isn't a good fit anymore. There's no per-item HTTP round-trip, you upload once, start the import once, and poll for status.

Choose your endpoint ​

Catalog sizeFrequencyRecommended endpoint
1 productOne-off or webhook-drivenPOST /products
Up to 500 productsOccasional bulk updatePOST /products/batch
500+ products, or any size on a recurring scheduleDaily or weekly full syncPOST /imports (this page)

The Imports API and the synchronous endpoints write to the same catalog, they're not separate stores. You can mix and match: backfill with an import, then keep the catalog fresh with POST /products calls from your webhook handler.

An import is a full sync, not a change feed

Every import re-reads your whole catalog. To propagate edits as they happen, send only the products that changed with POST /products/batch or PATCH /products/{id}. Imports are capped at 6 per hour per company and environment (see Limits and quotas). A re-push where nothing changed shows report.unchanged equal to the number of rows read.

If products belong to product families, create the groups through POST /product-groups before starting the import. Every NDJSON line accepts the same group_memberships field as a synchronous product write. Creating groups requires catalog:write; running the import requires imports:write.

Workflow overview ​

The full import lifecycle has four steps. The merchant talks to the Humind API for steps 1, 3, and 4; step 2 is a direct upload to Azure Blob Storage using the URL Humind returned in step 1.

1. POST /imports                       → { sync_id, upload_url }
2. PUT  <upload_url>  (NDJSON body)    → 201 from Azure Blob
3. POST /imports/{sync_id}/start       → { status: "processing" }
4. GET  /imports/{sync_id}             → poll until status="done" or "failed"

A fifth optional step, POST /imports/{sync_id}/cancel, stops a running import at the next checkpoint.

Required scope for every step: imports:write. The same scope covers reads.


Step 1: Create the import ​

POST /imports reserves a sync_id and returns a short-lived pre-signed URL you can PUT your NDJSON file to.

Required scope: imports:write

Request ​

bash
curl -X POST https://api.thehumind.com/public/v1/imports \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 3f1a8d92-7c4b-4e6f-b2a1-d5e9c8f7a3b4" \
  -H "Content-Type: application/json" \
  -d '{
    "resource_type": "product",
    "format": "ndjson"
  }'
FieldTypeRequiredDescription
resource_type'product'YesWhat kind of resource the file contains. Only product is supported today.
format'ndjson'YesThe on-the-wire format. Only ndjson (newline-delimited JSON) is supported today.

Response 201 Created ​

json
{
  "sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
  "status": "pending",
  "upload_url": "https://<storage>.blob.core.windows.net/<container>/b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b.ndjson?sv=2024-...&sig=...",
  "expires_at": "2026-04-25T15:30:00Z"
}
FieldTypeDescription
sync_idstringThe handle for this import. Use it in the path of every subsequent call.
statusstringAlways pending at this point, the file hasn't been uploaded yet.
upload_urlstringThe pre-signed URL you PUT your NDJSON to. Scoped to write/create on this single blob; can't be used to read or list anything else.
expires_atISO 8601When the upload_url stops accepting writes. One hour from creation.

The upload URL expires in one hour

If you don't PUT your file before expires_at, the URL becomes useless and you'll need to call POST /imports again to get a fresh one. The sync_id is cheap to recreate, don't try to reuse a stale one.

POST /imports returns 429 with code import_quota_exceeded once your company has created 6 imports in the last hour with keys of the same environment (live and test keys have separate budgets). The Retry-After header and details.retry_after give the seconds until a slot frees up. A refused request is not stored under its Idempotency-Key, so retry with the same key. On this response the X-RateLimit-* headers still describe your per-key request bucket, not the import quota.


Step 2: Upload your NDJSON ​

Once you have the upload_url, send your file directly to Azure Blob Storage with a single PUT. No Humind authentication is involved here: the SAS token in the URL is the only credential needed, and it's already scoped to write this one blob.

File format ​

NDJSON is "newline-delimited JSON": one JSON object per line, separated by \n (not \r\n), no surrounding array, no commas between lines, UTF-8. Each line must be a valid Product payload, the same shape POST /products accepts.

Example products.ndjson:

{"external_id":"SKU-001","title":"Crème hydratante","handle":"creme-hydratante","default_language":"fr","metafields":[{"namespace":"content","key":"skin_types","type":"list.single_line_text_field","value":"[\"dry\",\"sensitive\"]"}],"variants":[{"external_id":"SKU-001-50ML","title":"50 ml","price":29.90,"currency":"EUR"}]}
{"external_id":"SKU-002","title":"Sérum éclat","handle":"serum-eclat","default_language":"fr","variants":[{"external_id":"SKU-002-30ML","title":"30 ml","price":49.00,"currency":"EUR"}]}
{"external_id":"SKU-003","title":"Baume lèvres","handle":"baume-levres","default_language":"fr","variants":[{"external_id":"SKU-003-15ML","title":"15 ml","price":12.50,"currency":"EUR"}]}

Product and variant metafields use the same format and validation rules as synchronous writes. See Product metafields. HTML values must use type: "html"; list values must be JSON-encoded strings.

Every field of the synchronous product schema is accepted per line, including translations (with per-language metafield overrides) and related_products / complementary_products.

Each line can also use "status": "unlisted" for a product that remains available through its direct storefront URL but must not be indexed or recommended by Humind. The import pipeline applies the same product status semantics as synchronous writes.

Product group_memberships also use the synchronous format. Referenced groups and options must already exist. A line with an unknown reference fails with product_group_not_found or product_group_option_not_found, while the other lines continue processing.

json
{"external_id":"SOFA-BLUE","title":"Blue sofa","group_memberships":[{"group_external_id":"sofa-model-1","selected_options":[{"option_external_id":"color","value":"Blue"}]}],"variants":[{"external_id":"SOFA-BLUE-DEFAULT","price":1290,"currency":"EUR"}]}

Request ​

bash
curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/x-ndjson" \
  -H "x-ms-blob-type: BlockBlob" \
  --upload-file products.ndjson

The x-ms-blob-type: BlockBlob header is required by Azure Blob Storage for all PUT uploads. The Content-Type: application/x-ndjson header is recommended so downstream tooling tags the blob correctly.

Response 201 Created ​

Azure returns 201 Created with no body on success. Any other status (typically 403 for an expired SAS, 400 for a missing required header) means the upload didn't land, fix the cause before calling start.

NDJSON gotchas

  • One product per line, \n-terminated. Don't pretty-print, that breaks the line splitter.
  • No leading [ or trailing ] and no commas between lines. NDJSON is not a JSON array.
  • UTF-8 only. If your dataset has accented characters, save the file as UTF-8 without a BOM.
  • A trailing empty line is fine and ignored. Any line that fails to parse is reported in error_logs after the import runs.

Step 3: Start processing ​

POST /imports/{sync_id}/start hands the import to the background worker. Humind streams the blob line by line, validates each product against the same schema as POST /products, and upserts it into your catalog.

Required scope: imports:write

Request ​

bash
curl -X POST https://api.thehumind.com/public/v1/imports/b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b/start \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 7a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d"

Response 202 Accepted ​

json
{
  "status": "processing"
}

The minimal body confirms the import has been queued. Use GET /imports/{sync_id} for the full state including started_at, counters, and error_logs.

The call returns as soon as the import is queued, usually under a second. The actual ingestion runs in the background; track progress with the polling endpoint.

Upload before you start

Calling start before you've successfully PUT the blob returns 422 Unprocessable Entity with code import_blob_missing. Calling start on an import that's already past pending returns 422 with code import_not_pending.

One import at a time per catalog

Calling start while another import of the same catalog (an API import or a product feed pull, same environment) is still processing returns 409 with code import_in_progress, and details.running_sync_id names the running import. Two imports running at once would both write, and the one finishing last would win, even if its file is older. Your import stays pending: call start again once the running one is done.


Step 4: Poll status ​

GET /imports/{sync_id} returns the current state of an import: counts so far, the report breakdown, and the latest error log entries.

Required scope: imports:write

Request ​

bash
curl https://api.thehumind.com/public/v1/imports/b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b \
  -H "Authorization: Bearer hmd_live_..."

Response 200 OK: while processing ​

json
{
  "sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
  "status": "processing",
  "resource_type": "product",
  "total_products": 12000,
  "synced_products": 4321,
  "report": {
    "created": 3210,
    "updated": 1100,
    "unchanged": 950,
    "failed": 11
  },
  "error_logs": [
    {
      "message": "Validation failed on line 742: variants[0].price must be a number",
      "product_id": "SKU-0742",
      "timestamp": "2026-04-25T14:32:01Z"
    }
  ],
  "started_at": "2026-04-25T14:31:12Z",
  "completed_at": null
}

Response 200 OK: once done ​

json
{
  "sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
  "status": "done",
  "resource_type": "product",
  "total_products": 12000,
  "synced_products": 12000,
  "report": {
    "created": 9876,
    "updated": 2100,
    "unchanged": 1800,
    "failed": 24
  },
  "error_logs": [
    {
      "message": "Validation failed on line 1042: handle is required",
      "product_id": "SKU-1042",
      "timestamp": "2026-04-25T14:38:14Z"
    }
  ],
  "started_at": "2026-04-25T14:31:12Z",
  "completed_at": "2026-04-25T14:41:55Z"
}

Polling cadence

Once every 5 to 10 seconds is sufficient. The status field only changes at known transitions (pending → processing → done|failed|cancelled), so polling faster doesn't surface anything new.


The Import object ​

FieldTypeDescription
sync_idstringStable identifier for this import. Use it in every subsequent call.
status'pending' | 'processing' | 'done' | 'failed' | 'cancelled'Lifecycle state. See State machine below.
resource_type'product'What kind of resource the file contained.
total_productsintegerNumber of lines parsed from the NDJSON file. Set after the file is fully read; may be 0 while pending or in the very early seconds of processing.
synced_productsintegerNumber of lines that were successfully created or updated so far. Failed lines (validation errors, malformed JSON) are not counted here, they live in report.failed. When the import is done, synced_products === report.created + report.updated and total_products = synced_products + report.failed.
report.createdintegerNumber of products that didn't exist before and were inserted.
report.updatedintegerNumber of products that already existed (matched on external_id) and were updated in place. Lines identical to what was last imported for that product (nothing to write) are counted here too.
report.unchangedintegerOf the report.updated lines, those identical to what was last imported for that product: nothing was written. When it equals total_products, the import changed nothing.
report.failedintegerNumber of lines that failed validation or upsert. This count is exact even if some entries are no longer present in error_logs.
error_logsobject[]The most recent failure entries. Capped at the last 100. See Error log entries.
upload_urlstringReturned only by POST /imports. Not echoed by GET.
expires_atISO 8601Upload URL expiry. Returned only by POST /imports.
started_atISO 8601 | nullWhen start was called. null while pending.
completed_atISO 8601 | nullWhen the import reached done, failed, or cancelled. null until then.
created_atISO 8601When the import was created (call to POST /imports).

State machine ​

pending  ──start──▶  processing  ──finish──▶  done
   │                      │
   │                      ├──fatal error──▶  failed
   │                      └──cancel──────▶  cancelled
   └──cancel──▶  cancelled

Once an import reaches a terminal state (done, failed, cancelled), it stays there. Re-running an import means creating a new one with POST /imports and uploading a fresh file.


Enrichment after the import ​

When an import reaches done, the products are stored and visible in your dashboard right away. The AI enrichment (search embedding, generated questions) runs just after, in a background job, for the products that were created or whose indexed fields changed. A product listed in the minutes following an import may therefore not be recommended by the assistant yet; it is once the enrichment pass has covered it. Lines that did not change since the previous import are not rewritten and keep their enrichment.

Cancelling an import ​

POST /imports/{sync_id}/cancel stops an import that's still pending or processing. It's best-effort: a processing import keeps running until the next checkpoint (typically every few hundred lines), at which point the worker notices the cancel flag and stops cleanly.

Required scope: imports:write

Request ​

bash
curl -X POST https://api.thehumind.com/public/v1/imports/b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b/cancel \
  -H "Authorization: Bearer hmd_live_..." \
  -H "Idempotency-Key: 9b8a7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d"

Response 200 OK ​

json
{
  "status": "cancelled"
}

Use GET /imports/{sync_id} afterward for the full state including completed_at and counters.

Products that were already created or updated before the cancel point stay in your catalog. The cancel doesn't roll back. If you need to undo, push a corrected import or use the synchronous PATCH/DELETE endpoints to fix specific entries.

Cancelling a done, failed, or already cancelled import returns 422 with code import_not_pending.


Error log entries ​

Each entry in error_logs describes one line from the NDJSON file that didn't make it into the catalog.

FieldTypeDescription
messagestringHuman-readable summary of the failure including the line number, e.g. Validation failed on line 3: Required or Invalid JSON on line 4: Expected property name…. The string is stable enough to grep for the broad class (Validation failed, Invalid JSON) but is not the machine error code. For per-field debug, replay the offending line through POST /products.
product_idstringThe external_id from the offending line, when it could be parsed. Omitted entirely when the line was malformed JSON (the message itself includes the line number, e.g. Invalid JSON on line 1042: …).
timestampISO 8601When the worker saw the failure.

The list is capped at the last 100 failures to keep the response payload bounded. The report.failed counter remains accurate regardless. If you need to debug an import with thousands of failures, fix the most common error in your source data, re-export, and run a fresh import.

Debugging a failed line ​

  1. Look at message to identify the error class (most often validation_failed).
  2. Use product_id to find the offending entry in your source export.
  3. Try the same payload through POST /products to get the full validation feedback in the response, the synchronous endpoint returns the precise field path in error.details, which the import log abbreviates.
  4. Fix the source data and re-run the import.

Limits and quotas ​

LimitValueNotes
Upload URL lifetime1 hourAfter expires_at, the SAS token stops accepting PUT requests. Re-create the import to get a fresh URL.
Lines per fileNo hard cap todayFiles over 1,000,000 lines may take a long time to ingest; consider splitting into multiple imports if you're past that range.
Imports per hour6 per company and environmentRolling hour, counted at POST /imports. Live and test keys have separate budgets. Beyond that, 429 with code import_quota_exceeded and a Retry-After header.
Concurrent imports per companyOne at a timestart returns 409 with code import_in_progress while another import or feed pull of the same catalog is processing. The import stays pending; start it again once the other one is done.
Resource typesproduct onlyMore resource types may be added later.
File formatNDJSON onlyCSV and JSON-array formats are not supported and not currently planned.

Webhooks ​

Skip polling with webhooks

Subscribe to the import.completed and import.failed events on a webhook endpoint and Humind will POST you the final status payload as soon as the import reaches a terminal state. The body mirrors the Import object, so you can drop your polling loop entirely. See Webhooks › Available events.


Test mode ​

Imports inherit the test/live mode of the API key that created them. If your key is hmd_test_*, every product ingested by the import is isolated from your live catalog. A live import (hmd_live_*) cannot create or modify test products and vice versa. See Authentication for the full test-mode model.


Common errors ​

The Imports API can return any of the standard HTTP statuses, but these are the codes specific to this resource:

StatusCodeWhenFix
403insufficient_scopeKey lacks imports:write.Create a new key with imports:write.
404not_foundThe {sync_id} doesn't match an import owned by your company.Confirm the sync_id. Cross-tenant lookups also return 404.
422import_not_pendingstart or cancel was called on an import that's not in the right state (e.g. start on a processing/done/failed/cancelled import).Check status first. Each lifecycle transition is one-way.
422import_blob_missingstart was called before the NDJSON file was uploaded to upload_url.PUT the blob first, then call start.
409import_in_progressstart was called while another import or feed pull of the same catalog is still processing.Wait for the import named in details.running_sync_id to finish (poll it or use the webhooks), then call start again.
429import_quota_exceededPOST /imports was called after 6 imports in the last hour (same company, same environment).Wait Retry-After seconds. To keep the catalog fresh, send only the changed products with POST /products/batch or PATCH /products/{id}.
400validation_failedThe body of POST /imports was missing resource_type or used an unsupported value.Fix the body and resend.

Product feed URL ​

No code on your side? Instead of calling the four endpoints above, give Humind the HTTPS URL of a product feed and let it pull it. The feed can be the NDJSON file described above, or the feed you already export for other channels, as is: CSV or TSV, XML (Google Shopping, Lengow, RSS), JSON, or NDJSON in another schema.

  1. In the dashboard, open Settings > Catalog > Catalog import, then Product feed.
  2. Paste the URL of your feed (a token in the query string is fine) and click Test the feed. Humind reads the first 10 MB (up to 5,000 products), detects the format and builds the field mapping: which Humind field each field of your feed fills (identifier, title, price, sale price and period, images, categories, stock, GTIN, color, size, other attributes). Nothing is imported or saved during the test.
  3. Check the result: products converted, products that would be rejected and why, required and recommended fields missing, and recommendations that each list the products concerned. You can change any line of the mapping and test again, or click Rebuild automatically.
  4. Pick a refresh frequency (every 1, 2, 3, 4, 6, 8, 12 or 24 hours) and save. The first pull starts immediately; the page shows the pull status and the last import's report (created, updated, failed, error log). Import now forces a pull between two scheduled runs.

A feed in another format is converted with its saved mapping at every pull. Variants are grouped by the group field of the feed (item_group_id in Google Shopping) or read from a nested list in JSON.

Each pull is a regular import: same validation, same upsert by external_id, same import.completed / import.failed webhooks. Rules:

  • HTTPS only, public internet host (private or local addresses are rejected). Redirects are followed (up to 3).
  • The response body must be a product feed: NDJSON, CSV or TSV, XML or JSON, encoded in UTF-8 or Windows-1252. HTML pages (a login page, for example) and empty files are rejected before anything is imported.
  • Maximum file size: 200 MB. Maximum download time: 5 minutes (headers and body).
  • One import at a time per feed: if the previous file is still being processed when the next pull is due, that pull is skipped.
  • Pull-side failures (unreachable URL, HTTP 4xx/5xx, wrong format, a column of the mapping missing from the file) are shown on the page and do not retry until the next scheduled pull. Per-line validation errors are reported like any import.

The feed and the API write to the same catalog; you can mix them (feed for the full catalog, POST /products from a webhook for urgent updates).

Next ​

Released under the proprietary Humind license.