Reten Docs

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 same external_id. If no address carries it, a new address is created and the heuristic below is not tried.
  • Without external_id (absent, null or "") — two heuristics are tried in order: first an address with the same formatted_address, attempted only if the item sends formatted_address; then an address with the same street, street_number and commune, 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

FieldTypeRequiredDescription
itemsobject[]YesList of commerces to write (minimum 1, maximum 500).

Each element of items:

FieldTypeRequiredDescription
external_idstringYesIdentifier of the commerce in the external system. Upsert key.
namestringYesCommerce name.
global_external_idstringNoAdditional external identifier of the commerce. Not used for matching.
tax_idstringNoTax identifier. Must not be in use by another commerce of the tenant. "" is equivalent to null and clears it.
metadataobjectNoArbitrary JSON. Merged recursively with the existing metadata on update.
addressesobject[]NoAddresses of the commerce (maximum 20, see below).
usersobject[]NoUsers of the commerce (maximum 20, see below).

Each element of addresses (every field is optional):

FieldTypeRequiredDescription
external_idstringNoIdentifier of the address in the external system. Match key — see Address matching.
address_aliasstringNoLabel for the address (e.g. "Main store").
is_primarybooleanNoDeprecated — accepted but ignored.
country_codestringNoCountry code.
regionstringNoRegion / state.
communestringNoCommune / municipality.
citystringNoCity.
streetstringNoStreet name.
street_numberstringNoStreet number.
unitstringNoUnit, floor or office.
formatted_addressstringNoFull address as a single string.
latitudenumberNoLatitude, between -90 and 90 inclusive. Out of range sends the whole item to errors.
longitudenumberNoLongitude, between -180 and 180 inclusive. Out of range sends the whole item to errors.

Each element of users:

FieldTypeRequiredDescription
external_idstringYesIdentifier of the user in the external system.
namestringNoUser name.
rolesenum[]NoRoles of the user in the commerce (maximum 10): OWNER, ORDERS, FINANCE, ATTENTION, ADMIN, COMMERCE_REP, OTHER.
contactsobject[]NoContacts of the user (maximum 20, see below).

Each element of users[].contacts:

FieldTypeRequiredDescription
contact_typeenumYesContact type: PHONE, EMAIL, WHATSAPP or OTHER.
valuestringYesContact value. Only EMAIL is checked for format; PHONE, WHATSAPP and OTHER are stored as sent.
is_primarybooleanNoDeprecated — 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

FieldTypeDescription
summary.totalnumberTotal items submitted.
summary.creatednumberItems inserted (new commerces).
summary.updatednumberItems updated (existing commerces modified).
summary.failednumberItems that could not be persisted.
errorsobject[]Detail per failed item (empty if all succeeded).
errors[].indexnumberPosition (0-based) of the item within the submitted items array.
errors[].identifierstringThe external_id of the failed item.
errors[].errorstringStable error code (see table below).
errors[].messagestringHuman/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.

errorMeaning
duplicate_in_batchThe 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_valueA 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_errorThe 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)

StatusDescription
400items 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.
401Missing or invalid API key.
403The API key lacks the commerce:batch-upsert permission.