Batch Upsert
Create or update up to 500 commerces in a single operation, with their addresses and users, and per-item result reporting.
POST /api/integration/commerces/batch-upsert
Creates or updates (upsert) up to 500 commerces in a single operation, including their addresses and users. It is the integration entry point for loading the commerce catalog from an external system (ERP, CRM).
Auth: Required — commerce:batch-upsert permission
Partial success. Once the body passes format validation, each item is
processed independently: the ones that can be persisted are, and the ones that
fail are reported in errors[] with a stable code — a failing item does not
undo the others. Always check summary.failed and errors in the response.
Upsert key
Each item is matched against existing commerces by external_id. If a commerce with that external_id exists it is updated (counted in summary.updated); otherwise it is created (summary.created). On update, metadata is merged with the existing one: keys sent overwrite keys with the same name, keys not sent are kept, and sending a key with null removes it. The merge is recursive (RFC 7386 JSON Merge Patch): nested objects are merged level by level, so you can update one key inside a nested object without resending its siblings. Any other value replaces the previous one wholesale — arrays included, which are never merged element by element. Sending metadata: null empties the whole bag.
tax_id cannot be shared between commerces of the tenant. If the tax_id sent belongs to another commerce that already existed before the batch, or to an earlier item of the same batch that did not fail, the whole item goes to errors and nothing of it is written — resending a commerce its own tax_id is not a conflict. Freeing a tax_id in one item does not release it for a later item of the same request: it becomes available on the next one. Sending tax_id: "" is equivalent to null and clears the stored value, while omitting the field keeps it.
Address matching
An address is not identified by its position in the array. Each element of addresses is matched against the addresses the commerce already has, and it is only created when nothing matches.
- With
external_id— the match is exact: the address of that commerce carrying the sameexternal_id. If no address carries it, a new address is created and the heuristic below is not tried. - Without
external_id(absent,nullor"") — two heuristics are tried in order: first an address with the sameformatted_address, attempted only if the item sendsformatted_address; then an address with the samestreet,street_numberandcommune, attempted only if the item sends all three.
When nothing matches, the address is created. Sending external_id on every address is how you keep a stable identity between loads instead of depending on the heuristic.
Primary rows
is_primary is deprecated and ignored. It is still accepted on addresses
and contacts so callers that already send it do not break, but the value has
no effect: the platform decides which row is primary.
An existing address or contact never changes its is_primary through this endpoint. A new row is born primary only when its group has no active row yet, and only the first new row of that group within the item — the group is the commerce for addresses, and the pair user + contact_type for contacts. "First" follows the order of the array in the payload.
Platform-owned rows
Rows with source: RETEN were entered from the Reten platform, and a bulk load has no authority over them. An address matched against a RETEN one keeps every column unchanged, and no duplicate is created for it. For a user with source: RETEN, name is left untouched — its roles and its contacts are still merged. Every address, user and contact this endpoint creates is written with source: INTEGRATION and is updated normally on later loads.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
items | object[] | Yes | List of commerces to write (minimum 1, maximum 500). |
Each element of items:
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | Yes | Identifier of the commerce in the external system. Upsert key. |
name | string | Yes | Commerce name. |
global_external_id | string | No | Additional external identifier of the commerce. Not used for matching. |
tax_id | string | No | Tax identifier. Must not be in use by another commerce of the tenant. "" is equivalent to null and clears it. |
metadata | object | No | Arbitrary JSON. Merged recursively with the existing metadata on update. |
addresses | object[] | No | Addresses of the commerce (maximum 20, see below). |
users | object[] | No | Users of the commerce (maximum 20, see below). |
Each element of addresses (every field is optional):
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | No | Identifier of the address in the external system. Match key — see Address matching. |
address_alias | string | No | Label for the address (e.g. "Main store"). |
is_primary | boolean | No | Deprecated — accepted but ignored. |
country_code | string | No | Country code. |
region | string | No | Region / state. |
commune | string | No | Commune / municipality. |
city | string | No | City. |
street | string | No | Street name. |
street_number | string | No | Street number. |
unit | string | No | Unit, floor or office. |
formatted_address | string | No | Full address as a single string. |
latitude | number | No | Latitude, between -90 and 90 inclusive. Out of range sends the whole item to errors. |
longitude | number | No | Longitude, between -180 and 180 inclusive. Out of range sends the whole item to errors. |
Each element of users:
| Field | Type | Required | Description |
|---|---|---|---|
external_id | string | Yes | Identifier of the user in the external system. |
name | string | No | User name. |
roles | enum[] | No | Roles of the user in the commerce (maximum 10): OWNER, ORDERS, FINANCE, ATTENTION, ADMIN, COMMERCE_REP, OTHER. |
contacts | object[] | No | Contacts of the user (maximum 20, see below). |
Each element of users[].contacts:
| Field | Type | Required | Description |
|---|---|---|---|
contact_type | enum | Yes | Contact type: PHONE, EMAIL, WHATSAPP or OTHER. |
value | string | Yes | Contact value. Only EMAIL is checked for format; PHONE, WHATSAPP and OTHER are stored as sent. |
is_primary | boolean | No | Deprecated — accepted but ignored. |
Example
curl -X POST "https://api.reten.ai/api/integration/commerces/batch-upsert" \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{
"external_id": "store-001",
"name": "Main Street Store",
"tax_id": "76123456-7",
"metadata": { "segment": "gold" },
"addresses": [
{
"external_id": "addr-001",
"address_alias": "Main store",
"country_code": "CL",
"region": "Región Metropolitana",
"commune": "Providencia",
"city": "Santiago",
"street": "Av. Providencia",
"street_number": "1234",
"latitude": -33.4489,
"longitude": -70.6693
}
],
"users": [
{
"external_id": "user-001",
"name": "María García",
"roles": ["OWNER"],
"contacts": [
{ "contact_type": "PHONE", "value": "+56912345678" },
{ "contact_type": "EMAIL", "value": "maria@example.com" }
]
}
]
},
{
"external_id": "store-002",
"name": "Downtown Store"
},
{
"external_id": "store-003",
"name": "Harbor Store",
"tax_id": "76123456-7"
}
]
}'const response = await fetch(
"https://api.reten.ai/api/integration/commerces/batch-upsert",
{
method: "POST",
headers: {
"x-api-key": "YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
items: [
{
external_id: "store-001",
name: "Main Street Store",
tax_id: "76123456-7",
metadata: { segment: "gold" },
addresses: [
{ external_id: "addr-001", address_alias: "Main store", country_code: "CL", region: "Región Metropolitana", commune: "Providencia", city: "Santiago", street: "Av. Providencia", street_number: "1234", latitude: -33.4489, longitude: -70.6693 },
],
users: [
{
external_id: "user-001",
name: "María García",
roles: ["OWNER"],
contacts: [
{ contact_type: "PHONE", value: "+56912345678" },
{ contact_type: "EMAIL", value: "maria@example.com" },
],
},
],
},
{ external_id: "store-002", name: "Downtown Store" },
{ external_id: "store-003", name: "Harbor Store", tax_id: "76123456-7" },
],
}),
},
);
const result = await response.json();import requests
response = requests.post(
"https://api.reten.ai/api/integration/commerces/batch-upsert",
headers={"x-api-key": "YOUR_API_KEY"},
json={
"items": [
{
"external_id": "store-001",
"name": "Main Street Store",
"tax_id": "76123456-7",
"metadata": {"segment": "gold"},
"addresses": [
{"external_id": "addr-001", "address_alias": "Main store", "country_code": "CL", "region": "Región Metropolitana", "commune": "Providencia", "city": "Santiago", "street": "Av. Providencia", "street_number": "1234", "latitude": -33.4489, "longitude": -70.6693},
],
"users": [
{
"external_id": "user-001",
"name": "María García",
"roles": ["OWNER"],
"contacts": [
{"contact_type": "PHONE", "value": "+56912345678"},
{"contact_type": "EMAIL", "value": "maria@example.com"},
],
},
],
},
{"external_id": "store-002", "name": "Downtown Store"},
{"external_id": "store-003", "name": "Harbor Store", "tax_id": "76123456-7"},
],
},
)
result = response.json()Response 200 OK
In this example, store-001 was created, store-002 already existed and was updated, and store-003 failed because its tax_id is already used by another commerce:
{
"summary": {
"total": 3,
"created": 1,
"updated": 1,
"failed": 1
},
"errors": [
{
"index": 2,
"identifier": "store-003",
"error": "upsert_error",
"message": "tax_id 76123456-7 already exists in commerce 5d2f8c1e-7b3a-4c9d-9e1f-2a3b4c5d6e7f"
}
]
}Response fields
| Field | Type | Description |
|---|---|---|
summary.total | number | Total items submitted. |
summary.created | number | Items inserted (new commerces). |
summary.updated | number | Items updated (existing commerces modified). |
summary.failed | number | Items that could not be persisted. |
errors | object[] | Detail per failed item (empty if all succeeded). |
errors[].index | number | Position (0-based) of the item within the submitted items array. |
errors[].identifier | string | The external_id of the failed item. |
errors[].error | string | Stable error code (see table below). |
errors[].message | string | Human/log-readable message with the cause. |
Per-item error codes
Within an item they are evaluated in this order: duplicate, then invalid EMAIL, then coordinates.
error | Meaning |
|---|---|
duplicate_in_batch | The external_id of the item already appeared in an earlier item of the same items array. The first occurrence is processed; every later one fails. |
invalid_contact_value | A contact of type EMAIL carries a value that is not an email address. The whole item fails — the commerce, its addresses and its users — while the rest of the batch is written. |
upsert_error | The item could not be persisted; message carries the cause (e.g. a coordinate out of range, or a tax_id already used by another commerce). |
Errors (for the whole request)
| Status | Description |
|---|---|
400 | items empty or with more than 500 elements; addresses or users with more than 20 elements, users[].roles with more than 10, or users[].contacts with more than 20; an item that does not match the format (missing required field, invalid type or enum value); or a property not declared here. No item is written. |
401 | Missing or invalid API key. |
403 | The API key lacks the commerce:batch-upsert permission. |