curl --request POST \
--url https://www.closient.com/products/api/v1/import/retailer-catalog/jobs \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"batch_size": 500,
"dry_run": true,
"fetched_at": "2025-12-01T00:00:00Z",
"reclaim_unattributed": true,
"skip_ingredients": false,
"source": "ulta_catalog",
"source_file": "ulta_catalog_2025-12.psv"
}
'import requests
url = "https://www.closient.com/products/api/v1/import/retailer-catalog/jobs"
payload = {
"batch_size": 500,
"dry_run": True,
"fetched_at": "2025-12-01T00:00:00Z",
"reclaim_unattributed": True,
"skip_ingredients": False,
"source": "ulta_catalog",
"source_file": "ulta_catalog_2025-12.psv"
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
batch_size: 500,
dry_run: true,
fetched_at: '2025-12-01T00:00:00Z',
reclaim_unattributed: true,
skip_ingredients: false,
source: 'ulta_catalog',
source_file: 'ulta_catalog_2025-12.psv'
})
};
fetch('https://www.closient.com/products/api/v1/import/retailer-catalog/jobs', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://www.closient.com/products/api/v1/import/retailer-catalog/jobs",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'batch_size' => 500,
'dry_run' => true,
'fetched_at' => '2025-12-01T00:00:00Z',
'reclaim_unattributed' => true,
'skip_ingredients' => false,
'source' => 'ulta_catalog',
'source_file' => 'ulta_catalog_2025-12.psv'
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://www.closient.com/products/api/v1/import/retailer-catalog/jobs"
payload := strings.NewReader("{\n \"batch_size\": 500,\n \"dry_run\": true,\n \"fetched_at\": \"2025-12-01T00:00:00Z\",\n \"reclaim_unattributed\": true,\n \"skip_ingredients\": false,\n \"source\": \"ulta_catalog\",\n \"source_file\": \"ulta_catalog_2025-12.psv\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://www.closient.com/products/api/v1/import/retailer-catalog/jobs")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"batch_size\": 500,\n \"dry_run\": true,\n \"fetched_at\": \"2025-12-01T00:00:00Z\",\n \"reclaim_unattributed\": true,\n \"skip_ingredients\": false,\n \"source\": \"ulta_catalog\",\n \"source_file\": \"ulta_catalog_2025-12.psv\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://www.closient.com/products/api/v1/import/retailer-catalog/jobs")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"batch_size\": 500,\n \"dry_run\": true,\n \"fetched_at\": \"2025-12-01T00:00:00Z\",\n \"reclaim_unattributed\": true,\n \"skip_ingredients\": false,\n \"source\": \"ulta_catalog\",\n \"source_file\": \"ulta_catalog_2025-12.psv\"\n}"
response = http.request(request)
puts response.read_body{
"chunk_rows": 10000,
"chunks_done": 25,
"chunks_total": 25,
"completed_at": "2026-09-11T07:41:00Z",
"dry_run": true,
"job_id": "b2c3d4e5-f678-9012-abcd-ef2345678901",
"poll_url": "/products/api/v1/import/retailer-catalog/jobs/b2c3d4e5-f678-9012-abcd-ef2345678901",
"processed_rows": 248566,
"reclaim_unattributed": true,
"result": {
"dual_listed": 14300,
"listings_created": 247566,
"products_created": 233241,
"products_enriched": 14300,
"quarantine": [
{
"detail": "net_content is 117 characters, limit 100",
"raw_gtin": "3378872412345",
"reason": "field_too_long",
"row_number": 18342,
"source_reference": "2598765"
}
],
"quarantine_reasons": {
"field_too_long": 1
},
"quarantined": 1,
"rows_read": 248566
},
"source": "ulta_catalog",
"source_file": "ulta_catalog_2025-12.psv",
"staged_rows": 248566,
"started_at": "2026-09-11T07:10:00Z",
"status": "completed"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}Open a retailer catalog import job
Open a job for one catalog source and return its id. The job starts in staging: stage rows with POST .../jobs/{job_id}/rows, then run it with POST .../jobs/{job_id}/start.
The run merges rows into the shared catalog GTIN-first — one product per GTIN across every retailer, one listing per retailer that carries it, and per-field provenance recording which retailer supplied what. A populated field is displaced only by a strictly higher measured confidence, which is what makes a rerun of the same feed inert and keeps the two feeds’ disagreements resolved the same way whatever order they run in.
dry_run defaults to true. Send dry_run: false to commit.
curl --request POST \
--url https://www.closient.com/products/api/v1/import/retailer-catalog/jobs \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"batch_size": 500,
"dry_run": true,
"fetched_at": "2025-12-01T00:00:00Z",
"reclaim_unattributed": true,
"skip_ingredients": false,
"source": "ulta_catalog",
"source_file": "ulta_catalog_2025-12.psv"
}
'import requests
url = "https://www.closient.com/products/api/v1/import/retailer-catalog/jobs"
payload = {
"batch_size": 500,
"dry_run": True,
"fetched_at": "2025-12-01T00:00:00Z",
"reclaim_unattributed": True,
"skip_ingredients": False,
"source": "ulta_catalog",
"source_file": "ulta_catalog_2025-12.psv"
}
headers = {
"X-API-Key": "<api-key>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'X-API-Key': '<api-key>', 'Content-Type': 'application/json'},
body: JSON.stringify({
batch_size: 500,
dry_run: true,
fetched_at: '2025-12-01T00:00:00Z',
reclaim_unattributed: true,
skip_ingredients: false,
source: 'ulta_catalog',
source_file: 'ulta_catalog_2025-12.psv'
})
};
fetch('https://www.closient.com/products/api/v1/import/retailer-catalog/jobs', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://www.closient.com/products/api/v1/import/retailer-catalog/jobs",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'batch_size' => 500,
'dry_run' => true,
'fetched_at' => '2025-12-01T00:00:00Z',
'reclaim_unattributed' => true,
'skip_ingredients' => false,
'source' => 'ulta_catalog',
'source_file' => 'ulta_catalog_2025-12.psv'
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-API-Key: <api-key>"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://www.closient.com/products/api/v1/import/retailer-catalog/jobs"
payload := strings.NewReader("{\n \"batch_size\": 500,\n \"dry_run\": true,\n \"fetched_at\": \"2025-12-01T00:00:00Z\",\n \"reclaim_unattributed\": true,\n \"skip_ingredients\": false,\n \"source\": \"ulta_catalog\",\n \"source_file\": \"ulta_catalog_2025-12.psv\"\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("X-API-Key", "<api-key>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://www.closient.com/products/api/v1/import/retailer-catalog/jobs")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"batch_size\": 500,\n \"dry_run\": true,\n \"fetched_at\": \"2025-12-01T00:00:00Z\",\n \"reclaim_unattributed\": true,\n \"skip_ingredients\": false,\n \"source\": \"ulta_catalog\",\n \"source_file\": \"ulta_catalog_2025-12.psv\"\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://www.closient.com/products/api/v1/import/retailer-catalog/jobs")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["X-API-Key"] = '<api-key>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"batch_size\": 500,\n \"dry_run\": true,\n \"fetched_at\": \"2025-12-01T00:00:00Z\",\n \"reclaim_unattributed\": true,\n \"skip_ingredients\": false,\n \"source\": \"ulta_catalog\",\n \"source_file\": \"ulta_catalog_2025-12.psv\"\n}"
response = http.request(request)
puts response.read_body{
"chunk_rows": 10000,
"chunks_done": 25,
"chunks_total": 25,
"completed_at": "2026-09-11T07:41:00Z",
"dry_run": true,
"job_id": "b2c3d4e5-f678-9012-abcd-ef2345678901",
"poll_url": "/products/api/v1/import/retailer-catalog/jobs/b2c3d4e5-f678-9012-abcd-ef2345678901",
"processed_rows": 248566,
"reclaim_unattributed": true,
"result": {
"dual_listed": 14300,
"listings_created": 247566,
"products_created": 233241,
"products_enriched": 14300,
"quarantine": [
{
"detail": "net_content is 117 characters, limit 100",
"raw_gtin": "3378872412345",
"reason": "field_too_long",
"row_number": 18342,
"source_reference": "2598765"
}
],
"quarantine_reasons": {
"field_too_long": 1
},
"quarantined": 1,
"rows_read": 248566
},
"source": "ulta_catalog",
"source_file": "ulta_catalog_2025-12.psv",
"staged_rows": 248566,
"started_at": "2026-09-11T07:10:00Z",
"status": "completed"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}{
"detail": "The requested resource was not found.",
"error_code": "not_found",
"retryable": false,
"status": 404,
"timestamp": "2026-03-31T12:00:00+00:00",
"title": "Not Found",
"type": "https://closient.com/docs/errors/not_found"
}Authorizations
Body
Flags for one catalog import run.
Catalog source this run merges under. One of: sephora_catalog, ulta_catalog. Not free text — the source decides the canonical retailer, its storefront, and the measured per-field confidence table that resolves every contested field, so an unregistered value is rejected with 422 rather than guessed at. The registered set is asserted against the engine's own registry in the C-5966 tests.
ulta_catalog, sephora_catalog Name of the feed drop these rows came from. Recorded as listing provenance, so a listing can be traced to the file it came from.
255When the feed drop was fetched — provenance about the data, not the run. Send the drop's own timestamp; omitting it stamps the run's start time, which misdates a feed imported weeks after it was published.
Run the real merge and roll back each batch. Default. The counts are directly comparable with a committed run's, so this is a real rehearsal rather than an estimate. Note that quarantine rows roll back too and are reported from the run's in-memory record instead.
Allow this run to claim fields on products that carry no recorded provenance — rows from the pre-C-4096 importers, which overwrote rather than enriched and left no field_sources. Restricted to products still marked imported/seeded, so a brand-claimed product or one sourced from a crawl is never touched. On by default (C-5960); a dry run reports exactly how many fields it would claim, and the flag can still be set to false for a specific run.
Skip ingredient-list parsing, the slowest part of a run.
Rows per transaction window inside the engine. Each window costs a fixed handful of queries regardless of size, so the default rarely needs changing. It is also the unit a run resumes from after an interruption, so a smaller window loses less work and costs more checkpoint writes.
1 <= x <= 5000Staged rows merged by one background chunk task. The run is a sequential chain of these. Omit it and the size is chosen from dry_run: 10,000 for a dry run, 2,000 for a commit. The two modes are not the same amount of work per row — a commit writes every row and parses every ingredient label inline, measured at 14-25 rows/s against a dry run's ~103 — and sizing both from the dry run is what killed the first full-feed commit run. Send a value only if you have measured your own throughput; the maximum is 12,000.
1 <= x <= 12000Response
OK
A job's current state. The same shape from open, append, start and poll.
Identifier of the job. Use it on the row, start and poll calls.
22^[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{22}$staging — accepting rows; the run has not started. queued — start accepted, waiting for a worker. running — the merge is executing; chunks_done and result advance as it goes. completed — finished; result carries the whole feed's census. failed — the run gave up; error says why, result carries the census of the work that did finish, and POST .../start resumes from where it stopped.
Catalog source this run merges under.
Whether this run rolls back each batch.
Whether this run may claim unattributed fields.
Rows accepted into the job so far.
x >= 0Staged rows merged by one chunk task. The run is a sequential chain of these, so no single task approaches the worker's time limit. Set at open time from dry_run unless you named one: 10,000 for a dry run, 2,000 for a commit, because a commit does an order of magnitude more work per row.
x >= 1Chunks this job's staged rows divide into. Zero until rows are staged.
x >= 0Chunks fully merged and recorded. A resumed run continues from here, so these rows are never merged twice.
x >= 0Staged rows already merged. A floor, not an estimate: rows counted here are recorded and survive a restart. It advances within a chunk as well as between chunks — a chunk checkpoints every batch_size rows — so it keeps moving during the minutes a commit chunk takes, and is the field to watch to tell a slow run from a stopped one.
x >= 0Path to poll for this job's status and result.
Feed drop name recorded as listing provenance.
When the run began. Null before it starts.
When the run reached a terminal state.
Why the run failed. Null unless status is failed.
The engine's census. Empty until the first chunk finishes, then the running total for the chunks merged so far, and the whole feed's figures once status is completed — so read status, not this field, to decide whether a run is done. Carries rows_read, products_created / products_enriched / products_unchanged, listings_created / listings_updated, dual_listed (products another retailer's feed had already created — the cross-retailer overlap), duplicate_gtins_in_feed, quarantined with quarantine_reasons, brand and ingredient counts, field_decisions (per field, how each value was decided), reclaimable_fields, and quarantine — the refused rows themselves, capped at 500 entries with quarantined remaining the true total. errors is capped the same way, with errors_total as its true count. census is the same figures as human-readable lines.