curl --request POST \
--url https://www.closient.com/retailers/api/v1/retailers/{retailer_id}/stores/batch \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"dry_run": true,
"phone_region": "US",
"proximity_m": 100,
"rows": [
{
"address_line_1": "1250 Main St",
"city": "Springfield",
"country": "US",
"first_party": true,
"lat": 39.7817,
"lon": -89.6501,
"name": "Ulta Beauty",
"postal_code": "62704",
"region": "IL",
"source": "ulta_site",
"store_number": "595"
}
]
}
'import requests
url = "https://www.closient.com/retailers/api/v1/retailers/{retailer_id}/stores/batch"
payload = {
"dry_run": True,
"phone_region": "US",
"proximity_m": 100,
"rows": [
{
"address_line_1": "1250 Main St",
"city": "Springfield",
"country": "US",
"first_party": True,
"lat": 39.7817,
"lon": -89.6501,
"name": "Ulta Beauty",
"postal_code": "62704",
"region": "IL",
"source": "ulta_site",
"store_number": "595"
}
]
}
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({
dry_run: true,
phone_region: 'US',
proximity_m: 100,
rows: [
{
address_line_1: '1250 Main St',
city: 'Springfield',
country: 'US',
first_party: true,
lat: 39.7817,
lon: -89.6501,
name: 'Ulta Beauty',
postal_code: '62704',
region: 'IL',
source: 'ulta_site',
store_number: '595'
}
]
})
};
fetch('https://www.closient.com/retailers/api/v1/retailers/{retailer_id}/stores/batch', 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/retailers/api/v1/retailers/{retailer_id}/stores/batch",
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([
'dry_run' => true,
'phone_region' => 'US',
'proximity_m' => 100,
'rows' => [
[
'address_line_1' => '1250 Main St',
'city' => 'Springfield',
'country' => 'US',
'first_party' => true,
'lat' => 39.7817,
'lon' => -89.6501,
'name' => 'Ulta Beauty',
'postal_code' => '62704',
'region' => 'IL',
'source' => 'ulta_site',
'store_number' => '595'
]
]
]),
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/retailers/api/v1/retailers/{retailer_id}/stores/batch"
payload := strings.NewReader("{\n \"dry_run\": true,\n \"phone_region\": \"US\",\n \"proximity_m\": 100,\n \"rows\": [\n {\n \"address_line_1\": \"1250 Main St\",\n \"city\": \"Springfield\",\n \"country\": \"US\",\n \"first_party\": true,\n \"lat\": 39.7817,\n \"lon\": -89.6501,\n \"name\": \"Ulta Beauty\",\n \"postal_code\": \"62704\",\n \"region\": \"IL\",\n \"source\": \"ulta_site\",\n \"store_number\": \"595\"\n }\n ]\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/retailers/api/v1/retailers/{retailer_id}/stores/batch")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"dry_run\": true,\n \"phone_region\": \"US\",\n \"proximity_m\": 100,\n \"rows\": [\n {\n \"address_line_1\": \"1250 Main St\",\n \"city\": \"Springfield\",\n \"country\": \"US\",\n \"first_party\": true,\n \"lat\": 39.7817,\n \"lon\": -89.6501,\n \"name\": \"Ulta Beauty\",\n \"postal_code\": \"62704\",\n \"region\": \"IL\",\n \"source\": \"ulta_site\",\n \"store_number\": \"595\"\n }\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://www.closient.com/retailers/api/v1/retailers/{retailer_id}/stores/batch")
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 \"dry_run\": true,\n \"phone_region\": \"US\",\n \"proximity_m\": 100,\n \"rows\": [\n {\n \"address_line_1\": \"1250 Main St\",\n \"city\": \"Springfield\",\n \"country\": \"US\",\n \"first_party\": true,\n \"lat\": 39.7817,\n \"lon\": -89.6501,\n \"name\": \"Ulta Beauty\",\n \"postal_code\": \"62704\",\n \"region\": \"IL\",\n \"source\": \"ulta_site\",\n \"store_number\": \"595\"\n }\n ]\n}"
response = http.request(request)
puts response.read_body{
"committed": false,
"coverage_after": {
"stores": 1622,
"with_phone": 3,
"with_point": 1622
},
"created": 1,
"enriched": 2,
"errors": 0,
"existing_stores": 1621,
"fields_superseded": 4,
"legacy_keys_preserved": 2,
"matched_address": 0,
"matched_proximity": 2,
"matched_store_number": 0,
"near_misses": 1,
"not_created": 0,
"phones_written": 3,
"rekey_skipped_conflict": 0,
"row_errors": [],
"rows": [
{
"conflict": "",
"distance_m": 18.4,
"error": "",
"index": 0,
"match_method": "proximity",
"outcome": "enriched",
"store_number": "595"
}
],
"submitted_rows": 3,
"unchanged": 0,
"unmatched_existing": 1619,
"urls_written": 3
}{
"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"
}Upsert physical stores in bulk
Reconcile and apply up to 1,000 store rows for one retailer, returning a per-row result.
Rows are reconciled, not inserted. Each row is tied to an existing store by exact store number, then by proximity within proximity_m, then by normalised street plus postal code — one-to-one, highest-confidence tier first. A match enriches the existing store in place; only a row that matches nothing creates one. This is why the endpoint is safe to point at a full dataset that overlaps rows already in the database: an insert-only surface would plant a duplicate beside every store whose recorded key predates the retailer’s own numbering.
Nothing is ever merged, deactivated or deleted here. An existing store no row claims is counted in unmatched_existing and left exactly as it is; a pair the matcher finds contradictory is reported in the row’s conflict and left unrekeyed.
dry_run defaults to true: every path executes against real rows inside a transaction that is then rolled back, so the counts and per-row outcomes are the matcher’s real verdict rather than an estimate, and a first call writes nothing. Send dry_run: false to commit.
retailer_id is a retailers.Retailer. An organization-private retailer requires OWNER or MANAGER on the organization that owns it; a canonical (Closient-curated) retailer requires a staff account, because those rows were previously writable only from a management command. The organization this credential acts for becomes the owning organization of any location record created.
Returns 200 with a per-row error report even on partial success — only request-level failures produce 4xx.
curl --request POST \
--url https://www.closient.com/retailers/api/v1/retailers/{retailer_id}/stores/batch \
--header 'Content-Type: application/json' \
--header 'X-API-Key: <api-key>' \
--data '
{
"dry_run": true,
"phone_region": "US",
"proximity_m": 100,
"rows": [
{
"address_line_1": "1250 Main St",
"city": "Springfield",
"country": "US",
"first_party": true,
"lat": 39.7817,
"lon": -89.6501,
"name": "Ulta Beauty",
"postal_code": "62704",
"region": "IL",
"source": "ulta_site",
"store_number": "595"
}
]
}
'import requests
url = "https://www.closient.com/retailers/api/v1/retailers/{retailer_id}/stores/batch"
payload = {
"dry_run": True,
"phone_region": "US",
"proximity_m": 100,
"rows": [
{
"address_line_1": "1250 Main St",
"city": "Springfield",
"country": "US",
"first_party": True,
"lat": 39.7817,
"lon": -89.6501,
"name": "Ulta Beauty",
"postal_code": "62704",
"region": "IL",
"source": "ulta_site",
"store_number": "595"
}
]
}
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({
dry_run: true,
phone_region: 'US',
proximity_m: 100,
rows: [
{
address_line_1: '1250 Main St',
city: 'Springfield',
country: 'US',
first_party: true,
lat: 39.7817,
lon: -89.6501,
name: 'Ulta Beauty',
postal_code: '62704',
region: 'IL',
source: 'ulta_site',
store_number: '595'
}
]
})
};
fetch('https://www.closient.com/retailers/api/v1/retailers/{retailer_id}/stores/batch', 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/retailers/api/v1/retailers/{retailer_id}/stores/batch",
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([
'dry_run' => true,
'phone_region' => 'US',
'proximity_m' => 100,
'rows' => [
[
'address_line_1' => '1250 Main St',
'city' => 'Springfield',
'country' => 'US',
'first_party' => true,
'lat' => 39.7817,
'lon' => -89.6501,
'name' => 'Ulta Beauty',
'postal_code' => '62704',
'region' => 'IL',
'source' => 'ulta_site',
'store_number' => '595'
]
]
]),
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/retailers/api/v1/retailers/{retailer_id}/stores/batch"
payload := strings.NewReader("{\n \"dry_run\": true,\n \"phone_region\": \"US\",\n \"proximity_m\": 100,\n \"rows\": [\n {\n \"address_line_1\": \"1250 Main St\",\n \"city\": \"Springfield\",\n \"country\": \"US\",\n \"first_party\": true,\n \"lat\": 39.7817,\n \"lon\": -89.6501,\n \"name\": \"Ulta Beauty\",\n \"postal_code\": \"62704\",\n \"region\": \"IL\",\n \"source\": \"ulta_site\",\n \"store_number\": \"595\"\n }\n ]\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/retailers/api/v1/retailers/{retailer_id}/stores/batch")
.header("X-API-Key", "<api-key>")
.header("Content-Type", "application/json")
.body("{\n \"dry_run\": true,\n \"phone_region\": \"US\",\n \"proximity_m\": 100,\n \"rows\": [\n {\n \"address_line_1\": \"1250 Main St\",\n \"city\": \"Springfield\",\n \"country\": \"US\",\n \"first_party\": true,\n \"lat\": 39.7817,\n \"lon\": -89.6501,\n \"name\": \"Ulta Beauty\",\n \"postal_code\": \"62704\",\n \"region\": \"IL\",\n \"source\": \"ulta_site\",\n \"store_number\": \"595\"\n }\n ]\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://www.closient.com/retailers/api/v1/retailers/{retailer_id}/stores/batch")
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 \"dry_run\": true,\n \"phone_region\": \"US\",\n \"proximity_m\": 100,\n \"rows\": [\n {\n \"address_line_1\": \"1250 Main St\",\n \"city\": \"Springfield\",\n \"country\": \"US\",\n \"first_party\": true,\n \"lat\": 39.7817,\n \"lon\": -89.6501,\n \"name\": \"Ulta Beauty\",\n \"postal_code\": \"62704\",\n \"region\": \"IL\",\n \"source\": \"ulta_site\",\n \"store_number\": \"595\"\n }\n ]\n}"
response = http.request(request)
puts response.read_body{
"committed": false,
"coverage_after": {
"stores": 1622,
"with_phone": 3,
"with_point": 1622
},
"created": 1,
"enriched": 2,
"errors": 0,
"existing_stores": 1621,
"fields_superseded": 4,
"legacy_keys_preserved": 2,
"matched_address": 0,
"matched_proximity": 2,
"matched_store_number": 0,
"near_misses": 1,
"not_created": 0,
"phones_written": 3,
"rekey_skipped_conflict": 0,
"row_errors": [],
"rows": [
{
"conflict": "",
"distance_m": 18.4,
"error": "",
"index": 0,
"match_method": "proximity",
"outcome": "enriched",
"store_number": "595"
}
],
"submitted_rows": 3,
"unchanged": 0,
"unmatched_existing": 1619,
"urls_written": 3
}{
"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
Path Parameters
Unique identifier of the retailer these stores belong to.
22^[23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{22}$Body
A batch of store rows plus the reconciliation knobs for the call.
Store rows to reconcile, at most 1,000 per call. Results come back in request order, each carrying its index. Matching is one-to-one within a call, so a store claimed by an earlier call is re-matched on the store-number tier in the next one — which is what makes repeating a batch idempotent rather than duplicative.
1 - 1000 elementsShow child attributes
Show child attributes
When true (the default) every path runs against real rows and the transaction is rolled back, so the results are the matcher's real verdict and nothing is written. Send false to commit.
Match radius in metres for the proximity tier (default 100). Capped at 500 m: pairs beyond the match radius but within it are reported as near_misses for a human to judge, and widening the radius to force a match is exactly what the tiered matcher exists to avoid.
x <= 500ISO 3166-1 alpha-2 region used to parse national phone numbers into E.164. An unassigned code parses nothing, so every phone in the batch would be counted invalid rather than written — silently, since a bad parse is a counter and not an error.
2^[A-Za-z]{2}$Response
OK
Batch census plus the per-row results.
created + enriched + unchanged + not_created + errors equals the
number of submitted rows for any 200 response. The endpoint returns 200
even when individual rows fail: a per-row failure is reported here rather
than aborting the batch, so a single malformed row cannot cost the other
999. Request-level failures (auth, an unwritable retailer, a batch over
the row cap) are 4xx instead.
False when this was a dry run — every path ran and the transaction was rolled back.
Number of rows in the request.
x >= 0Stores this retailer already had, i.e. the size of the candidate match set.
x >= 0Rows that produced a new store.
x >= 0Rows that matched and changed something.
x >= 0Rows that matched and needed no change.
x >= 0Rows that matched nothing and were not allowed to create a store.
x >= 0Rows that raised. Equals the length of row_errors.
x >= 0Rows matched on the exact store-number tier.
x >= 0Rows matched on the proximity tier.
x >= 0Rows matched on the normalised-address tier.
x >= 0Pairs beyond proximity_m but within 500 m that were deliberately NOT matched. A judgement call for a human, never auto-matched by widening the radius.
x >= 0Existing stores no submitted row claimed. For a full-dataset run these are stores the retailer's own list no longer carries, i.e. probably closed — reported, never deactivated or deleted.
x >= 0Phone numbers written or refreshed.
x >= 0Store URLs written or refreshed.
x >= 0Existing non-placeholder values a first-party row overwrote. The previous value is preserved under Place.source_extras['superseded'] rather than discarded.
x >= 0Stores whose pre-existing store number was replaced by this source's real one, with the old key kept in Place.source_extras['legacy_store_key'].
x >= 0Stores left on their existing key because the evidence for re-keying was contradictory.
x >= 0Per-column coverage for this retailer's stores after the call — stores, with_point, with_real_address, with_phone, with_url, with_source_extras. On a dry run these are the post-rollback figures the run would have produced, measured inside the transaction.
Show child attributes
Show child attributes
One result per submitted row, in request order.
Show child attributes
Show child attributes
Batch-level restatement of every row that raised, for callers that read only the census.