Upsert por Lote
Crea o actualiza hasta 500 comercios en una sola operación, con sus direcciones y usuarios, y reporte de resultado por item.
POST /api/integration/commerces/batch-upsert
Crea o actualiza (upsert) hasta 500 comercios en una sola operación, incluyendo sus direcciones y usuarios. Es el punto de entrada de integración para cargar el catálogo de comercios desde un sistema externo (ERP, CRM).
Auth: Requerida — permiso commerce:batch-upsert
Éxito parcial. Una vez que el cuerpo pasa la validación de formato, cada
item se procesa de forma independiente: los que se pueden persistir se
persisten, y los que fallan se reportan en errors[] con un código estable —
un item que falla no deshace a los demás. Revisa siempre summary.failed y
errors en la respuesta.
Clave del upsert
Cada item se busca entre los comercios existentes por external_id. Si existe un comercio con ese external_id se actualiza (cuenta en summary.updated); si no, se crea (summary.created). En la actualización, metadata se fusiona con la existente: las claves enviadas sobrescriben las del mismo nombre, las no enviadas se conservan, y enviar una clave con null la elimina. La fusión es recursiva (RFC 7386 JSON Merge Patch): los objetos anidados se fusionan nivel por nivel, así podés actualizar una clave dentro de un objeto anidado sin reenviar sus hermanas. Cualquier otro valor reemplaza al anterior por completo, incluidos los arrays, que nunca se fusionan elemento a elemento. Enviar metadata: null vacía la bolsa entera.
El tax_id no se puede compartir entre comercios del tenant. Si el tax_id enviado pertenece a otro comercio que ya existía antes del lote, o a un item anterior del mismo lote que no falló, el item entero va a errors y no se escribe nada suyo — reenviarle a un comercio su propio tax_id no es conflicto. Liberar un tax_id en un item no lo libera para un item posterior de la misma petición: queda disponible recién en la siguiente. Enviar tax_id: "" equivale a null y borra el valor guardado, mientras que omitir el campo lo conserva.
Coincidencia de direcciones
Una dirección no se identifica por su posición en el array. Cada elemento de addresses se busca entre las direcciones que el comercio ya tiene, y sólo se crea cuando no coincide con ninguna.
- Con
external_id— la coincidencia es exacta: la dirección de ese comercio que lleva el mismoexternal_id. Si ninguna lo lleva, se crea una dirección nueva y no se prueba la heurística de abajo. - Sin
external_id(ausente,nullo"") — se prueban dos heurísticas en orden: primero una dirección con el mismoformatted_address, sólo si el item envíaformatted_address; después una dirección con los mismosstreet,street_numberycommune, sólo si el item envía los tres.
Cuando no coincide ninguna, la dirección se crea. Enviar external_id en cada dirección es la forma de mantener una identidad estable entre cargas en lugar de depender de la heurística.
Filas principales
is_primary está deprecado y se ignora. Se sigue aceptando en direcciones
y contactos para no romper a los callers que ya lo envían, pero el valor no
tiene efecto: la plataforma decide qué fila es la principal.
Una dirección o un contacto que ya existe nunca cambia su is_primary por este endpoint. Una fila nueva nace principal sólo cuando su grupo todavía no tiene ninguna fila activa, y sólo la primera fila nueva de ese grupo dentro del item — el grupo es el comercio para las direcciones, y el par usuario + contact_type para los contactos. "Primera" sigue el orden del array en el payload.
Filas de la plataforma
Las filas con source: RETEN se ingresaron desde la plataforma de Reten, y una carga masiva no tiene autoridad sobre ellas. Una dirección que coincide con una RETEN conserva todas sus columnas sin cambios, y tampoco se le crea un duplicado. En un usuario con source: RETEN no se toca el name — sus roles y sus contactos sí se fusionan. Toda dirección, usuario y contacto que este endpoint crea se escribe con source: INTEGRATION y se actualiza normalmente en las cargas siguientes.
Cuerpo de la Petición
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
items | object[] | Sí | Lista de comercios a escribir (mínimo 1, máximo 500). |
Cada elemento de items:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
external_id | string | Sí | Identificador del comercio en el sistema externo. Clave del upsert. |
name | string | Sí | Nombre del comercio. |
global_external_id | string | No | Identificador externo adicional del comercio. No se usa para la coincidencia. |
tax_id | string | No | Identificador tributario. No puede estar en uso por otro comercio del tenant. "" equivale a null y lo borra. |
metadata | object | No | JSON arbitrario. Se fusiona recursivamente con la metadata existente al actualizar. |
addresses | object[] | No | Direcciones del comercio (máximo 20, ver abajo). |
users | object[] | No | Usuarios del comercio (máximo 20, ver abajo). |
Cada elemento de addresses (todos los campos son opcionales):
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
external_id | string | No | Identificador de la dirección en el sistema externo. Clave de coincidencia — ver Coincidencia de direcciones. |
address_alias | string | No | Etiqueta de la dirección (p. ej. "Casa matriz"). |
is_primary | boolean | No | Deprecado — se acepta pero se ignora. |
country_code | string | No | Código de país. |
region | string | No | Región / estado. |
commune | string | No | Comuna / municipio. |
city | string | No | Ciudad. |
street | string | No | Nombre de la calle. |
street_number | string | No | Número. |
unit | string | No | Unidad, piso u oficina. |
formatted_address | string | No | Dirección completa en un solo string. |
latitude | number | No | Latitud, entre -90 y 90 inclusive. Fuera de rango manda el item entero a errors. |
longitude | number | No | Longitud, entre -180 y 180 inclusive. Fuera de rango manda el item entero a errors. |
Cada elemento de users:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
external_id | string | Sí | Identificador del usuario en el sistema externo. |
name | string | No | Nombre del usuario. |
roles | enum[] | No | Roles del usuario en el comercio (máximo 10): OWNER, ORDERS, FINANCE, ATTENTION, ADMIN, COMMERCE_REP, OTHER. |
contacts | object[] | No | Contactos del usuario (máximo 20, ver abajo). |
Cada elemento de users[].contacts:
| Campo | Tipo | Requerido | Descripción |
|---|---|---|---|
contact_type | enum | Sí | Tipo de contacto: PHONE, EMAIL, WHATSAPP u OTHER. |
value | string | Sí | Valor del contacto. Sólo se valida el formato de EMAIL; PHONE, WHATSAPP y OTHER se guardan tal como se envían. |
is_primary | boolean | No | Deprecado — se acepta pero se ignora. |
Ejemplo
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()Respuesta 200 OK
En este ejemplo, store-001 se creó, store-002 ya existía y se actualizó, y store-003 falló porque su tax_id ya está en uso por otro comercio:
{
"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"
}
]
}Campos de la respuesta
| Campo | Tipo | Descripción |
|---|---|---|
summary.total | number | Total de items enviados. |
summary.created | number | Items insertados (comercios nuevos). |
summary.updated | number | Items actualizados (comercios existentes modificados). |
summary.failed | number | Items que no se pudieron persistir. |
errors | object[] | Detalle por item fallido (vacío si todos tuvieron éxito). |
errors[].index | number | Posición (base 0) del item dentro del array items enviado. |
errors[].identifier | string | El external_id del item fallido. |
errors[].error | string | Código de error estable (ver tabla siguiente). |
errors[].message | string | Mensaje legible para humanos/logs con la causa. |
Códigos de error por item
Dentro de un item se evalúan en este orden: duplicado, después EMAIL inválido, después coordenadas.
error | Significado |
|---|---|
duplicate_in_batch | El external_id del item ya apareció en un item anterior del mismo array items. La primera ocurrencia sí se procesa; todas las siguientes fallan. |
invalid_contact_value | Un contacto de tipo EMAIL trae un value que no es una dirección de correo. Falla el item entero — el comercio, sus direcciones y sus usuarios — mientras el resto del lote se escribe. |
upsert_error | El item no se pudo persistir; message trae la causa (p. ej. una coordenada fuera de rango, o un tax_id ya en uso por otro comercio). |
Errores (de la request completa)
| Status | Descripción |
|---|---|
400 | items vacío o con más de 500 elementos; addresses o users con más de 20 elementos, users[].roles con más de 10, o users[].contacts con más de 20; un item que no cumple el formato (campo requerido ausente, tipo o valor de enum inválido); o una propiedad no declarada aquí. No se escribe ningún item. |
401 | Clave de API faltante o inválida. |
403 | La clave de API no tiene el permiso commerce:batch-upsert. |