← Portal de Integração

Centriaa — Guia da API de Integração

Contrato da API de Integração (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.

A maior parte da superfície recebe dados do ERP externo; §7-ter é o canal inverso, que devolve pedidos confirmados.

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

Evolução planejada — V2 da entrega de integração

Decisão do dono em 2026-09-05: ficam para V2 o registro dos destinos ERP ativos e a distribuição, por destino, das alterações de produtos e pessoas feitas no Centriaa. Isso inclui reenfileirar clientes já Integrated quando mudarem dados consumidos de cliente, pessoa, endereço ou contato, com revisão e ACK protegidos contra concorrência. Não implementado e fora do aceite da V1. V2 aqui identifica a etapa da entrega; não define uma nova rota de API.

O desenho da V2 deverá distinguir a origem da alteração para evitar eco e gerar pendência somente por mudança efetiva dos dados consumidos. No-op, ACK e timestamps operacionais não devem gerar nova pendência. Revisão e payload precisam representar o mesmo snapshot; ACK obsoleto não pode confirmar nem marcar erro sobre uma revisão mais nova. O delta de contrato e sua adoção pelos consumidores serão fechados antes da implementação.

Na V1, os feeds existentes de clientes, pedidos e OS continuam consultados pelo serviço, retornando apenas Pending ou ProcessingError; Integrated fica fora. A edição de cliente já integrado não o reenfileira atualmente. Produtos têm CRUD e consulta, sem feed reverso de alterações com ACK. O catálogo ERP externo → Centriaa, os fluxos existentes de cliente/pedido/OS e a entrega operacional descrita em pedidos operacionais continuam no escopo da V1. A expansão V2 não bloqueia seu fechamento.

Contrato atual


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 expirado → 401 (validação de lifetime do JwtBearer) → faça refresh e re-tente; - JWT de usuário (access), API key, ou token sem claim tenantId → 403.

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 rede — header Idempotency-Key

Os endpoints mutáveis que declaram Idempotency-Key usam o middleware como autoridade única do retry de rede. Um retry com a mesma chave e o mesmo conteúdo devolve o mesmo resultado, sem reexecutar o handler. Além dos POSTs de criação, a confirmação de pedido (§7-ter) exige o header.


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,
  "requiresSerialNumbers": null,
  "tracksLotAndExpiry": false
}

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.

O múltiplo de venda (salesMultiple), que não integra este payload, mantém o valor cadastrado no produto.

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_FOUND → 404) — 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? }>?
  "requiresSerialNumbers": null,           // bool? — true EXIGE série, false AFIRMA que não exige,
                                           //         null = ainda não classificado. NUNCA presuma false.
  "tracksLotAndExpiry": null               // bool? — omitido cai no default false do catálogo
}

Nenhum campo além destes é aceito. Desde 2026-08-08 (adendo da ADR-0042) a superfície de produtos recusa campo desconhecido — na raiz e dentro de variations[], images[], values[] e customFields[] — com 400 VALIDATION_ERROR. Antes disso o campo era descartado em silêncio, e um typo (requiresSerialNumber no singular, por exemplo) passava com 2xx sem o dado chegar. No POST /batch, o campo a mais derruba só o item que o carrega. O rigor cobre o corpo; parâmetro de query com typo continua sendo ignorado, e nesse caso o filtro simplesmente não se aplica.

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 OK — PagedResult<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.

4.8 Observação de referências recusadas (ADR-0184)

Unidade, NCM, marca, modelo ou categoria não resolvidos continuam recusando o produto com os mesmos códigos, mensagens, campos, ordem e status já documentados. Antes de devolver a recusa, o Centriaa tenta registrar uma observação agregada por tenant, tipo, fonte e código externo. Essa tentativa é best-effort e isolada: indisponibilidade do registro nunca transforma a falha original nem impede o próximo item do lote.

O adapter não recebe id de observação e não ganha endpoint de de-para. A consulta GET /api/v1/integration-failures pertence ao plano administrativo, exige access token de usuário Admin e não aceita token de integração.


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.1.1 Criar e atualizar locais de estoque — POST e PUT /api/v1/integration/stock-locations/{id}

ADR-0220. O ERP externo é a fonte dos locais: cria sem GUID por POST (201), guarda o id do recibo e atualiza por PUT /{id} (200). As duas escritas exigem Idempotency-Key (replay pelo middleware da API, como as demais rotas de escrita).

{"name":"Geral","isDefault":true,"isActive":true}

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"
}

6.4 Consultar saldo por local — GET /api/v1/integration/stock-balances?variantId={uuid}

Leitura pura (ADR-0086, adendo de visibilidade): a distribuição por local que o Centriaa conhece daquela variante, para o ERP enxergar o multi-local do WMS sem replicar saldo. Não grava nada, não movimenta estoque e não reabre replicação de movimento — o push de estoque (§6.3) continua sendo a única direção de escrita.

200 OK — array cru, sem paginação (o teto natural é o número de locais do tenant), ordem estável por stockLocationId:

[
  {
    "stockLocationId": "1a2b3c4d-0000-0000-0000-000000000001",
    "quantityKnown": 42.0000000000,
    "pendingInFlight": 0.0000000000,
    "freshness": "Fresh",
    "reconciliationState": "Unknown",
    "lastAuthoritativeAt": "2026-08-07T18:20:00+00:00",
    "updatedAt": "2026-08-07T18:20:00+00:00"
  }
]

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 1 → 409 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 OK — PagedResult<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).


7-bis. Clientes — /api/v1/integration/customers

A porta que faltava. Até esta fatia, das 14 portas de integração nenhuma era de cliente: produto, catálogo e estoque subiam pelo ERP, e a carteira de clientes não tinha por onde entrar. A entrega logística nunca precisou dela porque ali o cliente é instantâneo no payload (DestinationSnapshot); o pedido de venda, ao contrário, exige um cliente cadastrado — sem esta porta o dado real do ERP para no catálogo e não alcança o fulfillment.

Mesmo par CRUD por GUID dos demais recursos: POST cria (servidor gera os GUIDs → 201 + Location + corpo), GET /{id} / PUT /{id} pelo path GUID, id nunca no corpo, Idempotency-Key no POST, auth RequireIntegrationToken, tenant do claim. O controller reusa o service de CRM do Centriaa — mesma validação de documento, mesmas regras de tenant, mesmos erros que a tela web aplica.

7-bis.1 Criar — POST /api/v1/integration/customers

Uma chamada e uma transação criam a pessoa, o papel de cliente e os endereços/contatos. Guarde os dois GUIDs: o do cliente é o que o pedido de venda referencia; o da pessoa é o dono dos endereços e contatos.

{
  "name": "Indústria Alfa LTDA",       // obrigatório
  "personType": 1,                      // 0 = pessoa física, 1 = pessoa jurídica
  "customerCode": "ERP-1001",          // OPCIONAL — proteção contra duplicata, não identidade
  "document": "12345678000195",        // CPF/CNPJ sem máscara; validado (dígitos verificadores)
  "documentType": "BR:CNPJ",           // opcional; resolvido pelo país do tenant + personType. Só código canônico COUNTRY:TYPE em maiúsculas; país com catálogo (BR) exige tipo registrado (BR:CPF, BR:CNPJ); país sem catálogo aceita a forma (ex.: US:SSN). "CPF", "BR:cpf" ou "BR:RG" → 400 VALIDATION_ERROR, field DocumentType (correção de 12/09/2026)
  "tradeName": "Alfa",
  "suframa": { "value": "123456789", "occurredAt": "2026-08-29T12:00:00Z" },
  "observation": { "value": "Cliente antigo, cobra por e-mail", "occurredAt": "2026-08-29T12:00:00Z" },
  "b2bBlock": { "value": false, "occurredAt": "2026-08-29T12:00:00Z" },
  "score": { "value": 780, "calculatedAt": "2026-08-28T00:00:00Z", "occurredAt": "2026-08-29T12:00:00Z" },
  "creditPolicy": {
    "enforceLimit": true,
    "value": 50000.00,
    "validFrom": null,
    "validUntil": null,
    "occurredAt": "2026-08-29T12:00:00Z"
  },
  "creditUsage": { "value": 1250.00, "asOf": "2026-08-29T12:05:00Z" },
  "stateRegistration": "1234567890",
  "municipalRegistration": null,
  "isExemptFromTax": false,
  "taxExemptionReason": null,
  "stateRegistrationIndicator": 1,     // NF-e indIEDest
  "taxRegime": 3,                       // CRT
  "isEndConsumer": false,               // NF-e indFinal
  "recipientType": 0,                   // classificação IBS/CBS
  "defaultPriceTableId": null,          // GUID de tabela de preço do Centriaa (resolve-or-fail)
  "addresses": [                        // opcional; teto 20
    {
      "label": "Entrega", "type": 3,    // 0 residencial · 1 comercial · 2 cobrança · 3 entrega
      "street": "Rua das Flores", "number": "100", "complement": null,
      "neighborhood": "Centro", "cityName": "Curitiba", "stateCode": "PR",
      "countryId": "BR", "postalCode": "80000000", "isPrimary": true
    }
  ],
  "contacts": [                         // opcional; teto 20
    { "type": 2, "value": "41999990000", "label": "Comprador", "isPrimary": true }
    // 0 e-mail · 1 telefone · 2 celular · 3 WhatsApp · 4 site · 5 Instagram · 6 LinkedIn · 99 outro
  ]
}

201 Created + Location: /api/v1/integration/customers/{id} + corpo:

{
  "id": "…",            // GUID do CLIENTE — é este que o pedido de venda usa
  "personId": "…",      // GUID da PESSOA — dono dos endereços/contatos
  "customerCode": "ERP-1001",
  "status": 0,
  "addressIds": ["…"],
  "contactIds": ["…"]
}

Principal, quando o payload não diz: se nenhum endereço vier com isPrimary, o primeiro vira o principal; se mais de um vier, só o primeiro vale. Para contato o principal é por tipo — o telefone principal e o e-mail principal são independentes.

7-bis.2 Idempotência — dois níveis, não confunda

Nível Mecanismo O que resolve
Rede header Idempotency-Key (§3) Retry de timeout: devolve a resposta original em cache, mesmo GUID, sem reexecutar.
Negócio customerCode (opcional) Reenvio do mesmo cliente: 409 com CustomerId e PersonId do existente — siga por PUT /{id}. Sem código, o documento cumpre o papel; sem nenhum dos dois, o participante é criado e você fica com o GUID.

A identidade é o GUID canônico, sob a SUA custódia — nenhum id externo é persistido como identidade (ADR-0042; foi por isso que a tabela de mapeamento de produto foi apagada). O customerCode não é uma segunda identidade: é o que permite detectar o reenvio enquanto você ainda não tem o GUID. Por isso ele é opcional — fornecedor e transportadora têm código próprio, e a porta não pode exigir o de cliente.

Opcional não quer dizer inútil, e vale entender por quê: sem nada a comparar, um reenvio sem GUID cria participante duplicado em silêncio e o caminho de recuperação pelo 409 nunca dispara. A escada é: código (índice único por tenant) → documento, se não houver código → cria, se não houver nenhum dos dois. O documento sozinho não serve como chave: o índice dele não é único e ele falta em consumidor final sem cadastro. Não há upsert silencioso: o POST nunca sobrescreve um cadastro — sobrescrever apagaria o que o operador editou na tela web.

Cliente que já existe sob outro código: o POST com um documento já cadastrado devolve 409 INTEGRATION_CUSTOMER_DOCUMENT_EXISTS. Isso não é um erro a contornar — é como você acha o cliente existente em vez de criar uma segunda ficha da mesma pessoa.

Os DOIS GUIDs viajam na metadata do 409 (CustomerId e PersonId), pela mesma razão do 201: o 409 é caminho de recuperação, e o vínculo que você grava a partir dele precisa nascer completo. Só com o CustomerId ele serve para o PUT e não serve para endereço/contato, que pendem da pessoa — e você seria obrigado a tornar a sua coluna de PERSON_ID anulável só para representar “achei, mas incompleto”. O par sai junto na criação; sai junto no conflito.

7-bis.3 Ler / atualizar / listar

As respostas dos sub-recursos de endereço (GET /{id}/addresses, POST /{id}/addresses e PUT /{id}/addresses/{addressId}) incluem cityCode (string anulável) e cityCodeSystem (enum anulável), preservando os valores persistidos. Ausência permanece null; o servidor não deriva código do nome da cidade nem realiza lookup nesta projeção. A identidade continua sendo o GUID canônico, conforme ADR-0042.

7-bis.3-bis Toda leitura sua deixa rastro

A porta audita. Cada leitura de cliente, de endereços ou de contatos grava uma entrada PiiAccessed no audit_log do tenant, com a sua credencial como ator — a trilha responde qual integração leu, não só que uma integração leu. Não muda nada do seu lado (nenhum header, nenhum campo), mas é bom saber que existe: numa auditoria de LGPD é o que separa “temos controle” de “não sabemos quem leu”.

Consequência operacional: a auditoria é fail-closed. Se a gravação da trilha falhar, a leitura falha junto e o recurso não é devolvido — por desenho, para que não exista leitura de dado pessoal sem registro.


7-bis.4 ADR-0160 — sincronização comercial

A porta carrega suframa, observation, b2bBlock, score, creditPolicy e o snapshot creditUsage. Cada conceito autoritativo tem seu próprio occurredAt e usa LWW; o snapshot usa asOf e nunca é autoridade para travar venda. O antigo creditLimit foi substituído pelo bloco completo, sem segunda cópia. Cidade e UF continuam em addresses[]. Permanecem fora: padrão de pagamento (ADR-0150), títulos vencidos, estado da fila do conector e ids externos.


7-ter. Pedidos confirmados — /api/v1/integration/orders

O canal nasce desligado por IntegrationOrders:Enabled=false. Com a flag ativa, GET /orders?since=... entrega pedidos que ingressaram no fluxo de confirmação, ainda não foram cancelados e cujo estado de integração está Pending ou ProcessingError, em página fixa de 20 e ordem keyset estável. A primeira leitura usa um instante ISO-8601 com offset; as seguintes usam o nextCursor opaco, assinado e vinculado ao tenant e à credencial. Cursor ausente, adulterado ou de outro escopo retorna 422 INTEGRATION_ORDER_CURSOR_INVALID.

Cada item traz GUIDs como string, cabeçalho, vínculo resolvível do cliente, itens e o snapshot explícito de deliveryDate/payments. orderDate é timestamp ISO-8601 com offset; entrega e vencimentos são datas puras. Quantidade, preço, desconto, frete, acréscimo e total usam quatro casas decimais no contrato. O cabeçalho expõe freightAmount e surchargeAmount como números obrigatórios, inclusive quando zero, copiados dos valores canônicos do pedido. totalAmount já inclui ambos: corresponde a subTotal - discountAmount + freightAmount + surchargeAmount, com piso zero do agregado. O conector deve preservar os componentes explícitos para importar o valor integral do pedido. O servidor nunca deriva vencimentos de paymentConditionId ou paymentMethodId; ausência explícita de parcelas permanece payments: []. customerId é o GUID canônico do cliente e resolve em GET /api/v1/integration/customers/{customerId}; o conector deve materializar o cliente antes do pedido. O import de cliente nascido no aplicativo continua sendo fatia própria, não um fallback ou uma identidade alternativa neste payload.

POST /orders/{orderId}/confirmation exige Idempotency-Key e aceita:

O retry de rede obedece ao §3. Separadamente, o agregado conserva o primeiro resultado de negócio: replay semântico idêntico não duplica transição e resultado divergente retorna 409 INTEGRATION_ORDER_CONFIRMATION_CONFLICT. Pedido integrado não reaparece. Pedido cancelado deixa o feed; se o cancelamento ocorrer entre a leitura e a confirmação, resultado novo retorna 409 sem transição, enquanto replay idêntico já persistido permanece aceito. Pedido ausente ou de outro tenant responde 404.


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). Em /products isso passou a incluir campo desconhecido no corpo, na raiz ou em qualquer record aninhado (2026-08-08) — antes o campo era descartado em silêncio
IDEMPOTENCY_KEY_CONFLICT 409 mutações com Idempotency-Key mesma chave vinculada a conteúdo diferente; handler não executado
IDEMPOTENCY_REQUEST_IN_PROGRESS 409 mutações com Idempotency-Key mesma requisição ainda em voo; aguarde e repita o mesmo conteúdo
INTEGRATION_ORDER_FEED_DISABLED 404 /orders canal de retorno de pedidos desligado
INTEGRATION_ORDER_CURSOR_INVALID 422 /orders since inválido, adulterado ou de outro tenant/credencial
INTEGRATION_ORDER_NOT_FOUND 404 /orders/{id}/confirmation pedido ausente/de outro tenant
INTEGRATION_ORDER_CONFIRMATION_INVALID 400 /orders/{id}/confirmation resultado de confirmação malformado
INTEGRATION_ORDER_CONFIRMATION_CONFLICT 409 /orders/{id}/confirmation resultado de negócio diverge do primeiro resultado persistido
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_CUSTOMER_CODE_EXISTS 409 cliente POST/PUT já existe cliente com este customerCode — metadata.CustomerId e metadata.PersonId apontam o existente; siga por PUT /{id}
INTEGRATION_CUSTOMER_DOCUMENT_EXISTS 409 cliente POST já existe cliente com este documento, sob outro código — metadata.CustomerId e metadata.PersonId apontam o existente; use-os em vez de criar uma segunda ficha
CUSTOMER_NOT_FOUND 404 cliente GET/PUT cliente inexistente ou de outro tenant (anti-IDOR)
CUSTOMER_DEFAULT_PRICE_TABLE_NOT_FOUND 404 cliente POST/PUT defaultPriceTableId inexistente para o tenant (_NOT_FOUND → 404 pela escada de ResultExtensions)
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