/api/v1/integrationContrato 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/integrationjá implementada e emdevelop(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 poroccurredAt, e idempotência de criação porIdempotency-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 legadoX-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 emlibs/application/Integration/elibs/application/ProductImports/, o catálogo de erros (libs/application/Common/Results/Errors.cs),endpoint-roles.md, os exemplos executáveis emapps/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 emdevelop, o código vence — reporte a divergência.
/api/v1/integration —
nomeada pela capacidade (integração de ERP), não pelo
parceiro. Não se funde na rota /api/v1/products da UI
(auth, contrato e cadência de versionamento próprios). Base:
https://<host>/api/v1/integration./auth/refresh e usa o access
JWT curto (Bearer) nos endpoints de negócio.tenantId é um claim do access JWT (e é resolvido do token
longo no refresh). Nenhum endpoint aceita tenantId no
corpo/query (ADR-0009). Vazamento entre tenants é falha crítica: toda
leitura/escrita é filtrada por tenant e recurso de outro tenant responde
404 (anti-IDOR), nunca 403 que confirmaria
existência.v1 do path é a versão
desta superfície de integração (independente da UI).
Mudanças são aditivas enquanto possível; quebra de
contrato exige nova versão de rota + ADR.Content-Type: application/json,
Accept: application/json). GUIDs em formato canônico
(8-4-4-4-12). Timestamps em ISO-8601 com
offset: o Z (UTC) dos exemplos ou
qualquer offset explícito (ex.: 2026-07-08T09:00:00-03:00)
são aceitos — o servidor normaliza para UTC preservando o
instante antes de gravar (o valor lido de volta vem em
Z). Enviar o offset local é válido e equivalente.12.34), não
como string ("12.34") — um número-como-string é rejeitado
com 400. (Vale só nesta superfície; o restante da API é
tolerante.)numeric(20,10); preço é numeric(18,4).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.
POST /api/v1/integration/credentialsCria (ou recupera/reseta) a credencial de integração do tenant corrente e emite o 1º token longo.
RequireAccessToken + RequireAdminRole).
Não é o token de integração. Rate
limit: política auth (10 req / 60 s).clientId) e
revoga a cadeia anterior de tokens longos
(recuperação/reset — o ERP recebe um token novo e o antigo morre).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."
}clientId é um identificador público e opaco
(referência/diagnóstico; não é segredo).INTEGRATION_CREDENTIAL_PROVISION_CONCURRENCY) duas
primeiras-provisões concorrentes para o mesmo tenant (re-tente; a 2ª cai
no caminho de reset).POST /api/v1/integration/auth/refreshTroca o token longo atual por um novo par: novo access (48 h) + novo token longo (7 d), girando ambos.
Auth: anônimo — o token longo
apresentado é a credencial. Rate
limit: política auth (10 req / 60 s por IP).
Isento de XSRF (é token de header/corpo, não cookie).
Entrada: o token longo no corpo ou no header:
POST /api/v1/integration/auth/refresh
Content-Type: application/json
{ "refreshToken": "<token longo atual>" }
— ou —
POST /api/v1/integration/auth/refresh
Authorization: Bearer <token longo atual>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"
}expiresIn é a validade do access em
segundos (padrão OAuth; 48 h = 172800 s).AUTH_INTEGRATION_REFRESH_TOKEN_REUSE → 401). A recuperação
é re-provisionar (§2.1).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.
Idempotency-KeyTodo 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.
(tenant, método HTTP, path) (ADR-0042, adendo
2026-07-08). O servidor persiste
"{tenantId}:{SHA-256(MÉTODO + \n + path_normalizado + \n + chave_bruta)}".
Consequência prática: a Idempotency-Key só precisa ser
única por operação/rota — o adapter pode
reusar uma chave derivada do seu próprio id de registro entre
rotas distintas (ex.: POST /products e
POST /product-brands) sem colidir. Dedup
de mesma rota + mesma chave é preservado.X-Idempotency-Replayed: true e o
status/corpo originais./api/v1/integration/productsCRUD 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.
POST /api/v1/integration/productsHeader Idempotency-Key recomendado (retry → mesmo
GUID).
201 Created +
Location: /api/v1/integration/products/{id} + corpo:
{
"id": "3f2a…",
"variants": [ { "id": "9b7c…", "sku": "FUR-750-AZ" } ]
}Guarde o id do produto e o
id de cada variante — são eles que endereçam estoque (§6) e
anúncios (§7).
400 validação do payload / regra de variante
violada (VALIDATION_ERROR,
INTEGRATION_PRODUCT_INVALID) · 404
auxiliar referenciado (marca/modelo/categoria/unidade) inexistente para
o tenant · 401/403 auth.
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
}PRODUCT_NOT_FOUND, anti-IDOR).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.
null=manter, não há como zerar um campo do produto
por aqui.PUT NUNCA
deleta nem recria variante. (Antes o update fazia um “swap
estrutural” que recriava as variantes com GUIDs novos; isso foi
removido.)id no corpo
(variations[].id = o GUID da variante):
id que casa uma variante do
próprio produto (+tenant) → altera in-place essa
variante (campos null=manter: sku,
barcode, active, stock, custos
(netCost, supplierCost,
averageCost), values). GUID e projeção de
estoque preservados. Estoque só muda se
enviado (mesmo caminho LWW/kardex da §6). Preço
não passa por esta rota — é gerenciado somente
via anúncios (§7). A combinação de valores
(values) de uma variante existente é a sua identidade
estrutural e não é reatribuída por esta rota — para
mudar a combinação, adicione uma variante nova e
desative a antiga (active:false);id → cria
uma variante nova (o servidor gera o GUID), sem tocar
as existentes. Uma variação value-less (sem
values) mapeia para a variante
única/default de um produto simples (não cria uma
segunda default);id que não pertence ao
produto/tenant → 404
PRODUCT_VARIANT_NOT_FOUND (anti-IDOR — o cliente não impõe
o GUID de uma variante nova nem endereça variante de outro tenant).
Falha antes de qualquer escrita (nada muda, nem o
produto);PUT — desative-a
com active:false.variations: null → não toca em
nenhuma variante (só os campos do produto que vierem
preenchidos).POST /api/v1/integration/products/batchCorpo: array de produtos (mesmo shape do create). Sem id externo,
cada item só cria. Header Idempotency-Key
cobre o lote inteiro.
Limites: máx. 500 itens; corpo máx. ~8 MB.
200 OK — sucesso parcial por item (um item que falha reporta seu código/erros sem abortar os demais):
{
"total": 2, "created": 1, "failed": 1,
"items": [
{ "index": 0, "id": "3f2a…", "variants": [ { "id": "9b7c…", "sku": "PAR-12V" } ], "success": true, "errorCode": null, "errors": [] },
{ "index": 1, "id": null, "variants": [], "success": false, "errorCode": "PRODUCT_BRAND_NOT_FOUND", "errors": ["…"] }
]
}400 lista vazia · 413 itens acima do limite · 401/403 auth.
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, cadacategories[]encm(todas*_NOT_FOUND→ 404) — são validadas num pré-flight único que acumula TODAS as que faltam e responde um só 404 comerrors[]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 emitems[].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? }>?
}
variations[].values[].id é um campo de rastreabilidade
opcional que o servidor nunca lê
(valores de variação resolvem por name/value).
Não dependa dele.variations[].id (o GUID da variante) —
no POST é ignorado (o servidor gera o
GUID). No PUT ele É lido e é a
chave de casamento da variante (§4.3): com
id altera in-place, sem id adiciona. Este é o
único id que o corpo carrega, e só na rota de update.barcode de nível-produto (capa GTIN, o campo
raiz "barcode"): para produto SIMPLES (sem
variações reais) o GTIN principal é gravado na variante
default/implícita (VariantKey == "") — [ADR-0034]:
a capa não vive no produto, vive na variante. Se a variação trouxer
barcode próprio, ele vence (é mais
específico); senão a capa é o fallback. Validação
canônica (GtinFormat 8/12/13/14) + unicidade por
tenant aplicadas pelo mesmo caminho da UI — GTIN
inválido/duplicado → erro de linha estruturado, nunca
500. Produto COM variações reais não recebe a capa
(cada variante traz o seu GTIN). (Fix P1 import 2026-07-12: antes o
update de variação sobrescrevia a capa de volta para null —
o GTIN de capa se perdia no import.)stock da variação define o estoque
inicial na criação/atualização (best-effort, não
bloqueia o produto). O fluxo autoritativo contínuo de
estoque é o push da §6./api/v1/integration/listings (§7) — é lá que
price, promotionalPrice, a janela promocional
e o listingType são endereçados e validados. Este endpoint
de produto não resolve nem valida nenhum desses campos.variations[].price,
variations[].marketplacePrices[] e
salesChannels foram removidos deste
payload, e o push de preço
PUT /api/v1/integration/products/{id}/prices não
existe mais — enviar esses campos hoje resulta em
400 (payload estrito) ou 404 de rota. Migre assim:
depois de criar o produto, crie um anúncio por marketplace/tipo
de anúncio (POST /api/v1/integration/listings,
§7.1) — o anúncio carrega o editorial opcional e
variations[] com preço fixo por variação
(ou sem preço, para usar as tabelas do Centriaa) —, e atualize-o via
PUT /api/v1/integration/listings/{id} (§7.4, substituição
do anúncio inteiro). Habilitar/desabilitar canal = criar/excluir o
anúncio.GET /api/v1/integration/productsLista 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.
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.
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):
GET …/product-brands|product-models|product-categories
— pageNumber/pageSize (teto 100) +
searchTerm
isActive → PagedResult<…ResultDto>
(mesma projeção do GET /{id}), tenant-filtrado ·
401/403 auth.DELETE …/{id} → 204 ·
404 inexistente/de outro tenant/já excluído (anti-IDOR
+ idempotência) · marca/modelo em uso por produtos →
409 PRODUCT_BRAND_IN_USE /
PRODUCT_MODEL_IN_USE · categoria com
subcategorias → PRODUCT_CATEGORY_HAS_CHILDREN
(mapeado para 400, o mesmo status da UI).| 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 } |
parentId: único caso de
auxiliar-referencia-auxiliar por GUID. É
resolve-or-fail — parent inexistente/de outro tenant →
404 (PRODUCT_CATEGORY_PARENT_NOT_FOUND),
nunca cria pai implícito. parentId é honrado só na
criação; o PUT atualiza atributos
(nome/descrição/ordem) e não faz re-parenting nem muda
estado ativo (fora da superfície de integração).PRODUCT_BRAND_NAME_EXISTS /
PRODUCT_MODEL_NAME_EXISTS /
PRODUCT_CATEGORY_NAME_EXISTS).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).
occurredAt deve ser um instante real
(após o epoch Unix) e não muito no futuro (o validador
aceita até 24 h à frente, para tolerar clock skew). Um
occurredAt absurdamente futuro envenena o
watermark (descartaria pushes legítimos posteriores até aquele instante
passar) e é rejeitado. O offset é livre (Z
ou qualquer fuso, ex.: -03:00) — normalizado para UTC
preservando o instante, então o LWW compara o mesmo ponto no tempo
independente do fuso enviado. O mesmo vale para
promotionStart/promotionEnd no bloco de preço
do anúncio (§7) — mas ali eles não participam de LWW: o
recurso de anúncios é CRUD comum, não um push por
occurredAt.occurredAt for
estritamente mais recente que o último aplicado àquela
célula. Push igual ou mais antigo é no-op de sucesso
(204, sem regredir o valor). Ao aplicar, um contador
interno (version) é incrementado só para o fanout de
marketplace coalescer — transparente ao ERP.occurredAt da origem (o
last_modified/watermark que você já lê no Firebird),
não o relógio do momento do POST — assim
reenvios/reordenações de rede não fixam um valor velho.GET /api/v1/integration/stock-locations200 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.
GET /api/v1/integration/marketplacesDescubra 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" } ]marketplaceType: o tipo do marketplace
(string) — o mesmo valor que você usa em
marketplace ao criar um anúncio (§7.1).connectionName / shopId: o rótulo da
conexão e o id da loja no marketplace.name / cnpj: enriquecimento do
catálogo GLOBAL (resolvido por
marketplaceType, §6.2.1) — nome amigável do marketplace e
CNPJ do operador (formatado ##.###.###/####-##, ou
null quando a plataforma não tem CNPJ central — ex.: Loja
Integrada). Campos aditivos no fim (não quebra
consumidores existentes).200 [].hasPromotionalTable saiu do contrato — o
ERP externo não endereça mais tabela de preço/promocional diretamente; a
promoção é sempre um campo do próprio anúncio
(promotionalPrice + janela, §7.1/§7.4), aceito independente
da conexão.GET /api/v1/integration/marketplaces/catalogReferê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 }
]type: o tipo do marketplace (string) —
o mesmo valor usado em marketplace ao criar um anúncio
(§7.1).name: nome amigável do marketplace.cnpj: CNPJ do operador (formatado), ou
null quando a plataforma não tem CNPJ central da plataforma
(Loja Integrada é plataforma de loja própria — o CNPJ relevante é o do
próprio lojista/tenant).PUT /api/v1/integration/products/{id}/stockAtualiza 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"
}
occurredAt não mais novo). A
célula é criada se ainda não existir (upsert). Publica
StockChangedInternal (re-sync do marketplace).VALIDATION_ERROR) ou
sem local e sem default
(INTEGRATION_NO_DEFAULT_STOCK_LOCATION) ·
404 produto/variante/local inexistente para o tenant
(PRODUCT_NOT_FOUND / PRODUCT_VARIANT_NOT_FOUND
/ STOCK_LOCATION_NOT_FOUND).⚠ 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):
- O
iddeste recurso passou a ser o do ANÚNCIO (item), não mais o da variação. Os GUIDs que o seu ERP guardou dosPOSTs antigos são ids de variação e deixaram de endereçarGET/PUT/DELETE. Migração mecânica: chamarGET /api/v1/integration/listings/{idAntigo}devolve 404 com o código estávelINTEGRATION_LISTING_ID_IS_VARIATIONemetadata.listingItemId= o id novo do anúncio ao qual aquela variação pertence — troque o id guardado e siga. Alternativa em lote:GETda lista (§7.2) e case pelosvariations[].id, que são exatamente os ids antigos.POSTcria o anúncio inteiro (variations[], ≥ 1 entrada) ePUTé SUBSTITUIÇÃO do anúncio inteiro — as flagsremovePrice/removePromotionnão existem mais:price: 0ounulllimpa o preço fixo da variação (volta à fontetable); variação ausente do JSON é removida; omitir a promoção a remove.DELETEremove o anúncio inteiro (item + todas as variações), não mais uma variação isolada.- 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:
fixed (precedência) — a variação
carrega preço próprio (price > 0 + opcionalmente
promotionalPrice/janela), gravado com auditoria
(listing_price_changes, fonte Integration) e
evento ListingPriceChanged na mesma
transação de cada escrita aceita.table (fallback) — sem preço próprio
(price nulo ou 0), a variação
usa a célula da variante nas tabelas de preço do tipo
de anúncio configuradas na conexão
(marketplace_connection_listing_types) — o mesmo modelo que
a UI usa. 0 não é preço: é ausência (o
invariante “preço > 0” continua de pé; um anúncio a R$ 0,00 seria
recusado pelo canal tarde demais).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.
POST /api/v1/integration/listingsHeader 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 } ] }
Location: /api/v1/integration/listings/{id} +
{ "id": "7e1a…" }. O id é o do
ANÚNCIO (item) — guarde-o: é ele que endereça
GET/PUT/DELETE. Os ids das
variações vêm na visão (variations[].id).variations[].variantId segue a regra
PxV: produto com variações exige variantId
em todas as entradas (400
INTEGRATION_LISTING_VARIANT_REQUIRED); produto
simples aceita exatamente uma entrada
com null — variantId explícito num produto
simples é 400
(INTEGRATION_LISTING_VARIANT_INVALID).
variantId repetido na payload → 400.
Variante que não pertence ao produto/tenant → 404
PRODUCT_VARIANT_NOT_FOUND.marketplace resolve a conexão por
TIPO (ver acima —
404/409).listingType segue a capability por
marketplace (ADR-0047/0048, mesma regra do cadastro de canal da UI):
obrigatório e um dos suportados quando o marketplace
tem tipos (Mercado Livre → gold_special
(Clássico) / gold_pro (Premium));
ausente/nulo quando não tem (Shopee,
Loja Integrada) — fora dessas regras,
400 VALIDATION_ERROR. Além da capability,
o tipo precisa estar configurado na conexão (linha de
tabela de preço cadastrada) — senão 400
MARKETPLACE_LISTING_TYPE_NOT_CONFIGURED. O mesmo
produto pode ter um anúncio Clássico e um
Premium (dois POST com listingType distinto —
são DOIS itens no canal).INTEGRATION_LISTING_ALREADY_EXISTS com
metadata.listingItemId — endereça o existente via
GET/PUT em vez de recriar.price > 0 → a
variação nasce fonte fixed (auditoria +
ListingPriceChanged na mesma transação, um por variação com
preço); null/0 → nasce fonte
table; negativo → 400. Promoção/janela só
junto de price > 0 na mesma entrada:
promo sem base → 400
MARKETPLACE_LISTING_PRICE_REQUIRED; bloco inválido (promo ≥
base, janela invertida) → 400
MARKETPLACE_LISTING_PRICE_INVALID.MARKETPLACE_LISTING_TITLE_LIMIT_EXCEEDED
(metadata.maxLength).x-version, risco de oversell).PRODUCT_NOT_FOUND) · 401 token
ausente/inválido.GET /api/v1/integration/listingsQuery: 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
}syncStatus/lastSyncedAt do TOPO é da
casca do anúncio (publicação do item →
externalItemId); o de cada variação é do
modelo (inclusão na grade + push de preço/estoque →
externalModelId). Não há agregado derivado — componha do
jeito que seu ERP precisar.title/description são os
ARMAZENADOS: null = o anúncio herda do produto na
publicação (a visão não materializa a herança — o nome do produto o seu
ERP já conhece).variations[].id é o GUID da variação —
o id que o contrato ANTERIOR devolvia; use-o para o de-para da
migração.RowVersion, tokens ou detalhe
interno de erro. marketplace e syncStatus
serializam como string. · 401/403 auth.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).
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
]
}
price > 0 = fonte fixed;
price: 0/null = limpa o preço
fixo — a variação volta à fonte table, com o fallback
efetivo (célula da variante nas tabelas do tipo) resolvido na
hora e propagado no evento (sem fallback resolvível, o preço do
evento vai null e o consumer registra o skip auditável
LISTING_PRICE_MISSING — nunca um preço errado).
Omitir a promoção remove a promoção (o bloco é
substituído por inteiro).Pending e re-sincroniza o item no canal
automaticamente (mesma transação); num anúncio ainda não publicado, só
armazena.MARKETPLACE_LISTING_PRICE_INVALID /
MARKETPLACE_LISTING_PRICE_REQUIRED; título →
MARKETPLACE_LISTING_TITLE_LIMIT_EXCEEDED).listing_price_changes, fonte Integration) e
publica um ListingPriceChanged na
mesma transação — mudanças de preço NÃO re-sincronizam
a casca (o pipeline de preço empurra por modelo).MARKETPLACE_LISTING_NOT_FOUND
(anti-IDOR) · 404
INTEGRATION_LISTING_ID_IS_VARIATION +
metadata.listingItemId para id do contrato antigo (§7 topo)
· 401 token ausente/inválido.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).
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…"
}code (em errors[]) é o
contrato estável — programe contra ele, não contra
detail (texto humano, traduzível).
messageKey é derivado 1:1 do código
(errors.{CODE}). field aponta
o campo inválido quando aplicável.
correlationId correlaciona a resposta ao
log do servidor — inclua-o ao reportar problemas.| 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).
table), quem governa o preço publicado no canal são as
tabelas de preço do Centriaa (as mesmas da UI) — o ERP
não as lê nem as escreve por esta API.order→stock de produtos
stock_ownership='ExternalERP' é hoje fail-closed.
No modelo push-only, o Centriaa não decide sozinho o
decremento local desses produtos ao confirmar um pedido: a operação é
recusada explicitamente com
STOCK_MODEL_B_PUSH_ONLY_ORDER_NOT_SUPPORTED
(não é oversell silencioso). A escolha definitiva
(no-op com oversell × decremento local otimista) depende do refluxo
acima e será decidida em ADR próprio. Em runtime, o Model B segue
inativo até o adapter ERP externo entrar (o provider de
settings devolve Centriaa).occurredAt é do ERP, não do Centriaa — só se
aplica ao push de estoque (§6). O Centriaa não infere ordenação
nesse push; confia no seu carimbo. Envie deltas de célula de estoque
ordenáveis por occurredAt. O recurso de anúncios
(§7) não usa occurredAt — é CRUD comum; a ordem de
aplicação é a ordem das chamadas HTTP.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:
PUT é alteração
pura in-place e não os recria (§4.3), então você
não precisa mais reconciliar os ids das variantes
existentes a cada update; só guarda os GUIDs das variantes
novas que um PUT adicionar. É o próprio
adapter que devolve esses id no corpo do PUT
para casar a variante a alterar;GET /stock-locations) que endereça o push por local;POST /listings →
{ id }, §7.1) que endereça
GET/PUT/DELETE daquele anúncio —
também sem recuperação server-side se perdido (use
GET /listings?productId=… para localizar de novo).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).
/integration/listings, §7; preço fixo por anúncio com
fallback opcional para tabela). A fase F4 é a fatia de
integração — é a citação que aparece nos XML docs dos
DTOs/controllers/services Integration*.endpoint-roles.md —
matriz de rotas × auth × rate limit (seção “API de Integração ERP
v2”).error-codes.md (envelope)
+ ../architecture/error-codes.md
(catálogo + mapeamento ErrorCode → HTTP).apps/api/Erp.Api.http — exemplos executáveis das Fases
1–4 + anúncios (ADR-0068 F4).external-erp-adapter.md
— DEPRECADO (Modelo B legado; referência histórica do
ciclo pull removido).