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(ouAuthorization: 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"+ (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
/api/v1/bestO 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=falsea 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": 0com"available": true: oavailablesignifica "existe oferta segundo o critério que pediu", e a resposta devolve orequire_stockaplicado para não haver dúvida. - Quando não há nenhuma oferta que sirva, devolve
"available": falsecomcost_price,distributor,distributor_slugeupdated_atanullestock: 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 oavailablevemfalse.updated_até o momento em que o distribuidor escolhido foi lido. No/api/v1/bulko campo com o mesmo nome é a leitura mais recente entre todos os distribuidores do artigo — duas coisas parecidas mas não iguais.
/api/v1/pricesA 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.
cheapestpode estar esgotado. Para vender, usecheapest_in_stock.source_part_numberé a referência como o distribuidor a escreve — é essa que serve para encomendar. Na maioria dos casos é igual aopart_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: oD4-SSDéD4 SSDna Real Solutions, oRail-E02éRAIL-E02no TD Synnex, oSDPHG1H018T-MBAADéSDPHG1H-018T-MBAADna Real Solutions, e oES1686dc-R2-2142IT-96GéES1686DCR22142IT96Gno Ingram Micro.cost_pricevem como string com quatro decimais, para não perder precisão. Converta para número antes de calcular — e trate o casonull, que acontece quando o distribuidor tem o artigo mas não publica preço. Essas ofertas vêm no fim da lista, e umparseFloatcego devolveNaN.quoted_currencyé a moeda em que o distribuidor cota o artigo, e vemnullem sete dos oito distribuidores — na prática só o Ingram Micro o declara. Ocost_priceestá sempre em euros, mesmo quando aqui apareceUSD: 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
descriptione omanufacturerde 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 deofferstraz 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 exemploeol,promotioneremainingStockno Wortmann,canon(taxa de cópia privada) no MCR,etaewarehouseno 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
offersvêm porcost_pricecrescente, 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 comcheapest,cheapest_in_stock,most_expensive,descriptioneaverage_priceanull. Ao contrário do/api/v1/best, este endpoint não tem campocurrency(os preços são todos em euros) nem aceitarequire_stock— traz sempre tudo, e a escolha é sua.
/api/v1/bulkTodos 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âmetro | Por omissão | O que faz |
|---|---|---|
only_in_stock | false | Só artigos com pelo menos um distribuidor com stock e com preço. |
only_with_offers | true | Só artigos para os quais algum distribuidor deu preço ou stock. |
manufacturer | (todos) | Filtra por fabricante. Ignora maiúsculas e espaços em volta. |
limit | 5000 | Máximo de artigos a devolver. Entre 0 e 5000. |
offset | 0 | Quantos 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_priceebest_distributorsão o mais barato incluindo esgotados.best_cost_price_in_stockebest_in_stock_distributorsão o mais barato que dá para encomendar já — é este o par a usar para preço de venda. Vêm anullquando 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_stockmaior que zero e ao mesmo tempobest_cost_price_in_stock: null. Para decidir se põe o artigo à venda, use obest_cost_price_in_stock— ou, no/api/v1/beste no/api/v1/prices, odistributors_with_stock_and_price, que é o único contador que conta apenas quem tem as duas coisas. Odistributors_with_stockconta quem tem existências, com ou sem preço, e é por isso que os dois números podem divergir. average_priceehighest_pricesã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/besto campo homónimo refere-se apenas ao distribuidor escolhido.)- O
filtersdevolve os filtros como o servidor os entendeu, para não ter de adivinhar se um filtro foi aplicado (ummanufacturervazio é ecoado comonull, porque nenhum filtro foi aplicado). Ummanufacturerque 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 umlimitmaior que zero e avance ooffsetaté receber uma página com menos artigos do que olimit. (Comlimit=0a resposta vem sempre vazia e esta regra não termina — não use zero para paginar.) Ooffsetaceita 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
AS7224RDXaparece antes deAS-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.
/api/v1/bulkO 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_numberda resposta não é a grafia que enviou: nos artigos encontrados é a grafia oficial da lista da PartnerPlus (que pode ter minúsculas, como emAS3302T 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émrequested, 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.
/api/v1/exportA 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=false | Todos os que algum distribuidor tem, esgotados incluídos. |
only_in_stock=false&only_with_offers=false | A 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.
/api/v1/statusEstado 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, esource_age_daysé a idade em dias. Para vigiar frescura, use osource_age_days: é um número, não precisa de ler texto. last_statuséok,warningouerror. Owarningquer dizer que a leitura correu mas há algo a saber — tipicamente dados antigos; o texto está emwarnings, em português, para uma pessoa ler. Atenção: um distribuidor pode terwarningse continuar emok— há notas apenas informativas (por exemplo "cabeçalho encontrado na linha 2") que ficam registadas mas não mudam o estado. Para vigiar, use olast_statuse osource_age_days, nunca o número de elementos emwarnings.rows_readsã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_typediz de onde vêm os dados:ftp,sftpehttpsão automáticos;uploaddepende 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. enableddiz 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éschedulena recolha automática,manualquando alguém clica no portal eapiquando vem do/api/v1/refresh. Não há campo com a hora do próximo agendamento.
/api/v1/refreshDispara 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.
/api/v1/healthSem 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ódigo | Significado |
|---|---|
| 200 | Pedido bem sucedido. |
| 400 | Falta 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. |
| 401 | Chave ausente, inválida ou revogada. |
| 404 | O part-number não está na lista de artigos controlados, ou o endereço não existe. |
| 405 | Método errado para aquele endereço (por exemplo POST em /api/v1/best). É o único código que responde sem corpo. |
| 503 | A 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/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.
