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 size | Frequency | Recommended endpoint |
|---|---|---|
| 1 product | One-off or webhook-driven | POST /products |
| Up to 500 products | Occasional bulk update | POST /products/batch |
| 500+ products, or any size on a recurring schedule | Daily or weekly full sync | POST /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.
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
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"
}'| Field | Type | Required | Description |
|---|---|---|---|
resource_type | 'product' | Yes | What kind of resource the file contains. Only product is supported today. |
format | 'ndjson' | Yes | The on-the-wire format. Only ndjson (newline-delimited JSON) is supported today. |
Response 201 Created
{
"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"
}| Field | Type | Description |
|---|---|---|
sync_id | string | The handle for this import. Use it in the path of every subsequent call. |
status | string | Always pending at this point, the file hasn't been uploaded yet. |
upload_url | string | The 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_at | ISO 8601 | When 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.
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","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"}]}Request
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/x-ndjson" \
-H "x-ms-blob-type: BlockBlob" \
--upload-file products.ndjsonThe 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_logsafter 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
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
{
"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.
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
curl https://api.thehumind.com/public/v1/imports/b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b \
-H "Authorization: Bearer hmd_live_..."Response 200 OK: while processing
{
"sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
"status": "processing",
"resource_type": "product",
"total_products": 12000,
"synced_products": 4321,
"report": {
"created": 3210,
"updated": 1100,
"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
{
"sync_id": "b5f9e3a2-7c1d-4e8a-9b3f-0c2d4e5f6a7b",
"status": "done",
"resource_type": "product",
"total_products": 12000,
"synced_products": 12000,
"report": {
"created": 9876,
"updated": 2100,
"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
| Field | Type | Description |
|---|---|---|
sync_id | string | Stable 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_products | integer | Number 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_products | integer | Number 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.created | integer | Number of products that didn't exist before and were inserted. |
report.updated | integer | Number of products that already existed (matched on external_id) and were updated in place. |
report.failed | integer | Number of lines that failed validation or upsert. This count is exact even if some entries are no longer present in error_logs. |
error_logs | object[] | The most recent failure entries. Capped at the last 100. See Error log entries. |
upload_url | string | Returned only by POST /imports. Not echoed by GET. |
expires_at | ISO 8601 | Upload URL expiry. Returned only by POST /imports. |
started_at | ISO 8601 | null | When start was called. null while pending. |
completed_at | ISO 8601 | null | When the import reached done, failed, or cancelled. null until then. |
created_at | ISO 8601 | When the import was created (call to POST /imports). |
State machine
pending ──start──▶ processing ──finish──▶ done
│ │
│ ├──fatal error──▶ failed
│ └──cancel──────▶ cancelled
└──cancel──▶ cancelledOnce 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.
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
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
{
"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.
| Field | Type | Description |
|---|---|---|
message | string | Human-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_id | string | The 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: …). |
timestamp | ISO 8601 | When 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
- Look at
messageto identify the error class (most oftenvalidation_failed). - Use
product_idto find the offending entry in your source export. - Try the same payload through
POST /productsto get the full validation feedback in the response, the synchronous endpoint returns the precise field path inerror.details, which the import log abbreviates. - Fix the source data and re-run the import.
Limits and quotas
| Limit | Value | Notes |
|---|---|---|
| Upload URL lifetime | 1 hour | After expires_at, the SAS token stops accepting PUT requests. Re-create the import to get a fresh URL. |
| Lines per file | No hard cap today | Files over 1,000,000 lines may take a long time to ingest; consider splitting into multiple imports if you're past that range. |
| Concurrent imports per company | No hard cap today | Queue your own imports if you want strict ordering. |
| Resource types | product only | More resource types may be added later. |
| File format | NDJSON only | CSV 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:
| Status | Code | When | Fix |
|---|---|---|---|
403 | insufficient_scope | Key lacks imports:write. | Create a new key with imports:write. |
404 | not_found | The {sync_id} doesn't match an import owned by your company. | Confirm the sync_id. Cross-tenant lookups also return 404. |
422 | import_not_pending | start 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. |
422 | import_blob_missing | start was called before the NDJSON file was uploaded to upload_url. | PUT the blob first, then call start. |
400 | validation_failed | The body of POST /imports was missing resource_type or used an unsupported value. | Fix the body and resend. |
Next
- Products: the product schema each NDJSON line must follow.
- Authentication: generate an
imports:writekey. - Errors: full error code reference.