Reten Docs
Comercios

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 mismo external_id. Si ninguna lo lleva, se crea una dirección nueva y no se prueba la heurística de abajo.
  • Sin external_id (ausente, null o "") — se prueban dos heurísticas en orden: primero una dirección con el mismo formatted_address, sólo si el item envía formatted_address; después una dirección con los mismos street, street_number y commune, 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

CampoTipoRequeridoDescripción
itemsobject[]SíLista de comercios a escribir (mínimo 1, máximo 500).

Cada elemento de items:

CampoTipoRequeridoDescripción
external_idstringSíIdentificador del comercio en el sistema externo. Clave del upsert.
namestringSíNombre del comercio.
global_external_idstringNoIdentificador externo adicional del comercio. No se usa para la coincidencia.
tax_idstringNoIdentificador tributario. No puede estar en uso por otro comercio del tenant. "" equivale a null y lo borra.
metadataobjectNoJSON arbitrario. Se fusiona recursivamente con la metadata existente al actualizar.
addressesobject[]NoDirecciones del comercio (máximo 20, ver abajo).
usersobject[]NoUsuarios del comercio (máximo 20, ver abajo).

Cada elemento de addresses (todos los campos son opcionales):

CampoTipoRequeridoDescripción
external_idstringNoIdentificador de la dirección en el sistema externo. Clave de coincidencia — ver Coincidencia de direcciones.
address_aliasstringNoEtiqueta de la dirección (p. ej. "Casa matriz").
is_primarybooleanNoDeprecado — se acepta pero se ignora.
country_codestringNoCódigo de país.
regionstringNoRegión / estado.
communestringNoComuna / municipio.
citystringNoCiudad.
streetstringNoNombre de la calle.
street_numberstringNoNúmero.
unitstringNoUnidad, piso u oficina.
formatted_addressstringNoDirección completa en un solo string.
latitudenumberNoLatitud, entre -90 y 90 inclusive. Fuera de rango manda el item entero a errors.
longitudenumberNoLongitud, entre -180 y 180 inclusive. Fuera de rango manda el item entero a errors.

Cada elemento de users:

CampoTipoRequeridoDescripción
external_idstringSíIdentificador del usuario en el sistema externo.
namestringNoNombre del usuario.
rolesenum[]NoRoles del usuario en el comercio (máximo 10): OWNER, ORDERS, FINANCE, ATTENTION, ADMIN, COMMERCE_REP, OTHER.
contactsobject[]NoContactos del usuario (máximo 20, ver abajo).

Cada elemento de users[].contacts:

CampoTipoRequeridoDescripción
contact_typeenumSíTipo de contacto: PHONE, EMAIL, WHATSAPP u OTHER.
valuestringSíValor del contacto. Sólo se valida el formato de EMAIL; PHONE, WHATSAPP y OTHER se guardan tal como se envían.
is_primarybooleanNoDeprecado — 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

CampoTipoDescripción
summary.totalnumberTotal de items enviados.
summary.creatednumberItems insertados (comercios nuevos).
summary.updatednumberItems actualizados (comercios existentes modificados).
summary.failednumberItems que no se pudieron persistir.
errorsobject[]Detalle por item fallido (vacío si todos tuvieron éxito).
errors[].indexnumberPosición (base 0) del item dentro del array items enviado.
errors[].identifierstringEl external_id del item fallido.
errors[].errorstringCódigo de error estable (ver tabla siguiente).
errors[].messagestringMensaje 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.

errorSignificado
duplicate_in_batchEl 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_valueUn 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_errorEl 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)

StatusDescripción
400items 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.
401Clave de API faltante o inválida.
403La clave de API no tiene el permiso commerce:batch-upsert.