Catálogo
Para qué sirve
Sección titulada «Para qué sirve»Con estas dos rutas tu app lee el catálogo de productos de un número de WhatsApp Business:
GET /v1/instances/{id}/business/catalog: una página de productos, con su cursor;GET /v1/instances/{id}/business/collections: las colecciones con sus productos.
Sin jid ni peer_ref se lee el catálogo del número de la instancia; con otro negocio, lo que
este número puede ver de él (lo decide WhatsApp). Es solo lectura: el motor no crea ni edita
productos. El perfil comercial (categoría, horario, dirección) está en
Etiquetas y Business.
Requisitos
Sección titulada «Requisitos»| Operación | Ruta | Scope |
|---|---|---|
| Una página del catálogo | GET /v1/instances/{id}/business/catalog |
catalog:read |
| Las colecciones | GET /v1/instances/{id}/business/collections |
catalog:read |
- Una instancia
conectado: se consulta en el momento y sin conexión responde 409no_conectada. - La vía con que el motor pide el catálogo la elige el operador en Ajustes (Catálogo comercial).
La de fábrica,
graphql, necesita el token público de WhatsApp Web cargado encomercio.token_graphql: sin él, 409via_catalogo_sin_token, sin consultar ni gastar cuota. - Cada consulta que sale a WhatsApp gasta una de la cuota comercial diaria de la app
(
comercio.consultas_dia; de fábrica, 500), que es aparte de la cuota de Contactos. Cuenta todo intento que pudo salir, también si falla.
El SDK de Go trae Catalogo y Colecciones.
1. Lee una página del catálogo
Sección titulada «1. Lee una página del catálogo»| Parámetro | Qué es |
|---|---|
jid |
El negocio: un JID de usuario @s.whatsapp.net, o un @lid con su teléfono ya comprobado por esta instancia. |
peer_ref |
O el peer_ref de un contacto de esta instancia. No va junto con jid. |
limit |
Productos por página, de 1 a 50 (por omisión, 10). Con un cursor, vale el de la primera página. |
cursor |
El next_cursor de la página anterior. |
La respuesta trae los productos de esta página y:
| Campo | Qué es |
|---|---|
has_more |
true si WhatsApp informó una continuación; null si no la informó (desconocido, nunca un false inventado). |
next_cursor |
El cursor de la página siguiente (del motor: opaco, firmado y atado a tu app, la instancia, el número vinculado, el negocio y el límite). |
continuacion |
informada o no_informada. |
paginacion |
Siempre proveedor: la continuación es la de WhatsApp. |
completitud |
Siempre pagina: el motor no recorre el catálogo entero. |
total |
Siempre null: WhatsApp no informa un total. |
origen |
whatsapp (se consultó ahora) o cache (la misma lectura de hace poco, sin volver a WhatsApp ni gastar cuota). |
fresca_hasta |
Hasta cuándo una consulta igual se responde con esta lectura. |
vence_en |
Hasta cuándo sirve el next_cursor. |
consulta_id |
La consulta que leyó esto: la de su evento query.completed. |
negocio_jid, propio |
El negocio consultado y si es el número de la instancia. |
cuota |
La cuota comercial de la app: cuota, usadas, renueva y su desglose (catalogo, colecciones). |
Cada producto trae id (opaco y del negocio, no un identificador global), nombre,
descripcion, codigo_vendedor (el retailer ID del negocio), url, precio_proveedor y
moneda, imagenes (con solicitada_url, de 100 px, y original_url: URLs de WhatsApp,
temporales, que el motor no descarga), revision (el estado de revisión, tal cual), oculto
(null si no vino), y escala_precio y disponibilidad, siempre null.
# El catálogo propio:curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/business/catalog?limit=20" \ -H "Authorization: Bearer TU_API_KEY"
# El de otro negocio, y la página siguiente con su cursor:curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/business/catalog?jid=NEGOCIO@s.whatsapp.net&limit=20" \ -H "Authorization: Bearer TU_API_KEY"curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/business/catalog?cursor=NEXT_CURSOR" \ -H "Authorization: Bearer TU_API_KEY"import motor "github.com/projectx27/motor-whatsapp/sdk/go"
c := motor.Nuevo("https://motor.ejemplo.com", apiKey)negocio := motor.Negocio{JID: "NEGOCIO@s.whatsapp.net"} // vacío: el propio
pagina, err := c.Catalogo(ctx, instanciaID, negocio, 20, "")for err == nil { for _, p := range pagina.Productos { guardar(p) // p.PrecioProveedor es texto: no lo conviertas a ciegas } // HasMore nil: WhatsApp no informó una continuación (no es «terminó»). if pagina.HasMore == nil || !*pagina.HasMore || pagina.NextCursor == nil { break } pagina, err = c.Catalogo(ctx, instanciaID, negocio, 20, *pagina.NextCursor)}
var e *motor.Errorif errors.As(err, &e) { switch e.Codigo() { case "cuota_agotada", "limitado_por_whatsapp", "vistas_agotadas": // Espera e.RetryAfter. case "cursor_vencido", "cursor_incompatible": // Vuelve a pedir la primera página. }}const base = `https://motor.ejemplo.com/v1/instances/${instanciaId}/business/catalog`;const auth = { Authorization: `Bearer ${apiKey}` };
let url = `${base}?jid=${encodeURIComponent('NEGOCIO@s.whatsapp.net')}&limit=20`;while (url) { const res = await fetch(url, { headers: auth }); const pagina = await res.json(); if (!res.ok) throw new Error(`${res.status} ${pagina.type} ${pagina.detail}`); for (const p of pagina.productos) await guardar(p); // has_more null: WhatsApp no informó una continuación (no es «terminó»). url = pagina.has_more === true && pagina.next_cursor ? `${base}?cursor=${encodeURIComponent(pagina.next_cursor)}` : null;}{ "negocio_jid": "NEGOCIO@s.whatsapp.net", "propio": false, "productos": [ { "id": "PRODUCTO_ID", "nombre": "Crema hidratante 50 ml", "descripcion": "Para piel seca", "codigo_vendedor": "SKU-0042", "precio_proveedor": "25900", "moneda": "PEN", "escala_precio": null, "disponibilidad": null, "oculto": null, "revision": "APPROVED", "imagenes": [ { "solicitada_url": "https://mmg.whatsapp.net/…", "original_url": "https://mmg.whatsapp.net/…" } ] } ], "limit": 20, "has_more": null, "next_cursor": null, "continuacion": "no_informada", "paginacion": "proveedor", "completitud": "pagina", "total": null, "origen": "whatsapp", "consulta_id": "3f9a0c11-0000-4000-8000-0000000000d4", "consultado_en": "2026-10-24T16:10:00Z", "fresca_hasta": "2026-10-24T16:11:00Z", "vence_en": "2026-10-24T16:25:00Z", "cuota": { "cuota": 500, "usadas": 16, "renueva": "2026-10-25T00:00:00Z", "desglose": [{ "tipo": "catalogo", "cantidad": 14 }, { "tipo": "colecciones", "cantidad": 2 }] }}2. Lee las colecciones
Sección titulada «2. Lee las colecciones»GET …/business/collections pide a WhatsApp una vista con collection_limit colecciones (de 1
a 20; por omisión, 10) y item_limit productos por colección (de 1 a 50; por omisión, 10). Los dos
se piden por separado y su producto no puede pasar de 200. Un producto puede estar en varias
colecciones.
Esa vista se guarda, y next_cursor la pagina localmente (paginacion: snapshot_local, de a
limit colecciones, de 1 a 20; por omisión, 10), sin volver a WhatsApp. has_more dice si hay más
colecciones en la vista guardada, no en WhatsApp.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/business/collections?collection_limit=10&item_limit=20&limit=5" \ -H "Authorization: Bearer TU_API_KEY"vista, err := c.Colecciones(ctx, instanciaID, motor.Negocio{}, motor.LimitesColecciones{ Colecciones: 10, // collection_limit Productos: 20, // item_limit (10 × 20 = 200, el máximo) Pagina: 5, // limit: colecciones por página local}, "")if err != nil { return err}if vista.PosibleTruncamiento { // La vista alcanzó un límite: puede haber más en WhatsApp.}for vista.HasMore && vista.NextCursor != nil { vista, err = c.Colecciones(ctx, instanciaID, motor.Negocio{}, motor.LimitesColecciones{}, *vista.NextCursor) if err != nil { return err }}const base = `https://motor.ejemplo.com/v1/instances/${instanciaId}/business/collections`;const auth = { Authorization: `Bearer ${apiKey}` };
let url = `${base}?collection_limit=10&item_limit=20&limit=5`;while (url) { const res = await fetch(url, { headers: auth }); const vista = await res.json(); if (!res.ok) throw new Error(`${res.status} ${vista.type} ${vista.detail}`); for (const col of vista.colecciones) await guardarColeccion(col); // has_more: hay más en la vista guardada (no en WhatsApp). url = vista.has_more && vista.next_cursor ? `${base}?cursor=${encodeURIComponent(vista.next_cursor)}` : null;}{ "negocio_jid": "TU_NUMERO@s.whatsapp.net", "propio": true, "colecciones": [ { "id": "COLECCION_ID", "nombre": "Cuidado facial", "revision": "APPROVED", "puede_apelar": null, "alcanza_limite": false, "completitud_productos": "desconocida", "productos": [{ "id": "PRODUCTO_ID", "nombre": "Crema hidratante 50 ml", "precio_proveedor": "25900", "moneda": "PEN", … }] } ], "collection_limit": 10, "item_limit": 20, "limit": 5, "has_more": false, "next_cursor": null, "total_vista": 1, "paginacion": "snapshot_local", "continuacion_remota": false, "proveedor_has_more": null, "paging_recibido": false, "posible_truncamiento": false, "completitud": "desconocida", "origen": "whatsapp", "consulta_id": "3f9a0c11-0000-4000-8000-0000000000d5", "cuota": { "cuota": 500, "usadas": 17, "renueva": "2026-10-25T00:00:00Z", "desglose": [ … ] }}La caché y los cursores
Sección titulada «La caché y los cursores»- Frescura: una consulta igual del mismo número dentro de
comercio.segundos_cache(de fábrica, 60 s; 30 s para una lista vacía) devuelve la misma lectura conorigen: cache, sin gastar cuota. Un error nunca se guarda. - Vida de un cursor:
comercio.minutos_cursor(de fábrica, 15 minutos); WhatsApp puede vencer el suyo antes. Un cursor sobrevive a un reinicio del motor. Vencido, 409cursor_vencido; de otra vinculación, otro negocio u otro límite, 409cursor_incompatible; alterado, 422cursor_invalido; de otra instancia o app, 404. En los tres primeros casos, vuelve a pedir la primera página. - Cupo por instancia: el motor guarda hasta
comercio.vistas_instanciaresultados (de fábrica, 100) y 10 MB por instancia. Con el cupo lleno, 429vistas_agotadas: nunca borra un cursor vigente. - Ritmo: una consulta a la vez por instancia, con 2 s entre dos. Si WhatsApp limita, la
instancia espera 60 s antes de la próxima, y el doble si se repite (hasta 15 minutos): 429
limitado_por_whatsappconRetry-After. - Espera: sin respuesta de WhatsApp en
comercio.segundos_timeout(de fábrica, 15 s), 504consulta_agotada, nunca una lista vacía.
Todos estos valores son Ajustes del operador.
Los eventos
Sección titulada «Los eventos»Estas rutas no crean comandos: responden en el momento. Cada consulta que sale a WhatsApp deja en
el ledger de la instancia un evento sin datos del negocio (ni productos ni el JID consultado),
con el mismo consulta_id que la respuesta. Una respuesta desde la caché no deja evento.
query.completed
Sección titulada «query.completed»La consulta terminó bien. cantidad es cuántos productos (o colecciones) leyó, vence_en hasta
cuándo sirve su cursor y, en el catálogo, continuacion dice si WhatsApp informó otra página.
{ "consulta_id": "3f9a0c11-0000-4000-8000-0000000000d4", "operacion": "catalogo", "resultado": "ok", "cantidad": 5, "vence_en": "2026-10-24T16:25:00Z"}query.failed
Sección titulada «query.failed»La consulta falló. codigo es el mismo error_code de la respuesta HTTP; via es la vía que se
pidió y server_code y server_origin, el código numérico de WhatsApp y por dónde llegó, si lo
hubo. La respuesta HTTP del error trae el consulta_id en errors.
{ "consulta_id": "3f9a0c11-0000-4000-8000-0000000000d6", "operacion": "colecciones", "codigo": "catalogo_no_disponible", "via": "graphql", "server_code": 2498055, "server_origin": "graphql"}Errores comunes
Sección titulada «Errores comunes»El error_code va en type como urn:motor-whatsapp:error:<código>; el SDK de Go lo devuelve en
Codigo().
| HTTP | error_code |
Cuándo |
|---|---|---|
| 403 | — | A tu key le falta catalog:read. |
| 403 | catalogo_no_autorizado |
WhatsApp no deja a este número ver ese catálogo (no dice por qué). |
| 403 | credencial_no_vigente |
Tu credencial dejó de autorizar la consulta mientras esperaba. |
| 404 | catalogo_no_disponible |
WhatsApp no encontró un catálogo para ese número (sin catálogo, no Business u otra causa). |
| 409 | no_conectada |
La instancia no está conectada (o se desconectó durante la consulta). |
| 409 | via_catalogo_sin_token |
La vía graphql sin el token en Ajustes. No se consultó. |
| 409 | doc_id_catalogo_invalido, variables_catalogo_invalidas |
Los ajustes de la consulta del catálogo no son válidos. No se consultó. |
| 409 | cursor_vencido, cursor_incompatible |
Vuelve a pedir la primera página. |
| 409 | observador_cambio |
La instancia cambió de número vinculado durante la consulta. |
| 422 | cursor_invalido |
El cursor fue alterado: úsalo tal cual lo dio el motor. |
| 422 | — | jid y peer_ref juntos, un peer_ref que no es válido, o collection_limit × item_limit mayor que 200. |
| 429 | cuota_agotada |
La app usó su cuota comercial de hoy (vuelve a cero a las 00:00 UTC). No se consultó. |
| 429 | limitado_por_whatsapp |
WhatsApp limitó las consultas del número: la instancia está en enfriamiento. |
| 429 | vistas_agotadas |
La instancia llegó a su cupo de resultados guardados. |
| 501 | catalogo_no_soportado |
El proveedor no consulta catálogos. |
| 502 | consulta_proveedor_rechazada |
WhatsApp rechazó la consulta. |
| 502 | respuesta_proveedor_invalida |
La respuesta no tiene el formato esperado: no se entrega como una lista vacía. |
| 502 | respuesta_proveedor_demasiado_grande |
La respuesta supera los límites del motor: pide menos con limit. |
| 502 | continuacion_sin_avance |
WhatsApp devolvió una continuación ya usada: vuelve a la primera página. |
| 503 | proveedor_temporalmente_no_disponible |
WhatsApp no está disponible para la consulta; reintenta más tarde. |
| 503 | registro_no_disponible |
La consulta terminó pero el motor no pudo registrar su resultado; reintenta. |
| 504 | consulta_agotada |
WhatsApp no contestó a tiempo: no hay resultado, tampoco uno vacío. |
Límites de WhatsApp
Sección titulada «Límites de WhatsApp»- Solo responde la vía
graphql(falla F30, corregida el 20/10/2026): WhatsApp ya no contesta el pedido viejo (iq) y pormexda 400. Congraphqly el token cargado, el catálogo y las colecciones, propios y ajenos, responden. Por eso es la vía de fábrica. - Lo oculto no aparece (caso B11, 20/10/2026): un producto oculto no llega marcado como
oculto: desaparece de la respuesta, también para el dueño. - Gratis y sin precio no se distinguen (caso B13, 20/10/2026): los dos llegan sin
precio_proveedornimoneda. El precio con valor llegó ×1000 ("25900"= S/ 25,90). - Sin cursor del servidor en catálogos chicos (casos B8 y B15, 20/10/2026): con 4 o 5
productos visibles y
limitde 1 o 2, WhatsApp devolvió todos de una vez, sin continuación. El recorrido con el cursor de WhatsApp y el cursor alterado, vencido o tras un reinicio no se pudieron probar en real (hace falta un catálogo de más de 20 productos). - Un número sin catálogo (caso B10 y falla F31, 20/10/2026): el catálogo de un número normal
dio 404
catalogo_no_disponible(código 2498052) y sus colecciones, también 404 (código 2498055), desde la corrección de la ronda 6. - Un número normal puede leer catálogos (caso B7, 20/10/2026): vio lo mismo que el dueño, sin lo oculto. Bloquear al negocio desde el observador no cambió la respuesta (B12); el caso de un negocio que bloquea al observador no se probó.
- Imágenes (caso B14, 20/10/2026): la
solicitada_urlmide 100 px de lado mayor, la original de 1080 a 1600 px, y las URLs vencieron a los ~4,5 días. - Corte y límites (casos B16 y B17, 20/10/2026): con la red cortada, 503 en 17 ms con su
query.failed, nunca una lista vacía. En unas 35 consultas del día no hubo ningún 429.