← Portal de Integração

Centriaa — Guia da API de Integração

Contrato da API de Integração de entrada (ERP externo→Centriaa) — /api/v1/integration

Contrato vivo (ADR-0042 + ADR-0068 F4). Documento de referência para o autor do adapter ERP externo (Delphi/Firebird — “Fase C”, em outro repositório) e de qualquer outro ERP que integre com o Centriaa. Descreve a superfície /api/v1/integration já implementada e em develop (Fases 1–5 + ADR-0068 F4): auth por Refresh Token Rotation (RTR), CRUD de produto/auxiliares/anúncios por GUID canônico, push unidirecional de estoque LWW por occurredAt, e idempotência de criação por Idempotency-Key. O preço é controlado somente via o recurso de anúncios (§7) — o ERP externo não interage com tabela de preços.

Substitui o external-erp-adapter.md (Modelo B legado X-Adapter-Token + POST /api/v1/external-erp/*), removido do código na Fase 5. Aquele arquivo permanece só como referência histórica do ciclo pull/pending_in_flight.

Fonte de verdade: os controllers apps/api/Controllers/Integration* (incl. IntegrationListingsController), os DTOs/validators em libs/application/Integration/ e libs/application/ProductImports/, o catálogo de erros (libs/application/Common/Results/Errors.cs), endpoint-roles.md, os exemplos executáveis em apps/api/Erp.Api.http, o ADR-0042 (+ adendos) e a reformulação de preços/anúncios do ADR-0068 (fase F4 = a fatia de integração). Em divergência entre este doc e o código em develop, o código vence — reporte a divergência.


1. Visão geral


2. Autenticação — Refresh Token Rotation (RTR)

Não há segredo estático permanente. Existem dois tokens:

Token O que é TTL Onde se usa
Token longo (refresh) credencial durável do ERP; gira a cada refresh 7 dias (Jwt:IntegrationRefreshTokenExpirationDays) corpo/Bearer de POST /auth/refresh
Access JWT tokenType="integration" + claim tenantId; curto 48 h (Jwt:IntegrationAccessTokenExpirationHours) header Authorization: Bearer <access> nos endpoints de negócio

Só o hash SHA-256 do token longo é persistido (hash-only, irrecuperável — princípio SH-B18). O plaintext do token longo aparece uma única vez em cada resposta que o emite.

2.1 Provisionamento (Admin) — POST /api/v1/integration/credentials

Cria (ou recupera/reseta) a credencial de integração do tenant corrente e emite o 1º token longo.

200 OK

{
  "clientId": "itg_9Fh2…",
  "refreshToken": "<token longo plaintext — exibido só aqui>",
  "expiresAt": "2026-07-15T12:00:00Z",
  "warning": "Guarde este token agora — ele só é exibido uma vez e rotaciona a cada refresh. Re-provisionar invalida a cadeia atual."
}

2.2 Refresh (ERP) — POST /api/v1/integration/auth/refresh

Troca o token longo atual por um novo par: novo access (48 h) + novo token longo (7 d), girando ambos.

200 OK

{
  "accessToken": "<JWT tokenType=integration>",
  "expiresIn": 172800,
  "accessTokenExpiresAt": "2026-07-10T12:00:00Z",
  "refreshToken": "<novo token longo plaintext — exibido só aqui>",
  "refreshTokenExpiresAt": "2026-07-15T12:00:00Z",
  "tokenType": "Bearer"
}

2.3 Janela de graça + detecção de reuso

2.4 Como o ERP usa o access JWT

Nos endpoints de negócio (produtos, estoque, anúncios, auxiliares, stock-locations):

Authorization: Bearer <accessToken>

O RequireIntegrationToken exige tokenType="integration" + claim tenantId. Rejeita: - sem token / não autenticado → 401; - access JWT expirado401 (validação de lifetime do JwtBearer) → faça refresh e re-tente; - JWT de usuário (access), API key, ou token sem claim tenantId403.

Regra prática: mantenha o access em memória; ao receber 401 em um endpoint de negócio, chame /auth/refresh uma vez, atualize o par e re-tente a requisição.


3. Idempotência de criação — header Idempotency-Key

Todo POST de criação (produto, batch, marca, modelo, categoria, anúncio) aceita o header Idempotency-Key. Um retry com a mesma chave devolve o mesmo resultado (mesmo GUID, mesmo status), sem re-executar o handler nem duplicar o recurso.


4. Produtos — /api/v1/integration/products

CRUD endereçado pelo GUID canônico do Centriaa. Nenhum id externo é usado, mapeado ou persistido: o servidor gera o GUID no POST e o devolve; GET/PUT operam pelo GUID no path — o id nunca vai no corpo. Auth: RequireIntegrationToken.

4.1 Criar — POST /api/v1/integration/products

4.2 Ler — GET /api/v1/integration/products/{id}

200 OK — projeção minimizada (sem imagens, sem campos de auditoria além de createdAt/updatedAt):

{
  "id": "3f2a…",
  "name": "Furadeira de Impacto 750W",
  "description": null,
  "isActive": true,
  "hasVariations": true,
  "width": null, "height": null, "length": null, "weight": null,
  "packagedWidth": null, "packagedHeight": null, "packagedLength": null, "packagedWeight": null,
  "productModelId": null,
  "ncm": null,
  "origin": 0,
  "brandId": null,
  "unitOfMeasureId": "1a2b…",
  "categoryIds": [],
  "variants": [ { "id": "9b7c…", "sku": "FUR-750-AZ", "barcode": null, "isActive": true } ],
  "createdAt": "2026-07-08T12:00:00Z",
  "updatedAt": null
}

4.3 Atualizar — PUT /api/v1/integration/products/{id} (alteração pura in-place)

O PUT é uma alteração pura in-place (MERGE), não uma substituição. Id só no path. O corpo tem shape próprio (IntegrationProductUpdateDto) — todo campo é opcional e a regra é sempre null (ou ausente) = manter; só campos com valor não-nulo são gravados. Isso vale para o produto e para cada variação.

4.4 Criar em lote — POST /api/v1/integration/products/batch

4.5 Shape do produto (request IntegrationProductDto)

Preserva o shape de campos do import do ERP externo (ADR-0035), sem o id externo (a identidade é o GUID canônico do path/gerado). Auxiliares referenciados por GUID (resolve-or-fail); unidade por abreviação.

Referências faltantes são AGREGADAS (não fail-fast). As cinco referências resolve-or-fail do produto — unitOfMeasure, brandId, productModelId, cada categories[] e ncm (todas *_NOT_FOUND404) — são validadas num pré-flight único que acumula TODAS as que faltam e responde um só 404 com errors[] listando cada uma (uma entrada por categoria faltante). O operador corrige tudo num ciclo, em vez de descobrir uma por vez. Ordem determinística: unitOfMeasure, brandId, productModelId, categories, ncm — o status vem do 1º erro (sempre 404). Nada é gravado se qualquer referência falta. As regras estruturais de variante/SKU/GTIN mantêm o fail-fast próprio (semânticas 400/409 distintas, não misturam com o 404 de referência). Precedência: as cinco referências são avaliadas ANTES da estrutura — num payload com referência faltante e violação estrutural (SKU/GTIN duplicado etc.), a resposta é o 404 agregado das referências; o erro estrutural (400/409) só aparece quando todas as cinco referências resolvem. O princípio resolve-or-fail é o mesmo (ADR-0041) — nada é auto-criado, só a forma de reportar mudou (agrega em vez de parar no primeiro). No batch, cada item traz suas referências faltantes em items[].errors.

ADR-0068 F4 — este payload não carrega preço nem canal. O ERP externo interage com preço e habilitação de canal somente via o recurso de anúncios (§7, /api/v1/integration/listings).

{
  "name": "Furadeira de Impacto 750W",   // obrigatório
  "origin": 0,                             // int (origem fiscal)
  "brandId": null,                         // GUID? (resolve-or-fail → 404 PRODUCT_BRAND_NOT_FOUND)
  "description": null,
  "width": null, "height": null, "length": null, "weight": null,  // decimal?
  "packagedWidth": null, "packagedHeight": null, "packagedLength": null, "packagedWeight": null,  // decimal? — item embalado (com embalagem)
  "productModelId": null,                  // GUID? (resolve-or-fail → 404 PRODUCT_MODEL_NOT_FOUND)
  "ncm": null,                             // classificação NCM (normaliza dígitos; resolve-or-fail → 404 PRODUCT_NCM_NOT_FOUND)
  "unitOfMeasure": "un",                   // abreviação (resolve-or-fail → 404 PRODUCT_UNIT_OF_MEASURE_NOT_FOUND)
  "categories": null,                      // List<GUID>? (cada uma resolve-or-fail → 404 PRODUCT_CATEGORY_NOT_FOUND)
  "images": null,                          // List<{ sku?, order, base64 }>?
  "variations": [
    {
      "sku": "FUR-750-AZ",
      "barcode": null,
      "active": true,
      "stock": null,                       // estoque inicial? (aplicado na criação, best-effort)
      "values": [ { "id": null, "name": "Cor", "value": "Azul" } ],  // atributos de variação
      "netCost": null, "supplierCost": null, "averageCost": null
    }
  ],
  "barcode": null, "gender": null, "warranty": null,
  "anatelCode": null, "anvisaCode": null, "inmetroCode": null, "mapaCode": null,
  "customFields": null                     // List<{ name, value? }>?
}

4.6 Listar — GET /api/v1/integration/products

Lista paginada (projeção minimizada, tenant-filtrada). Query: pageNumber (default 1), pageSize (default 20, teto 100 no servidor), searchTerm (nome, opcional), isActive (opcional).

200 OKPagedResult<IntegrationProductListItemDto>:

{
  "items": [ { "id": "3f2a…", "name": "Furadeira 750W", "isActive": true, "hasVariations": true,
               "createdAt": "2026-07-08T12:00:00Z", "updatedAt": null } ],
  "pageNumber": 1, "pageSize": 20, "totalCount": 1, "totalPages": 1, "hasPreviousPage": false, "hasNextPage": false
}

Sem preço/estoque/PII (o ERP pagina/reconcilia por GUID e lê o detalhe via GET /{id}). · 401/403 auth.

4.7 Excluir — DELETE /api/v1/integration/products/{id}

Soft-delete pelo GUID canônico (mesma semântica/guards da UI, tenant-filtrado). 204 No Content · 404 produto inexistente, de outro tenant, ou já excluído (anti-IDOR + idempotência espelham a UI) · 401/403 auth.


5. Auxiliares — marca / modelo / categoria

Mesmo par CRUD por GUID dos produtos: POST cria (servidor gera o GUID → 201 + Location + corpo), GET /{id} / PUT /{id} pelo path GUID, id nunca no corpo, Idempotency-Key no POST. Auth: RequireIntegrationToken. Os controllers reusam os services de UI do Catalog — mesma validação, unicidade de nome por tenant e isolamento (sem regra duplicada). O ERP externo guarda o GUID devolvido e o referencia no produto (resolve-or-fail, ADR-0041). Leitura/edição de recurso de outro tenant → 404 (anti-IDOR).

Cada auxiliar também expõe GET (lista paginada) e DELETE /{id} (soft-delete) com a mesma query e teto de página dos produtos (§4.6) e a mesma semântica de delete da UI (§4.7):

Recurso Rotas Request Response
Marca POST / GET / GET /{id} / PUT /{id} / DELETE /{id} …/product-brands { name, description?, logoUrl?, website?, isActive? } { id, name, description, logoUrl, website, isActive }
Modelo POST / GET / GET /{id} / PUT /{id} / DELETE /{id} …/product-models { name, isActive? } { id, name, isActive }
Categoria POST / GET / GET /{id} / PUT /{id} / DELETE /{id} …/product-categories { name, description?, parentId?, sortOrder? } { id, name, description, parentId, sortOrder, isActive }

6. Estoque — push autoritativo unidirecional (LWW por occurredAt)

O ERP é a autoridade: empurra o saldo já consolidado de uma célula de estoque (não um delta incremental — envia o valor absoluto). O Centriaa nunca empurra estoque de volta ao ERP (§9). Ordenação é Last-Write-Wins por occurredAt — o ERP não mantém versão; carimba em cada push um timestamp de origem. Preço não usa mais este modelo (ADR-0068 F4) — veja §7 (Anúncios).

6.1 Descobrir locais de estoque — GET /api/v1/integration/stock-locations

200 OK — conjunto de referência (dropdown-sized), sem paginação:

[ { "id": "1a2b…", "name": "Depósito Central", "isDefault": true } ]

Use o id para endereçar estoque por local; o local isDefault=true é o alvo quando você omite stockLocationId.

6.2 Descobrir marketplaces/conexões — GET /api/v1/integration/marketplaces

Descubra quais marketplaces/conexões o tenant tem antes de criar anúncios por tipo (§7). Projeção mínima — a conexão guarda segredos OAuth cifrados (access/refresh token, metadata, expiries) que nunca saem por aqui.

200 OK — conjunto de referência (poucas conexões por tenant), sem paginação:

[ { "marketplaceType": "Shopee", "connectionName": "Loja Shopee", "shopId": "123456", "name": "Shopee", "cnpj": "35.635.824/0001-12" } ]

6.2.1 Catálogo GLOBAL de marketplaces — GET /api/v1/integration/marketplaces/catalog

Referência GLOBAL (não filtrada por tenant, mas exige o token de integração): mapeia cada tipo de marketplace ao nome amigável e ao CNPJ do operador. Independe de o tenant ter conexão — descreve o que cada marketplace é.

200 OK — conjunto de referência (uma linha por marketplace com provider), sem paginação, ordem estável por tipo:

[
  { "type": "Shopee", "name": "Shopee", "cnpj": "35.635.824/0001-12" },
  { "type": "MercadoLivre", "name": "Mercado Livre", "cnpj": "03.007.331/0001-41" },
  { "type": "LojaIntegrada", "name": "Loja Integrada", "cnpj": null }
]

6.3 Push de estoque — PUT /api/v1/integration/products/{id}/stock

Atualiza o saldo autoritativo de uma variante numa célula (produto × variante × local).

{
  "variantId": "9b7c…",        // obrigatório; deve pertencer ao produto do path (+tenant)
  "stockLocationId": null,      // opcional; null → local DEFAULT do tenant
  "quantity": 42.0000000000,    // numeric(20,10), ≥ 0
  "occurredAt": "2026-07-08T12:00:00Z"
}

7. Anúncios — a única superfície de preço da integração (ADR-0068 F4 · contrato do ANÚNCIO INTEIRO desde o ADR-0070 F3)

⚠ BREAKING (ADR-0070 F3, 2026-07-30) — leia antes de qualquer chamada se o seu adapter foi construído contra o contrato anterior (ADR-0068 F4, grão VARIAÇÃO):

  1. O id deste recurso passou a ser o do ANÚNCIO (item), não mais o da variação. Os GUIDs que o seu ERP guardou dos POSTs antigos são ids de variação e deixaram de endereçar GET/PUT/DELETE. Migração mecânica: chamar GET /api/v1/integration/listings/{idAntigo} devolve 404 com o código estável INTEGRATION_LISTING_ID_IS_VARIATION e metadata.listingItemId = o id novo do anúncio ao qual aquela variação pertence — troque o id guardado e siga. Alternativa em lote: GET da lista (§7.2) e case pelos variations[].id, que são exatamente os ids antigos.
  2. POST cria o anúncio inteiro (variations[], ≥ 1 entrada) e PUT é SUBSTITUIÇÃO do anúncio inteiro — as flags removePrice/removePromotion não existem mais: price: 0 ou null limpa o preço fixo da variação (volta à fonte table); variação ausente do JSON é removida; omitir a promoção a remove.
  3. DELETE remove o anúncio inteiro (item + todas as variações), não mais uma variação isolada.
  4. O anúncio ganhou editorial opcional (title/description, herdados do produto quando nulos) e o estado de sync aparece em dois grãos (casca do anúncio × modelo por variação).

Decisão de produto: o ERP externo não controla nada de tabela de preço — ele interage com preço somente através de anúncios, em /api/v1/integration/listings. Um anúncio é como o canal enxerga: um ITEM (o MLB do Mercado Livre, o item da Shopee) com N variações (modelos). No Centriaa isso é o ListingItem (identidade: produto × conexão × tipo de anúncio) com suas Listings (uma por variação do produto), endereçado pelo GUID canônico do item. O bloco de preço FIXO decide a fonte POR VARIAÇÃO:

Diferente do push de estoque (§6), este recurso não é LWW por occurredAt — é CRUD comum: o servidor gera o GUID no POST; GET/PUT/DELETE operam por esse GUID no path (mesmo padrão de produtos/auxiliares, §4/§5). A conexão de marketplace é sempre resolvida por TIPO, nunca por shop direto (ADR-0042): 0 conexões do tipo → 404 INTEGRATION_MARKETPLACE_CONNECTION_NOT_FOUND; mais de 1409 INTEGRATION_MARKETPLACE_CONNECTION_AMBIGUOUS (consolide as conexões do tenant e re-tente). Auth: RequireIntegrationToken (tenant do claim, nunca do corpo).

Editorial (title/description) é opcional e herdado: nulo/ausente/em branco = o anúncio publica o nome/descrição do produto (e acompanha automaticamente mudanças do produto — cada edição de produto re-sincroniza os anúncios); um valor próprio torna o editorial do anúncio (fixo, não segue mais renomes do produto). O teto do título é por canal: Mercado Livre 60, Shopee 120, demais 200 — acima disso, 400 MARKETPLACE_LISTING_TITLE_LIMIT_EXCEEDED com metadata.maxLength.

7.1 Criar anúncio — POST /api/v1/integration/listings

Header Idempotency-Key recomendado (retry → mesmo GUID, sem duplicar). Cria o anúncio inteiro: o editorial em cima, o preço por variação embaixo.

{
  "productId": "3f2a…",
  "marketplace": "MercadoLivre",   // o TIPO (string) — nunca GUID de conexão
  "listingType": "gold_special",   // obrigatório+suportado p/ ML (gold_special|gold_pro); ausente p/ Shopee/LojaIntegrada
  "title": "Furadeira 750W 220V",  // opcional — nulo/ausente = publica o NOME do produto (e o acompanha)
  "description": null,             // opcional — nulo = publica a descrição do produto
  "variations": [                  // ≥ 1 entrada; uma por variação publicada no canal
    { "variantId": "9b7c…", "price": 314.90, "promotionalPrice": 299.90,
      "promotionStart": "2026-07-10T00:00:00Z", "promotionEnd": "2026-07-17T00:00:00Z" },
    { "variantId": "1d44…", "price": null }   // sem preço fixo → fonte "table" (células da variante)
  ]
}

Produto simples (sem variações): exatamente uma entrada com variantId: null (anúncio a nível de produto — o pipeline resolve a variante default):

{ "productId": "3f2a…", "marketplace": "Shopee", "listingType": null,
  "variations": [ { "variantId": null, "price": null } ] }

7.2 Listar anúncios — GET /api/v1/integration/listings

Query: pageNumber (default 1), pageSize (default 20, teto 100), productId (opcional), marketplace (opcional, o TIPO como string). Ordem estável por criação. Página de ANÚNCIOS (itens), cada um com suas variations aninhadas.

200 OKPagedResult<IntegrationListingDto>:

{
  "items": [
    {
      "id": "7e1a…",
      "productId": "3f2a…",
      "marketplace": "MercadoLivre",
      "listingType": "gold_special",
      "title": "Furadeira 750W 220V",
      "description": null,
      "syncStatus": "Synced",
      "lastSyncedAt": "2026-07-30T12:00:00Z",
      "externalItemId": "MLB123",
      "createdAt": "2026-07-08T12:00:00Z",
      "updatedAt": "2026-07-30T12:00:00Z",
      "variations": [
        {
          "id": "0aa1…",
          "variantId": "9b7c…",
          "price": 314.90,
          "promotionalPrice": 299.90,
          "promotionStart": "2026-07-10T00:00:00Z",
          "promotionEnd": "2026-07-17T00:00:00Z",
          "priceSource": "fixed",
          "syncStatus": "Synced",
          "externalModelId": "MOD-1",
          "lastSyncedAt": "2026-07-30T12:00:00Z"
        }
      ]
    }
  ],
  "pageNumber": 1, "pageSize": 20, "totalCount": 1, "totalPages": 1, "hasPreviousPage": false, "hasNextPage": false
}

7.3 Ler um anúncio — GET /api/v1/integration/listings/{id}

200 OK — mesmo shape do item de §7.2 (anúncio + variações aninhadas, dois grãos de estado). · 404 anúncio inexistente, removido ou de outro tenant (MARKETPLACE_LISTING_NOT_FOUND, anti-IDOR) · 404 INTEGRATION_LISTING_ID_IS_VARIATION + metadata.listingItemId quando o {id} é um id de variação do contrato antigo — troque pelo listingItemId devolvido (é o aviso estruturado da migração).

7.4 Substituir um anúncio — PUT /api/v1/integration/listings/{id}

SUBSTITUIÇÃO do anúncio INTEIRO — o JSON é o estado final (ADR-0070: o merge campo a campo com null ambíguo, de onde vinham as flags removePrice/removePromotion, morreu junto com elas). A identidade (produto/marketplace/tipo de anúncio) não é editável — para mudar de marketplace/tipo, crie outro anúncio.

{
  "title": "Furadeira 750W 220V — nova embalagem",  // opcional; nulo/branco = volta a herdar do produto
  "description": null,
  "variations": [                                    // o ESTADO FINAL da grade (≥ 1 entrada)
    { "variantId": "9b7c…", "price": 329.90 },       // presente com preço novo → substitui o bloco
    { "variantId": "1d44…", "price": 0 }             // 0 OU null → limpa o preço fixo (volta à fonte "table")
    // variação que existia e NÃO está aqui → REMOVIDA do anúncio (soft-delete)
    // variantId que ainda não existia → variação NOVA criada sob o anúncio
  ]
}

7.5 Excluir anúncio — DELETE /api/v1/integration/listings/{id}

Soft-delete do anúncio inteiro (item + TODAS as variações, na mesma transação) pelo GUID canônico do item — o hub deixa de gerenciar o anúncio; o item NÃO é encerrado no canal (comportamento documentado, igual ao delete da UI). Recriar o mesmo (produto, marketplace, tipo) depois cria um anúncio NOVO — a próxima publicação cria outro item no canal (o antigo continua lá, não gerenciado). 204 No Content · 404 inexistente, já removido ou de outro tenant (anti-IDOR + idempotência) · 404 INTEGRATION_LISTING_ID_IS_VARIATION para id de variação do contrato antigo · 401/403 auth.

Fluxo recomendado ao ERP: descubra marketplaces (GET /marketplaces, §6.2) → crie o produto (POST /products, §4.1) → crie um anúncio por marketplace/tipo de anúncio (POST /listings, com o editorial opcional e a grade completa em variations[]) → para QUALQUER mudança (título, preços, adicionar/remover variação), PUT /listings/{id} com o estado final (nunca recrie o anúncio para atualizar).


8. Catálogo de códigos de erro

Formato do corpo de erro: ProblemDetails (RFC 7807) com Extensions ricos — ver docs/api/error-codes.md. Campos úteis ao adapter:

{
  "title": "Not Found",
  "detail": "…",
  "status": 404,
  "errors": [ { "message": "…", "code": "PRODUCT_VARIANT_NOT_FOUND", "metadata": { "Field": "variantId" } } ],
  "messageKey": "errors.PRODUCT_VARIANT_NOT_FOUND",
  "field": "variantId",
  "correlationId": "5f1c…"
}
Código HTTP Onde Significado
AUTH_INTEGRATION_REFRESH_TOKEN_INVALID 401 /auth/refresh token longo não encontrado/inválido (ou sucessor inválido/revogado)
AUTH_INTEGRATION_REFRESH_TOKEN_EXPIRED 401 /auth/refresh token longo (ou sucessor) expirado
AUTH_INTEGRATION_REFRESH_TOKEN_REUSE 401 /auth/refresh anterior apresentado após o sucessor usado → cadeia revogada (re-provisione)
AUTH_INTEGRATION_CREDENTIAL_INACTIVE 401 /auth/refresh credencial inexistente/inativa
INTEGRATION_CREDENTIAL_PROVISION_CONCURRENCY 409 /credentials duas primeiras-provisões concorrentes para o mesmo tenant — re-tente
VALIDATION_ERROR 400 todos payload violou o validador (carrega field do 1º campo inválido)
INTEGRATION_PRODUCT_INVALID 400 produto POST/PUT/batch produto rejeitado na etapa estrutural de variante/imagem
PRODUCT_NOT_FOUND 404 produto/estoque/anúncios produto inexistente ou de outro tenant (anti-IDOR)
PRODUCT_VARIANT_NOT_FOUND 404 estoque/anúncios variante não pertence ao produto (+tenant)
PRODUCT_BRAND_NOT_FOUND 404 produto/marca brandId/marca inexistente para o tenant
PRODUCT_MODEL_NOT_FOUND 404 produto/modelo productModelId/modelo inexistente para o tenant
PRODUCT_CATEGORY_NOT_FOUND 404 produto/categoria categoria referenciada inexistente para o tenant (uma entrada por categoria faltante)
PRODUCT_CATEGORY_PARENT_NOT_FOUND 404 categoria POST parentId inexistente/de outro tenant
PRODUCT_UNIT_OF_MEASURE_NOT_FOUND 404 produto unitOfMeasure (abreviação) não resolve
PRODUCT_NCM_NOT_FOUND 404 produto ncm não normaliza para 8 dígitos ou não existe na tabela global ncms
PRODUCT_BRAND_NAME_EXISTS 400 marca POST/PUT nome de marca duplicado no tenant
PRODUCT_MODEL_NAME_EXISTS 400 modelo POST/PUT nome de modelo duplicado no tenant
PRODUCT_CATEGORY_NAME_EXISTS 400 categoria POST/PUT nome de categoria duplicado no mesmo pai
STOCK_LOCATION_NOT_FOUND 404 estoque stockLocationId explícito inexistente para o tenant
INTEGRATION_NO_DEFAULT_STOCK_LOCATION 400 estoque sem stockLocationId e tenant sem local default
INTEGRATION_MARKETPLACE_CONNECTION_NOT_FOUND 404 anúncios tenant sem conexão de marketplace do tipo enviado
INTEGRATION_MARKETPLACE_CONNECTION_AMBIGUOUS 409 anúncios tenant com >1 conexão do tipo → conexão ambígua
INTEGRATION_LISTING_VARIANT_REQUIRED 400 anúncios produto com variações e entrada de variations[] sem variantId
INTEGRATION_LISTING_VARIANT_INVALID 400 anúncios variantId explícito num produto simples, ou entrada duplicada na payload
INTEGRATION_LISTING_ALREADY_EXISTS 409 anúncios já existe anúncio vivo para (produto, conexão, tipo de anúncio) — metadata.listingItemId aponta o existente
INTEGRATION_LISTING_ID_IS_VARIATION 404 anúncios o id enviado é de VARIAÇÃO (contrato anterior ao ADR-0070 F3) — metadata.listingItemId = o id novo do anúncio
MARKETPLACE_LISTING_TYPE_NOT_CONFIGURED 400 anúncios listingType sem linha de preço configurada na conexão
MARKETPLACE_LISTING_PRICE_REQUIRED 400 anúncios promoção/janela numa entrada de variação sem price base > 0
MARKETPLACE_LISTING_PRICE_INVALID 400 anúncios bloco de preço fixo viola invariante (promo ≥ base, janela invertida, preço negativo)
MARKETPLACE_LISTING_TITLE_LIMIT_EXCEEDED 400 anúncios title acima do teto do canal (ML 60 / Shopee 120 / demais 200) — metadata.maxLength
MARKETPLACE_LISTING_NOT_FOUND 404 anúncios anúncio inexistente, removido ou de outro tenant (GET/PUT/DELETE)

Além destes: 401 genérico (token de integração ausente/expirado), 403 (tipo de token inadequado / sem claim tenantId), 409 (idempotência em voo — mesma chave ainda processando), 413 (batch acima do limite). Erros de infraestrutura permanecem 5xx (sem detalhe técnico sensível no corpo).


9. Notas de modelo (o adapter precisa entender)


10. Custódia do GUID (responsabilidade do adapter)

Como não há id externo no modelo de dados do Centriaa para este fluxo, não há recuperação server-side se o adapter perder um GUID. O adapter é stateful e 100% responsável por guardar de forma durável os GUIDs que o Centriaa devolve:

Reintroduzir id externo (mapa + lookup by-external-id) é o caminho previsto se essa custódia virar risco operacional — hoje é aceita “no momento” (ADR-0042, Consequências).


Referências