Venty API — Administração completa para ERP

API máquina-a-máquina para o seu ERP controlar 100% das ações administrativas de uma loja Venty: produtos (todos os campos), estoque multi-local, localizações, transferências, categorias, marcas, atributos, variações, clientes (PF/PJ) e pedidos. Autenticação OAuth2, exemplos em Go, Rust e Node.js. Auto-suficiente e redundante — cada endpoint entende-se isolado. Mapa completo em Superfície completa · contrato em /openapi.yaml.

Base URL. Produção https://api.venty.me (chaves venty_ci_live_…) · Homologação/teste https://api.hml.venty.me (chaves venty_ci_test_…). Todos os caminhos abaixo são relativos à base escolhida; o token vale para o host onde foi emitido.
Regras de ouro (valem em TODO endpoint):
  1. Bearer sempre. Envie Authorization: Bearer <access_token>.
  2. O tenant (a loja) vem do TOKEN — nunca do corpo nem da URL. Não existe tenant_id nos paths.
  3. Estoque é SET absoluto, não delta. Entrada/baixa = read-modify-write (GET → calcula → PUT).
  4. Escopos. Escrever produto = catalog:write; escrever estoque = inventory:write; ler = catalog:read.

Fluxo de autenticação

No backoffice da loja, o dono cria uma chave de API escolhendo um escopo base (ERP/Pedidos, Catálogo & estoque, Fiscal, Somente leitura ou Acesso total). Gera um par: CI (client_id, público, 64 chars, prefixo venty_ci_live_/venty_ci_test_) e CS (client_secret, secreto, 256 chars, prefixo venty_cs_live_/venty_cs_test_) — o CS aparece uma única vez. Seu ERP troca esse par por um access_token Bearer de ~10 min e o usa nas chamadas. Renove quando faltar < 60s para expirar.

Fluxo: client_credentials → access_token (JWT) → Authorization: Bearer. Não há X-API-Key em request; o segredo só vai ao endpoint de token. O id (UUID) da chave é só para gerir/revogar — não é o client_id.

Modelo de estoque

ModoOnde viveDisponívelO que você envia no PUT
globalUm número na loja (stock/reserved)stock - reserved{"stock": N}
per_locationPor origem (loja/depósito) em inventory_levelsΣ(on_hand - reserved){"levels":[{"origin_id":"…","on_hand":N}]}
Trava anti-venda-a-descoberto: você NÃO consegue baixar o estoque abaixo do que já está reservado por pedidos em aberto → 409 estoque_abaixo_do_reservado.
manage_stock=false = produto não rastreado (sempre disponível, available=null). Definir stock_mode liga manage_stock=true.

Com N lojas, qual atende o pedido?

Não existe uma “loja selecionada” fixa. A origem é resolvida por item, no momento da compra:

CenárioQuem escolhe a lojaRegraComo ler na API
Entrega (delivery) Backend (automático) Prefere a origem que o cliente indicou na opção de frete; se ela não cobre a quantidade, cai para a de maior disponível que cobre. ORDER BY (origin=preferida) DESC, (on_hand-reserved) DESC. Itens diferentes podem sair de lojas diferentes. items[].stock_origin_id + stock_origin_name
Retirada (pickup) Cliente A loja é a que o cliente escolheu na opção de retirada. fulfillment.pickup_location
Crossdock Cliente (loja A) + backend (supplier B) Cliente retira na loja A; o backend acha a loja B da malha com estoque e transfere A←B. Retirada agendada. fulfillment.is_crossdock, scheduled_for, pickup_location
Estoque global — Sem conceito de loja: pool único. stock_origin_id = null

Tudo no detalhe GET /api/v2/orders/{id}:

{
  "fulfillment": { "type": "pickup", "is_crossdock": false, "scheduled_for": null,
                   "pickup_location": { "name": "Loja Centro", "address": "Rua X, 3", "instructions": "" } },
  "items": [
    { "product_id": "…", "sku": "DW00180", "quantity": 2,
      "stock_origin_id": "7937a17e-…", "stock_origin_name": "Loja Alcântara", "stock_status": "reserved" }
  ]
}
Resumo: retirada → fulfillment.pickup_location; entrega → items[].stock_origin_id/name.

Emitir token de máquina

POST /api/v2/oauth/token · sem auth prévia

Troca client_id + client_secret por um access_token Bearer (~10 min). Aceita form-urlencoded (padrão OAuth) ou JSON. Os escopos efetivos vêm do grant da chave.

Corpo (form)

CampoTipo
grant_typestringobrigatório = client_credentials
client_idstringobrigatório CI (64 chars, venty_ci_live_/venty_ci_test_)
client_secretstringobrigatório CS (256 chars, venty_cs_…, mostrado 1x)
scopestringopcional (informativo)

Resposta 200

access_tokenstringJWT Bearer
token_typestringBearer
expires_inintsegundos (~600)
scopestringescopos concedidos
# form-urlencoded (padrão OAuth)
curl -X POST https://api.venty.me/api/v2/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=venty_ci_live_ab12cd34 \
  -d client_secret=$VENTY_SECRET \
  -d 'scope=catalog:read inventory:write'
package main
import ("net/http"; "net/url"; "io"; "fmt")
func token() string {
  form := url.Values{"grant_type":{"client_credentials"},
    "client_id":{"venty_ci_live_ab12cd34"}, "client_secret":{secret}}
  r, _ := http.PostForm("https://api.venty.me/api/v2/oauth/token", form)
  defer r.Body.Close(); b, _ := io.ReadAll(r.Body)
  // parse {"access_token": "..."} → devolve o token
  return parseAccessToken(b)
}
// reqwest = { version = "0.12", features=["json"] }
let res = client.post("https://api.venty.me/api/v2/oauth/token")
    .form(&[("grant_type","client_credentials"),
            ("client_id","venty_ci_live_ab12cd34"),
            ("client_secret",&secret)])
    .send().await?.json::<Token>().await?;
let access = res.access_token;
const body = new URLSearchParams({
  grant_type:'client_credentials',
  client_id:'venty_ci_live_ab12cd34', client_secret:process.env.VENTY_SECRET });
const r = await fetch('https://api.venty.me/api/v2/oauth/token',{method:'POST',body});
const { access_token } = await r.json();
{"access_token":"eyJ…","token_type":"Bearer","expires_in":600,"scope":"catalog:read inventory:write"}

Ler estoque do produto catalog:read

GET /api/v2/catalog/products/{id}/inventory

Passo 1 do in/out. Retorna o modo de estoque, os totais globais e uma linha por origem ativa. Use o on_hand/available atuais para calcular o novo total antes do PUT. Lembre: Bearer e o tenant vem do token.

Resposta 200

CampoTipo
stock_modeenumglobal|per_location
stock/reserved/availableinttotais globais
levels[]arraypor origem: origin_id, origin_name, origin_type, on_hand, reserved, available
curl https://api.venty.me/api/v2/catalog/products/$PID/inventory \
  -H "Authorization: Bearer $TOKEN"
req, _ := http.NewRequest("GET",
  base+"/api/v2/catalog/products/"+pid+"/inventory", nil)
req.Header.Set("Authorization", "Bearer "+token)
res, _ := http.DefaultClient.Do(req) // → 200 Inventory JSON
let inv = client.get(format!("{base}/api/v2/catalog/products/{pid}/inventory"))
    .bearer_auth(&token).send().await?.json::<Inventory>().await?;
const inv = await (await fetch(
  `${base}/api/v2/catalog/products/${pid}/inventory`,
  { headers:{ Authorization:`Bearer ${token}` } })).json();
{"stock_mode":"per_location","manage_stock":true,"stock":0,"reserved":0,"available":0,
 "levels":[{"origin_id":"eae8…","origin_name":"Bonsucesso","origin_type":"store","on_hand":40,"reserved":3,"available":37}]}

Definir estoque — entrada/saída inventory:write

PUT /api/v2/catalog/products/{id}/inventory

Passo 2 do in/out. Escreve o estoque absoluto (SET). Entrada = número maior; baixa = número menor. Modo global → {"stock":N}; modo per_location → {"levels":[{"origin_id,"on_hand":N}]} (só as origens enviadas mudam). Opcional stock_mode troca o modo. Bearer + tenant do token.

Baixar abaixo do reserved → 409 estoque_abaixo_do_reservado (com origin_id no per_location). Nada é alterado.

Corpo

stockintnovo on-hand global (modo global)
levels[].origin_iduuidobrigatório por linha
levels[].on_handintnovo on-hand absoluto na origem
stock_modeenumopcional; troca o modo
# ENTRADA por loja: Bonsucesso passa a 45 on_hand
curl -X PUT https://api.venty.me/api/v2/catalog/products/$PID/inventory \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"levels":[{"origin_id":"eae8154e-…","on_hand":45}]}'
// read-modify-write: leia (GET) → novo = atual + entrada → PUT
body := `{"levels":[{"origin_id":"eae8154e-…","on_hand":45}]}`
req, _ := http.NewRequest("PUT", base+"/api/v2/catalog/products/"+pid+"/inventory",
  strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req) // 200 = ok; 409 = abaixo do reservado
let res = client.put(format!("{base}/api/v2/catalog/products/{pid}/inventory"))
    .bearer_auth(&token)
    .json(&json!({"levels":[{"origin_id":oid,"on_hand":45}]}))
    .send().await?;
if res.status()==409 { /* abaixo do reservado */ }
const res = await fetch(`${base}/api/v2/catalog/products/${pid}/inventory`,{
  method:'PUT',
  headers:{ Authorization:`Bearer ${token}`, 'Content-Type':'application/json' },
  body: JSON.stringify({ levels:[{ origin_id:oid, on_hand:45 }] }) });
if (res.status===409) { /* estoque_abaixo_do_reservado */ }
# 200: devolve o estoque recalculado (mesmo shape do GET)
{"stock_mode":"per_location","levels":[{"origin_id":"eae8…","on_hand":45,"reserved":3,"available":42}]}

Criar produto catalog:write

POST /api/v2/catalog/products

Cria um produto na loja do token. Mínimo: name, slug, sku e (para simple) price. Não é idempotente — reenvio duplica; slug/sku repetido → 409. Para estoque por-loja, crie e depois use o PUT de estoque.

Corpo — TODOS os campos

Obrigatórios: name, slug, sku, price (simple/variation). Além destes, o produto aceita o conjunto completo:

Preço/comercial
price, promotional_price, cost_pricenumbervenda, promo, custo (margem)
tax_ratenumber% imposto (fiscal)
Descrição/mídia
short_description, description, content, banner_image, image_url, external_image_urlstringtextos (HTML) + imagens
Logística
weight, length, width, heightnumberpeso (kg) + dimensões (cm)
Estoque/estado
stock, stock_status, manage_stock, low_stock_threshold, active, show_product—estoque inicial + flags
Classificação/relacionamento
category_id, brand_id, product_group_iduuidcategoria/marca/grupo
Tipo/variação/extras
product_type, parent_product_id, variation_attributes, product_attributes—simple/parent/variation + eixos
is_virtual, is_downloadable, download_file, custom_fields—virtual/digital + campos livres
Lista com tipos/validação de cada campo em /openapi.yaml (schema ProductWrite).
curl -X POST https://api.venty.me/api/v2/catalog/products \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Placa ST 13","slug":"placa-st-13","sku":"PLST13","price":18.03,"stock":120}'
body := `{"name":"Placa ST 13","slug":"placa-st-13","sku":"PLST13","price":18.03,"stock":120}`
req, _ := http.NewRequest("POST", base+"/api/v2/catalog/products", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req) // 201 {"id":"…"}; 409 slug/sku
let r = client.post(format!("{base}/api/v2/catalog/products"))
    .bearer_auth(&token)
    .json(&json!({"name":"Placa ST 13","slug":"placa-st-13",
        "sku":"PLST13","price":18.03,"stock":120}))
    .send().await?; // 201 { id }
const r = await fetch(`${base}/api/v2/catalog/products`,{ method:'POST',
  headers:{ Authorization:`Bearer ${token}`, 'Content-Type':'application/json' },
  body: JSON.stringify({ name:'Placa ST 13', slug:'placa-st-13',
    sku:'PLST13', price:18.03, stock:120 }) });
const { id } = await r.json(); // 201
{"id":"88cfc492-ad6d-405c-8bbf-d582b3e21b83"}

Listar produtos catalog:read

GET /api/v2/catalog/products?limit=50&cursor=…

Paginado por cursor opaco. Resposta: {"data":[Product…],"next_cursor":"…"|null}. Passe next_cursor em cursor para a próxima página. limit máx 250. Bearer + tenant do token.

curl "https://api.venty.me/api/v2/catalog/products?limit=100" -H "Authorization: Bearer $TOKEN"
let cursor, all=[];
do {
  const u = new URL(`${base}/api/v2/catalog/products`);
  u.searchParams.set('limit','250'); if(cursor) u.searchParams.set('cursor',cursor);
  const p = await (await fetch(u,{headers:{Authorization:`Bearer ${token}`}})).json();
  all.push(...p.data); cursor = p.next_cursor;
} while (cursor);

Ler produto catalog:read

GET /api/v2/catalog/products/{id}

Detalhe do produto (id, name, slug, sku, price, active, stock, stock_mode, product_type, …). 404 se não existir na loja. Bearer + tenant do token.

curl https://api.venty.me/api/v2/catalog/products/$PID -H "Authorization: Bearer $TOKEN"

Atualizar produto catalog:write

PUT /api/v2/catalog/products/{id}

Atualização parcial. Atenção: alguns campos são preservados quando omitidos (name, slug, sku, price, active, stock, manage_stock) e outros são zerados quando omitidos (promotional_price, weight, category_id, …) — envie o conjunto completo desses para não limpar sem querer.

curl -X PUT https://api.venty.me/api/v2/catalog/products/$PID \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"price":19.90,"active":true}'   # → {"ok":true}

Localizações (lojas/depósitos) inventory:read/write

GET /api/v2/inventory/locations · POST · PUT /:id

Uma localização é uma loja ou depósito. O `id` da localização é o `origin_id` usado nos níveis de estoque (multi-local). Crie as localizações aqui e depois escreva o estoque por localização no PUT de inventário. Bearer + tenant do token.

Campos (criar/atualizar)

namestringobrig.
typeenumstore|warehouse
cepstring8 díg — geocoda no save
address,number,city,state…stringendereço
pickup_enabledboolretirada (só store)
own_delivery_enabledboolfrete próprio (só store)
# criar loja com retirada
curl -X POST https://api.venty.me/api/v2/inventory/locations \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"name":"Loja Centro","type":"store","cep":"20221030","city":"Rio de Janeiro","state":"RJ","pickup_enabled":true,"pickup_ready_hours":24}'
# → {"id":"<location id = origin_id>","geocoded":true}
body := `{"name":"Loja Centro","type":"store","cep":"20221030","pickup_enabled":true}`
req, _ := http.NewRequest("POST", base+"/api/v2/inventory/locations", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req) // 201 {id, geocoded}
const r = await fetch(`${base}/api/v2/inventory/locations`,{ method:'POST',
  headers:{ Authorization:`Bearer ${token}`, 'Content-Type':'application/json' },
  body: JSON.stringify({ name:'Loja Centro', type:'store', cep:'20221030', pickup_enabled:true }) });
const { id } = await r.json(); // id = origin_id p/ os níveis de estoque

Clientes (PF/PJ) customers:read/write

POST /api/v2/customers · GET/PUT /:id

Cadastro completo de clientes, PF ou PJ. No create, a senha de login é gerada (o cliente pode redefinir depois) — o ERP não precisa dela. Bearer + tenant do token.

Campos

emailstringobrig. no create
name, phonestringcontato
person_typeenumpf|pj
documentstringCPF (11) / CNPJ (14) díg
legal_name, trade_namestringrazão social / fantasia
state_registrationstringinscrição estadual
birth_datedateYYYY-MM-DD
# criar cliente PJ
curl -X POST https://api.venty.me/api/v2/customers \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"email":"compras@acme.com","name":"ACME","person_type":"pj","document":"12345678000199","legal_name":"ACME LTDA","trade_name":"ACME"}'
# → {"id":"…","email":"compras@acme.com"};  GET/PUT /api/v2/customers/{id}
body := `{"email":"compras@acme.com","person_type":"pj","document":"12345678000199","legal_name":"ACME LTDA"}`
req, _ := http.NewRequest("POST", base+"/api/v2/customers", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req) // 201 {id, email}
const r = await fetch(`${base}/api/v2/customers`,{ method:'POST',
  headers:{ Authorization:`Bearer ${token}`, 'Content-Type':'application/json' },
  body: JSON.stringify({ email:'compras@acme.com', person_type:'pj',
    document:'12345678000199', legal_name:'ACME LTDA' }) });

Mídia / imagens — referência externa (não re-suba a cada update)

Toda imagem tem duas identidades: o id (UUID do Venty) e o external_id (a sua chave do ERP). Suba os bytes uma vez; depois referencie por external_id/id/URL — reordenar e atualizar não re-sobem imagem.

Fluxo recomendado (idempotente):
  1. Suba a imagem em POST /api/v2/catalog/media — por bytes (multipart file) ou por URL pública ({url, external_id}; o Venty baixa e re-hospeda). Retorna {id, url, checksum, deduped}. Dedup por sha256: os mesmos bytes viram um só asset (deduped:true).
  2. Declare a galeria em PUT /api/v2/catalog/products/{id}/gallery com a lista final (por external_id). O Venty faz upsert + reordena + define capa + remove os ausentes numa call.
  3. Reordenar/atualizar = reenviar o mesmo PUT com os mesmos external_id/checksum na nova ordem. Fast-path: checksum igual ao gravado ⇒ a URL não é re-baixada.
POST /api/v2/catalog/media catalog:write

Sobe bytes OU URL. external_id opcional = sua referência ao asset. Só imagem raster (PNG/JPEG/WEBP/GIF); a URL passa por fetch protegido contra SSRF (só http(s) para IP público).

# por URL (o Venty baixa e re-hospeda + deduplica)
curl -X POST https://api.venty.me/api/v2/catalog/media \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"url":"https://erp.exemplo/img/1001.jpg","external_id":"MATNR-1001"}'
# → {"id":"…","url":"/media/…","checksum":"…","deduped":false}

Sincroniza a galeria inteira de forma declarativa. rehost (default true) baixa URLs http(s); prune (default true) remove as imagens ERP-owned ausentes. Só mexe em itens com external_id — imagens adicionadas por humanos ficam intactas.

curl -X PUT https://api.venty.me/api/v2/catalog/products/$PID/gallery \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"items":[
    {"external_id":"MATNR-1001","url":"https://erp/1001.jpg","checksum":"","is_main":true},
    {"external_id":"MATNR-1002","media_id":""}
  ]}'
# → {"synced":2,"pruned":0}

Superfície completa — todos os recursos via API

100% das ações administrativas seguras via token de máquina. O contrato exaustivo (todos os campos, todos os endpoints) está em /openapi.yaml e /llms.txt.

RecursoEscopoEndpoints
Produtos (todos os campos + variações)catalog:read/writeGET/POST/PUT /catalog/products[/:id]
Estoque (global + multi-local)catalog:read / inventory:writeGET/PUT /catalog/products/:id/inventory
Localizações (lojas/depósitos)inventory:read/writeGET/POST/PUT/DELETE /inventory/locations
Transferências (malha + crossdock)inventory:read/write/inventory/transfer-routes, /inventory/transfers/:id/{dispatch,receive,cancel}
Categoriascatalog:read/writeGET/POST/PUT/DELETE /catalog/categories
Marcascatalog:read/writeGET/POST/PUT/DELETE /catalog/brands
Atributos + valorescatalog:read/write/catalog/attributes, /catalog/attributes/values
Variações (grupos + termos)catalog:read/write/catalog/variation-groups, /catalog/variation-terms
Grupos de produtocatalog:read/write/catalog/product-groups[/:id/attributes]
Mídia/galeriacatalog:read/write/catalog/products/:id/gallery
Venda-casadacatalog:read/write/catalog/cross-sell/pools
Clientes (PF/PJ)customers:read/writeGET/POST/PUT /customers[/:id]
Pedidos (status/fulfillment)orders:read/writeGET /orders[/:id], PATCH status, POST ship, fiscal-document
Relatóriosreports:read/reports/abc, /reports/sales
Exports (bulk)por dataset/exports[/:id/download]
Fora de propósito (humano-only, por segurança): RBAC/staff, credenciais de gateway de pagamento, promover tema, domínio próprio, decisão de KYC, hard-delete. Compras/procurement (ordem de compra a fornecedor) não existe no sistema — só pedidos de venda.

Erros

HTTPQuandoCorpo
400Corpo/campo inválido{"error","code":"invalid_input","details":{campo:constraint}}
401/403Sem token / escopo insuficienteproblem+json {status,title,detail,trace_id}
404Produto não existe na loja{"error":"product not found"}
409Estoque abaixo do reservado{"error":"estoque_abaixo_do_reservado","origin_id":"…"}
429Rate limit—
Todo request v2 é auditado (api.call) com linhagem máquina→dono. Guarde o trace_id das respostas de erro para suporte.