API de preços e existências

Esta API devolve, por part-number, o preço de custo e as existências em cada distribuidor da PartnerPlus, e diz qual o distribuidor mais barato que tem o artigo em stock. Foi feita para a loja online ir buscar os preços sem intervenção manual.

URL base
https://partnerplus.mobiweb.pt
Autenticação
Cabeçalho x-api-key (ou Authorization: Bearer …)
Formato
JSON, UTF-8
Moeda
Euros, sem IVA — são preços de custo do distribuidor
Atualização
Diária, às 03:00 (Europe/Lisbon)

Precisa de uma chave? Peça-a a quem administra o portal — é criada em Chaves da API, uma por consumidor, e pode ser revogada isoladamente.

Autenticação

O Authorization só é aceite com o esquema Bearer. Se enviar os dois cabeçalhos, o x-api-key ganha: um x-api-key presente mas errado faz o pedido falhar, mesmo que o Authorization traga uma chave válida.

Todos os pedidos, exceto /api/v1/health, exigem a chave. Sem ela ou com uma chave revogada, a resposta é 401.

curl -H "x-api-key: SUA_CHAVE" \
  "https://partnerplus.mobiweb.pt/api/v1/best?part_number=DS925%2B"
Part-numbers com o sinal +. Muitas referências Synology terminam em + (DS925+, DS1825+). Numa query string o + significa espaço, por isso codifique-o como %2B. A API também aceita o + literal, mas codificar é o correto.

Endpoints

GET/api/v1/best

O melhor preço de custo entre distribuidores com stock, e quem o tem. É o endpoint a usar para definir preço de venda de um artigo.

Parâmetros: part_number (obrigatório) · require_stock (por omissão true).

GET /api/v1/best?part_number=DS925%2B

{
  "part_number": "DS925+",
  "available": true,
  "cost_price": 588.23,
  "currency": "EUR",
  "distributor": "Qloudea",
  "distributor_slug": "qloudea",
  "stock": 154,
  "require_stock": true,
  "average_price": 669.75,
  "distributors_with_stock": 4,
  "distributors_with_stock_and_price": 4,
  "updated_at": "2026-09-07T13:27:55.650Z"
}
  • Com require_stock=true (o valor por omissão) a escolha é feita só entre distribuidores com stock. É este o modo a usar para definir preço de venda.
  • Com require_stock=false a escolha passa a considerar todas as ofertas, esgotadas incluídas. Serve para saber quanto o artigo custaria se houvesse stock — não dá para encomendar. Nesse modo pode vir "stock": 0 com "available": true: o available significa "existe oferta segundo o critério que pediu", e a resposta devolve o require_stock aplicado para não haver dúvida.
  • Quando não há nenhuma oferta que sirva, devolve "available": false com cost_price, distributor, distributor_slug e updated_at a null e stock: 0 — nesse caso o artigo deve ficar indisponível na loja. Os campos são sempre os mesmos, haja ou não oferta, para quem consome não ter de tratar dois formatos.
  • average_price é a média dos preços de todos os distribuidores que têm o artigo, esgotados incluídos, a duas decimais — serve para perceber se o preço escolhido é bom, não para vender. distributors_with_stock é quantos distribuidores têm existências; distributors_with_stock_and_price, quantos desses também publicam preço. Quando o segundo é zero e o primeiro não, o artigo existe em armazém mas ninguém lhe pôs preço, e o available vem false.
  • updated_at é o momento em que o distribuidor escolhido foi lido. No /api/v1/bulk o campo com o mesmo nome é a leitura mais recente entre todos os distribuidores do artigo — duas coisas parecidas mas não iguais.
GET/api/v1/prices

A comparação completa de um artigo, incluindo os distribuidores sem stock. Útil para mostrar a comparação a um humano.

GET /api/v1/prices?part_number=WD122KRYZ

{
  "part_number": "WD122KRYZ",
  "manufacturer": "WD",
  "description": "HD WD INTERNO DESKTOP ENTERPRISE 12TB 3.5 SATA WD GOLD - WD122KRYZ",
  "cheapest":          { … objeto de oferta completo, ver abaixo … },
  "cheapest_in_stock": { … objeto de oferta completo … },
  "most_expensive":    { … objeto de oferta completo … },
  "average_price": 509.86,
  "total_stock": 72,
  "distributors_with_stock": 3,
  "distributors_with_stock_and_price": 3,
  "offers": [
    {
      "distributor_slug": "wortmann",
      "distributor_name": "Wortmann",
      "part_number": "WD122KRYZ",
      "source_part_number": "WD122KRYZ",
      "manufacturer": "Western Digital",
      "description": "WD HD3.5\" SATA3-Raid 12TB WD122KRYZ / Gold (Di)",
      "cost_price": "448.0000",
      "quoted_currency": null,
      "stock": 0,
      "captured_at": "2026-09-07T13:26:47.868Z",
      "flags": { "eol": "0", "currency": "EUR", "promotion": "0", "remainingStock": "0" }
    }
  ]
}

Os três agregados — cheapest, cheapest_in_stock e most_expensive — são o objeto de oferta completo, com os mesmos onze campos que os elementos de offers, e não um resumo.

  • cheapest pode estar esgotado. Para vender, use cheapest_in_stock.
  • source_part_number é a referência como o distribuidor a escreve — é essa que serve para encomendar. Na maioria dos casos é igual ao part_number, mas em 303 das 3193 ofertas atuais não é (uma em dez), e em 240 dessas a diferença é só na caixa das letras. As restantes mudam a sério e são as que estragam encomendas: o D4-SSD é D4 SSD na Real Solutions, o Rail-E02 é RAIL-E02 no TD Synnex, o SDPHG1H018T-MBAAD é SDPHG1H-018T-MBAAD na Real Solutions, e o ES1686dc-R2-2142IT-96G é ES1686DCR22142IT96G no Ingram Micro.
  • cost_price vem como string com quatro decimais, para não perder precisão. Converta para número antes de calcular — e trate o caso null, que acontece quando o distribuidor tem o artigo mas não publica preço. Essas ofertas vêm no fim da lista, e um parseFloat cego devolve NaN.
  • quoted_currency é a moeda em que o distribuidor cota o artigo, e vem null em sete dos oito distribuidores — na prática só o Ingram Micro o declara. O cost_price está sempre em euros, mesmo quando aqui aparece USD: no único caso em que isto acontece (Ingram Micro) a especificação do próprio distribuidor garante que a coluna de preço que lemos está em euros, e a moeda de cotação serve só de aviso de que aquele artigo varia de preço com o câmbio.
  • O description e o manufacturer de topo são os do artigo: a descrição mais completa entre os distribuidores que o têm — a mais longa, descartando as que são só a repetição do part-number, com o nome do distribuidor a desempatar quando há duas do mesmo tamanho — e o nome do fabricante como consta da lista de artigos da PartnerPlus. Cada oferta dentro de offers traz também os seus, que são o texto do próprio distribuidor e podem ser diferentes. Os de topo são iguais aos do /api/v1/bulk.
  • flags é informação extra do distribuidor, tal como ele a dá. As chaves variam de distribuidor para distribuidor e não são normalizadas — por exemplo eol, promotion e remainingStock no Wortmann, canon (taxa de cópia privada) no MCR, eta e warehouse no Ingram Micro. Trate-as como texto informativo para mostrar a um humano; não construa lógica de negócio sobre elas, porque podem mudar quando o distribuidor mudar o ficheiro.
  • As offers vêm por cost_price crescente, com as que não têm preço no fim.
  • Os campos são sempre os mesmos, tenha o artigo ofertas ou não. Um artigo da lista que nenhum distribuidor tenha devolve 200 com offers: [] e com cheapest, cheapest_in_stock, most_expensive, description e average_price a null. Ao contrário do /api/v1/best, este endpoint não tem campo currency (os preços são todos em euros) nem aceita require_stock — traz sempre tudo, e a escolha é sua.
GET/api/v1/bulk

Todos os artigos de uma vez, uma linha por part-number. É este que a loja deve usar para atualizar preços em massa — um pedido em vez de milhares.

ParâmetroPor omissãoO que faz
only_in_stockfalseSó artigos com pelo menos um distribuidor com stock e com preço.
only_with_offerstrueSó artigos para os quais algum distribuidor deu preço ou stock.
manufacturer(todos)Filtra por fabricante. Ignora maiúsculas e espaços em volta.
limit5000Máximo de artigos a devolver. Entre 0 e 5000.
offset0Quantos artigos saltar, para paginar.

Os booleanos aceitam true, 1, sim e yes para verdadeiro, e false, 0, nao, não e no para falso, em maiúsculas ou minúsculas. Qualquer outro valor dá 400, em vez de ser interpretado à sorte. Um parâmetro presente mas vazio (?only_in_stock=) conta como não ter sido enviado, ficando com o valor por omissão. Repare que o only_in_stock aqui é false por omissão e no /api/v1/export é true — são pontos de partida diferentes de propósito, porque o Excel serve para carregar a loja e o JSON para consultar.

GET /api/v1/bulk?only_in_stock=true&limit=5000

{
  "count": 632,
  "filters": {
    "only_in_stock": true,
    "only_with_offers": true,
    "manufacturer": null,
    "limit": 5000,
    "offset": 0
  },
  "items": [
    {
      "part_number": "DS925+",
      "description": "Synology Disk Station DS925+ - servidor NAS",
      "manufacturer": "Synology",
      "best_cost_price": 556.13,
      "best_cost_price_in_stock": 588.23,
      "best_distributor": "Ingram Micro",
      "best_in_stock_distributor": "Qloudea",
      "average_price": 669.75,
      "highest_price": 770.90,
      "total_stock": 336,
      "distributors": 6,
      "distributors_with_stock": 4,
      "updated_at": "2026-09-07T13:27:55.650Z"
    }
  ]
}
  • best_cost_price e best_distributor são o mais barato incluindo esgotados. best_cost_price_in_stock e best_in_stock_distributor são o mais barato que dá para encomendar já — é este o par a usar para preço de venda. Vêm a null quando ninguém tem stock.
  • distributors é quantos distribuidores têm o artigo; distributors_with_stock, quantos desses têm existências. total_stock é a soma de todas as existências.
  • Ter stock não é o mesmo que dar para comprar. Há distribuidores que anunciam existências sem publicar preço ("sob consulta"). Por isso um artigo pode ter total_stock maior que zero e ao mesmo tempo best_cost_price_in_stock: null. Para decidir se põe o artigo à venda, use o best_cost_price_in_stock — ou, no /api/v1/best e no /api/v1/prices, o distributors_with_stock_and_price, que é o único contador que conta apenas quem tem as duas coisas. O distributors_with_stock conta quem tem existências, com ou sem preço, e é por isso que os dois números podem divergir.
  • average_price e highest_price são calculados sobre todas as ofertas com preço, esgotadas incluídas, a duas decimais.
  • updated_at é a leitura mais recente entre os distribuidores do artigo. (No /api/v1/best o campo homónimo refere-se apenas ao distribuidor escolhido.)
  • O filters devolve os filtros como o servidor os entendeu, para não ter de adivinhar se um filtro foi aplicado (um manufacturer vazio é ecoado como null, porque nenhum filtro foi aplicado). Um manufacturer que não exista dá 400 com a lista dos nomes válidos, em vez de uma lista vazia.
  • O count é o número de artigos nesta página, não o total: para paginar, use um limit maior que zero e avance o offset até receber uma página com menos artigos do que o limit. (Com limit=0 a resposta vem sempre vazia e esta regra não termina — não use zero para paginar.) O offset aceita até um milhão; acima disso é 400.
  • Os artigos vêm ordenados por fabricante e, dentro dele, por part-number, com a ordenação alfabética da base de dados — que ignora pontuação e caixa das letras, pelo que AS7224RDX aparece antes de AS-T10G2. Se precisar de reproduzir esta ordem do seu lado, não a compare byte a byte; prefira usar a ordem em que a recebe.
POST/api/v1/bulk

O mesmo, restrito a uma lista de part-numbers. Os que não existirem vêm com "found": false.

POST /api/v1/bulk
Content-Type: application/json

{ "part_numbers": ["ds925+", "NAO_EXISTE"] }

{
  "count": 2,
  "items": [
    {
      "part_number": "DS925+",
      "requested": "ds925+",
      "found": true,
      "description": "Synology Disk Station DS925+ - servidor NAS",
      … os mesmos campos do GET …
    },
    { "part_number": "NAO_EXISTE", "requested": "NAO_EXISTE", "found": false }
  ]
}
  • Máximo de 5000 part-numbers por pedido. Uma lista maior é recusada com 400 e a resposta diz quantos foram recebidos — nada é truncado em silêncio. Para catálogos maiores, parta a lista em pedidos de 5000. O tecto é contado sobre o que enviou, antes de retirar os repetidos: 5100 elementos com 2100 distintos são recusados.
  • O part_number da resposta não é a grafia que enviou: nos artigos encontrados é a grafia oficial da lista da PartnerPlus (que pode ter minúsculas, como em AS3302T v2) e nos não encontrados é o que enviou em maiúsculas e sem espaços em volta. A lista é desduplicada ignorando maiúsculas. Por isso cada artigo traz também requested, que é a primeira grafia que você enviou — é esse o campo a usar para casar a resposta com a sua lista.
  • Sem part_numbers, com um array vazio, ou com corpo que não seja JSON válido, devolve 400.
GET/api/v1/export

A mesma informação em Excel (.xlsx), para quem preferir ficheiro a JSON. Aceita only_in_stock (por omissão true, ao contrário do /api/v1/bulk), only_with_offers e manufacturer.

Os dois booleanos dão três âmbitos, e a diferença entre eles é grande — de 2441 artigos controlados, a maioria não é vendida por nenhum destes distribuidores:

(por omissão)Só o que dá para encomendar já: com stock e com preço.
only_in_stock=falseTodos os que algum distribuidor tem, esgotados incluídos.
only_in_stock=false&only_with_offers=falseA lista inteira. Os artigos que nenhum distribuidor tem saem com preço, stock e distribuidor em branco — servem para ver o que falta.

O ficheiro tem uma folha de preços com dezasseis colunas e uma segunda folha Info que explica coluna a coluna o que cada uma significa e quando foi gerado. Além das grandezas do JSON, traz o stock do distribuidor que ganha a comparação e o segundo distribuidor com stock, com o seu preço e existências — sem isso, um artigo que dois distribuidores têm parece ter só um, que é o nome que cabe na linha. Não traz o contador distributors.

A coluna Preço de custo é sempre a oferta que dá para encomendar já, e fica vazia quando nenhum distribuidor tem existências e preço ao mesmo tempo — corresponde ao best_cost_price_in_stock do JSON, nunca ao best_cost_price. O preço da oferta esgotada vive apenas nas duas últimas colunas. Ou seja, com only_in_stock=false há linhas com preço em branco: são artigos que não devem ir à venda. Repare que pode haver preço em branco com Stock total maior que zero — é o caso de um artigo que alguém tem mas sem preço publicado (sob consulta); não é uma incoerência do ficheiro.

A coluna Atualizado em é uma data do Excel em UTC, não texto ISO como no JSON: em Portugal no verão está uma hora atrás da hora local. Não tem limit nem offset — vem sempre o catálogo inteiro que o filtro deixar passar.

GET/api/v1/status

Estado do sistema: quando correu a última recolha, e por distribuidor se correu bem, de que dia são os dados e quantos artigos foram encontrados. Serve para monitorização.

GET /api/v1/status

{
  "watchlist": { "total": 2441, "active": 2441 },
  "offers":    { "total": 3193, "partNumbers": 978, "inStock": 1456 },
  "last_run":  {
    "id": 13, "trigger": "schedule", "status": "ok",
    "started_at": "2026-09-07T02:00:03.118Z",
    "finished_at": "2026-09-07T02:03:22.737Z"
  },
  "distributors": [
    {
      "slug": "cpcdi", "name": "CPCDI",
      "enabled": true, "source_type": "upload",
      "last_status": "warning",
      "rows_read": 16322, "rows_matched": 169,
      "error": null,
      "warnings": ["O ficheiro foi gerado em 2026-08-27, há 11 dias. …"],
      "last_collected_at": "2026-09-07T02:01:11.402Z",
      "source_date": "2026-08-27",
      "source_age_days": 11
    }
  ]
}
  • Duas datas, e a diferença importa. last_collected_at é quando nós lemos o ficheiro; source_date é o dia a que os preços do distribuidor se referem, e source_age_days é a idade em dias. Para vigiar frescura, use o source_age_days: é um número, não precisa de ler texto.
  • last_status é ok, warning ou error. O warning quer dizer que a leitura correu mas há algo a saber — tipicamente dados antigos; o texto está em warnings, em português, para uma pessoa ler. Atenção: um distribuidor pode ter warnings e continuar em ok — há notas apenas informativas (por exemplo "cabeçalho encontrado na linha 2") que ficam registadas mas não mudam o estado. Para vigiar, use o last_status e o source_age_days, nunca o número de elementos em warnings.
  • rows_read são as linhas do ficheiro do distribuidor; rows_matched, quantas dessas correspondem a artigos da lista da PartnerPlus. Uma queda brusca do segundo com o primeiro estável significa normalmente que o distribuidor mudou o formato.
  • source_type diz de onde vêm os dados: ftp, sftp e http são automáticos; upload depende de alguém submeter o ficheiro no portal.
  • Neste endpoint, e só neste, os nomes dos contadores estão em camelCase (offers.partNumbers) por razões históricas. O resto da API usa snake_case.
  • enabled diz se o distribuidor está a ser recolhido. last_run.id é o número sequencial da recolha, útil só para referir uma corrida concreta.
  • O last_run.trigger é schedule na recolha automática, manual quando alguém clica no portal e api quando vem do /api/v1/refresh. Não há campo com a hora do próximo agendamento.
POST/api/v1/refresh

Dispara uma recolha imediata. Raramente é necessário, porque a recolha automática corre todas as noites.

Não tem parâmetros nem corpo. Responde 200 assim que a recolha é aberta, com o número da corrida — a recolha em si continua depois da resposta: {"started":true,"run_id":18,"message":"…"}. Não espere pelo resultado neste pedido; acompanhe no /api/v1/status, onde o last_run passa a ok quando termina. Aceita a chave de API ou a sessão do portal.

GET/api/v1/health

Sem autenticação. Devolve {"status":"ok","time":"2026-09-07T14:28:23.836Z"} se o sistema responde e a base de dados está acessível, e 503 com "status": "error" se não. É o único endpoint que ignora a chave de API.

Códigos de resposta

CódigoSignificado
200Pedido bem sucedido.
400Falta um parâmetro obrigatório, um parâmetro tem um valor que não serve (um limit que não é inteiro, um booleano que não é true nem false, um fabricante que não existe, mais de 5000 part-numbers), ou o corpo do pedido é inválido. A resposta diz sempre qual é o parâmetro.
401Chave ausente, inválida ou revogada.
404O part-number não está na lista de artigos controlados, ou o endereço não existe.
405Método errado para aquele endereço (por exemplo POST em /api/v1/best). É o único código que responde sem corpo.
503A base de dados está inacessível (só em /health).

Um part-number que exista na lista mas que nenhum distribuidor tenha devolve 200 com offers: [] — é diferente de 404, que significa que o artigo não está a ser controlado.

Exemplo: sincronizar com WooCommerce

Percorre os artigos, encontra o produto pelo SKU e guarda o preço de custo em meta. O cálculo do preço de venda fica onde já estava — este sistema não define preços de venda.

<?php
/**
 * Sincroniza os precos de custo dos distribuidores com os produtos.
 * Agendar depois das 03:00, hora a que a recolha termina.
 */
const PP_BASE    = 'https://partnerplus.mobiweb.pt';
const PP_API_KEY = '...';   // a chave criada no portal

function pp_sync_custos(): void {
    $res = wp_remote_get(
        PP_BASE . '/api/v1/bulk?only_in_stock=true&limit=5000',
        [ 'headers' => [ 'x-api-key' => PP_API_KEY ], 'timeout' => 120 ]
    );

    if ( is_wp_error( $res ) || 200 !== wp_remote_retrieve_response_code( $res ) ) {
        error_log( '[partnerplus] falha ao obter precos' );
        return;
    }

    $items = json_decode( wp_remote_retrieve_body( $res ), true )['items'] ?? [];

    foreach ( $items as $item ) {
        $product_id = wc_get_product_id_by_sku( $item['part_number'] );
        if ( ! $product_id ) {
            continue;
        }
        $product = wc_get_product( $product_id );

        $product->update_meta_data( '_pp_custo',        $item['best_cost_price_in_stock'] );
        $product->update_meta_data( '_pp_distribuidor', $item['best_in_stock_distributor'] );
        $product->update_meta_data( '_pp_stock_total',  $item['total_stock'] );
        $product->update_meta_data( '_pp_atualizado',   $item['updated_at'] );

        // Aplicar aqui as regras de margem da PartnerPlus:
        // $product->set_regular_price( pp_calcular_pvp( $item ) );

        $product->save();
    }
}
add_action( 'pp_sync_diario', 'pp_sync_custos' );

if ( ! wp_next_scheduled( 'pp_sync_diario' ) ) {
    wp_schedule_event( strtotime( 'tomorrow 04:00' ), 'daily', 'pp_sync_diario' );
}
php
Notas de integração. O part-number corresponde ao SKU do produto; a comparação ignora maiúsculas e espaços. Prefira o /api/v1/bulk a percorrer /api/v1/best artigo a artigo — é um pedido em vez de milhares. E verifique sempre best_cost_price_in_stock: se vier null, ninguém tem o artigo disponível.

Frescura dos dados

Os preços mudam com frequência neste mercado, por isso a API distingue quando processámos de quando o distribuidor gerou o ficheiro. O updated_at de cada artigo é o momento em que nós lemos; o /api/v1/status dá, por distribuidor, a data a que os preços se referem (source_date) e a sua idade em dias (source_age_days).

Cinco distribuidores são recolhidos automaticamente todas as noites. Três dependem de alguém submeter o ficheiro no portal, pelo que os seus dados podem ter alguns dias — o /api/v1/status deixa isso explícito no source_age_days de cada distribuidor.

A recolha automática corre às 03:00 e termina por volta das 03:05, mas pode haver recolhas fora dessa hora: alguém pode disparar uma no portal ou pelo /api/v1/refresh a qualquer momento. Não conte, por isso, que o updated_at seja sempre da madrugada — se precisar de saber de que corrida vieram os dados, veja o last_run do /api/v1/status.

Não há limite de pedidos por minuto, e por isso também não há cabeçalhos de quota a que se possa reagir. O sistema serve um número pequeno de integrações conhecidas; se precisar de percorrer o catálogo, use o /api/v1/bulk num pedido em vez de milhares de pedidos individuais. O único limite declarado é os 5000 part-numbers por POST /api/v1/bulk.

Os endereços são sensíveis a maiúsculas (/api/v1/BEST dá 404) e uma barra no fim provoca um 308 para o endereço sem barra — siga o redirecionamento ou escreva o endereço sem barra. Se repetir um parâmetro no mesmo pedido, vale o primeiro.

Parâmetros que não existam são ignorados em silêncio, e a resposta vem com 200 — escrever part_numbers onde se queria part_number não dá erro, dá uma resposta que não é a que pediu. Confirme os nomes contra esta página, e no /api/v1/bulk use o filters da resposta para ver o que foi de facto aplicado.

A ordenação e os nomes dos campos descritos nesta página fazem parte do contrato: se algum tiver de mudar, muda com aviso e com uma versão nova do caminho (/api/v2/…). Campos novos podem aparecer sem aviso, pelo que quem integra deve ignorar o que não conhece em vez de rejeitar a resposta.