Guia

Leituras e entrega

A leitura é um recurso: você lista o que já rodou ("a das 10h"), acompanha o que está rodando e drena os itens com cursor retomável. Os itens vêm no vocabulário schema.org Product+Offer - o mesmo que os sites publicam, sem manual pra decorar.

As rotas

GET /v1/reads                          as leituras (filtros: store, channel, período, status, item_list)
GET /v1/reads/{read_id}                o resumo de uma leitura
GET /v1/reads/{read_id}/items          os itens dela - é aqui que você consome
GET /v1/{store}/products/{id}          consulta pontual - {id} = catálogo, sku OU o seu alias
GET /v1/{store}/products/{id}/history  série temporal (90 dias)

{store} aceita o slug (mercadolivre) ou o id numérico que você já usa (468): número é sempre store_id, texto é sempre slug. Loja que não atendemos responde 404; item sem oferta não é 404, é resposta (NO_OFFERS).

{id} segue a mesma ideia: aceita o id de catálogo (MLB16039858), o sku ou o seu alias (MLB25708216-DIVINA_FARMA-PRICE-F-1) - você nunca precisa fazer o de/para ao contrário para perguntar por um item. A resposta volta na chave que você pediu.

O item_list também aceita um alias (MLB22558754-ABCDACONSTRUCAO-PRICE-F-1) ou só a cauda dele: a gente lê a lista de dentro. O alias parece conter a lista, mas ABCDACONSTRUCAO-PRICE-F-1 e ABCDACONSTRUCAO-F não são a mesma string - colar as duas pontas é trabalho nosso.

"Sem oferta" tem dois motivos diferentes: monitored, not read yet é item seu, na sua lista, que ainda não teve leitura própria - é só esperar. item not monitored é referência que nenhuma linha sua nomeia - aí é corrigir a lista. Enquanto uma carteira nova entra, o primeiro é a maioria: a identidade de um item se lê uma vez.

Filtrar por lista: GET /v1/reads?item_list=KABUM-F devolve só as leituras daquela lista, com o nome que você deu a ela. Não diferencia maiúscula de minúscula, e lista que não existe devolve vazio - nunca a carteira inteira. Combina com os outros filtros (?item_list=KABUM-F&status=DONE).

Qual leitura é esta

Uma lista pode ser lida várias vezes ao dia - a KABUM-F é lida às 00:30, 06:30 e 11:30 - e as três respondem o mesmo item_list. O que as separa é scan (1, 2, 3… dentro da sua lista) e scan_time, o horário em que ela é configurada.

Não use scheduled_for para isso. Ele é o instante em que a leitura de fato rodou: numa re-execução, as leituras do dia de uma mesma lista disparam com segundos de diferença, e a atribuição por horário erraria.

O seu código de volta

product.external_id é o seu alias, devolvido exatamente como chegou na sua lista (MLB25708216-DIVINA_FARMA-PRICE-F-1). product.id é o id de catálogo da loja - nosso, de descobrir. Sem o primeiro, você manteria à mão um de/para de algo que a gente já sabe.

Ele é por lista: o mesmo item pode ser monitorado por dois clientes finais seus, com dois códigos, e cada leitura devolve o daquela lista - inclusive quando as duas apontam para a mesma página, que a gente lê uma vez e entrega duas. external_id vem null só quando não temos nenhuma linha sua daquele item, tipicamente numa leitura de vitrine, que varre a loja inteira e traz itens que você nunca nos mandou. Nunca o código do cliente ao lado, porque código errado reconcilia em silêncio contra o item errado.

Sem oferta não é uma coisa só

NO_OFFERS vem com no_offers_reason, porque o campo misturava fatos diferentes:

É um campo, não um quarto status: quem já trata NO_OFFERS continua funcionando sem mexer em nada.

Item sem oferta volta com o veredito daquela leitura e offers vazio - a entrega diz o que esta leitura viu, e não carrega preço de leitura anterior (o campo last_known_price saiu em 31/08/2026). Preço já coletado você consulta onde ele mora: GET /v1/{store}/products/{id} e .../history.

Contexto na raiz: uma leitura = um contexto

site é uma leitura, app é outra, app + RS é outra. O contexto qualifica a leitura inteira e vem na raiz de todo envelope - nunca repetido item a item, sempre presente, com null explícito no que não se aplica.

{
  "channel": "app",                // site | app | in_store
  "region": {
    "country": "BR", "state": null, "city": null,
    "postal_code": "93265001", "lat": null, "lng": null,
    "branch_id": "3078"          // filial, quando a leitura é de loja física
  }
}

O resumo de uma leitura

GET /v1/reads/{read_id}
{
  "read_id": 4821,
  "store": "mercadolivre", "store_id": 468,
  "context": { ... },
  "scheduled_for": "2026-08-05T10:00:00-03:00",  // o slot ("a das 10h")
  "started_at": "2026-08-05T10:00:12-03:00",
  "finished_at": null,                            // null enquanto roda
  "status": "RUNNING",                            // RUNNING | DONE | PARTIAL
  "items": { "expected": 52000, "ok": 47570, "no_offers": 4210, "no_results": 0, "failed": 110 }
}

Os contadores batem 1:1 com os status dos itens em /items. PARTIAL = terminou com item FAILED (falha nossa, nunca cobrada).

Os itens: cursor retomável

GET /v1/reads/{read_id}/items?after=&limit=

Ordem sempre por item_id crescente (ordem de landing). after é o último item_id que você recebeu: parou na página 10, recomeça de onde parou, sem reprocessar e sem pular. Idempotente. Com a leitura RUNNING, item novo entra no fim (id maior) - dá pra consumir enquanto roda.

{
  "item_id": 2049,
  "status": "OK",                     // OK | NO_OFFERS | NO_RESULTS | FAILED
  "collected_at": "2026-08-05T10:00:00-03:00",
  "product": {
    "id": "MLB1234567890", "name": "Torneira Docol Lift 00872006",
    "brand": "Docol", "gtin": "7891461329723", "sku": "00872006",
    "url": "https://...", "image": "https://...",
    "category": ["Casa", "Banheiros", "Torneiras"]
  },
  "offer_count": 2,                    // ofertas LIDAS, nunca o que o site anuncia
  "offers": [{
    "rank": 1,                         // ordem que a loja serviu
    "is_featured": true,               // ganhou a buybox / oferta em destaque
    "price": 199.90, "list_price": 249.90, "price_currency": "BRL",
    "availability": "InStock",
    "seller": { "id": "1234567", "name": "Loja Y", "is_official": false },
    "installments": { "count": 12, "value": 18.90 },
    "payment_prices": { "pix": 189.90 }
  }]
}

offers é sempre lista, mesmo com uma oferta só: loja com buybox e loja sem buybox têm a mesma forma - um parser só. rank 1 é a ordem da loja, que nem sempre é o mais barato: não reordenamos por preço.

Leitura de listagem: a posição na relação

Uma listagem - termo de busca, categoria ou departamento - é uma leitura e sai pelas mesmas rotas: mesmo envelope, mesmo cursor, contexto na raiz. Uma leitura = uma fonte: o termo tenis nike das 9h e a categoria Calçados das 9h são duas leituras, cada uma com seu read_id. Elas aparecem em GET /v1/reads junto com as demais, e quem só quer listagem filtra pelas que trazem listing preenchido.

GET /v1/reads/{read_id}/items
{
  "read_id": 5104,
  "store": "mercadolivre", "store_id": 468,
  "context": { ... },                     // na raiz: a posição é sempre a DESTE contexto
  "status": "DONE",
  "listing": {
    "source": { "id": 7, "type": "SEARCH",      // SEARCH = termo | SECTOR = categoria/departamento
                "name": "tenis nike",
                "url": "https://lista.mercadolivre.com.br/tenis-nike" },
    "positions": 63,                    // posições entregues por esta leitura
    "last_rank": 63,                    // a posição em que a relação terminou
    "complete": true                    // a relação foi varrida até o fim
  },
  "totals": { "expected": 63, "ok": 63,   // a LEITURA inteira -- mesmo bloco que /reads traz como "items"
               "no_offers": 0, "no_results": 0, "failed": 0 },
  "count": 63,                         // itens NESTA página (não o total: esse é totals.expected)
  "next_after": null,
  "items": [{
    "item_id": 90114,
    "rank": 1,                            // a posição na relação exibida
    "status": "OK",
    "collected_at": "2026-08-05T09:01:00-03:00",
    "product": { "id": "MLB101", "url": "https://...", "name": "Tenis Nike Revolution" },
    "offer_count": 0, "offers": []
  }]
}

A ordem do cursor é a ordem da relação. A varredura entrega a página 1 antes da 2 e a posição 1 antes da 2, então drenar por ?after= devolve o ranking na ordem; rank diz a posição explicitamente em vez de deixá-la implícita.

Posição não é oferta. A listagem lê a relação, não o preço de cada item: os itens vêm com offers: []. Para preço, use a leitura de item (/v1/{store}/products/{id}). A identidade garantida do item é id + url; name vem preenchido quando já conhecemos o item.

A posição só significa algo com o contexto. Marketplace personaliza e regionaliza ranking: posição 3 é sempre posição 3 no contexto declarado, nunca absoluta. Por isso o context está na raiz da resposta, e não por item.

complete: false nunca é passado por fim de relação. Enquanto a leitura roda, e quando uma página dela não pôde ser lida, não afirmamos que a relação acabou: last_rank diz o que foi entregue, e nada além disso.

NO_RESULTS é resposta, não falha. Relação lida e vazia vem como um item de status NO_RESULTS, sem product e sem rank - e é cobrada, como qualquer resposta. Falha de coleta continua FAILED e nunca é cobrada.

Re-ler não duplica. Rodar a mesma leitura de novo move a posição do item no lugar; o item_id dele (o cursor) é preservado.

Consulta pontual e histórico

GET /v1/{store}/products/{id}

A leitura mais recente de um item, no mesmo formato dos itens acima (envelope com request_id, context na raiz e results). query devolve exatamente o que você mandou - reconciliação sem depender de ordem.

GET /v1/{store}/products/{id}/history?from=&to=

Uma entrada por leitura passada (instante + ofertas), mais nova primeiro, janela de 90 dias.

Cobrança

O faturável nasce na coleta, não aqui: consultar esta API - paginação, retry, reprocessamento - nunca cobra a mais. FAILED (falha nossa) nunca é cobrado. Detalhes em Uso e faturamento.

Webhook (previsto)

Aviso por leitura (nunca por item): um POST com { "event": "read.finished", "read": { ...o resumo... } } quando a leitura fechar. Ainda não implementado; até lá, GET /v1/reads?status=RUNNING cobre o caso.