Etiquetas y Business
Para qué sirve
Sección titulada «Para qué sirve»Estas rutas cubren dos cosas de WhatsApp que se suelen pedir para un número de negocio:
- Las etiquetas de WhatsApp del número: crearlas, editarlas, eliminarlas y ponerlas o quitarlas de un chat o de un mensaje. Viven en la sincronización de la app de WhatsApp (el appstate) y el teléfono las ve. En WhatsApp Business se ven con su color; en un número normal son las Listas del teléfono.
- El perfil comercial de un número: categoría, descripción, dirección, correo, sitios y horario, tal como los entrega WhatsApp.
Requisitos
Sección titulada «Requisitos»| Operación | Ruta | Scope |
|---|---|---|
| Listar las etiquetas | GET /v1/instances/{id}/labels |
labels:read |
| Crear una etiqueta | POST /v1/instances/{id}/labels |
labels:manage |
| Editar una etiqueta | PATCH /v1/instances/{id}/labels/{label_id} |
labels:manage |
| Eliminar una etiqueta | DELETE /v1/instances/{id}/labels/{label_id} |
labels:manage |
| Poner o quitar en un chat o un mensaje | POST …/labels/{label_id}/associations |
labels:manage |
| Ver la sincronización | GET /v1/instances/{id}/appstate |
chats:read |
| Pedir una sincronización completa | POST /v1/instances/{id}/appstate/resync |
chats:manage |
| El perfil comercial | GET /v1/instances/{id}/business/profile |
contacts:lookup |
- Los comandos de etiquetas piden una
Idempotency-Key(de 1 a 200 caracteres) y se ejecutan con la instancia conectada; sin conexión vuelven a la cola. - El perfil comercial se consulta en el momento: sin conexión, 409
no_conectada. Gasta una consulta de la cuota diaria de la app (la misma de Contactos). - Funciona con un número de WhatsApp Business y con uno normal; ver los límites.
Los ejemplos de Go y JavaScript usan una función auxiliar, llamar, que se define en el primer
paso: el SDK de Go no trae métodos de etiquetas, así que es HTTP directo con el token del cliente.
El perfil comercial sí tiene método en el SDK.
1. Lista las etiquetas
Sección titulada «1. Lista las etiquetas»GET /v1/instances/{id}/labels devuelve las etiquetas en el orden de su ID, de a limit (1 a 200;
por omisión, 100), con next_cursor y has_more. Cada una trae label_id (el ID de WhatsApp, un
texto), nombre, color (el índice de la paleta de WhatsApp, de 0 a 19), predefinida (el ID de
una etiqueta predefinida de Business), orden, cuántos chats y mensajes la tienen y
eliminada. Las eliminadas solo salen con incluir_eliminadas=true.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/labels?limit=100" \ -H "Authorization: Bearer TU_API_KEY"import motor "github.com/projectx27/motor-whatsapp/sdk/go"
// llamar: el SDK no trae las etiquetas, así que es HTTP directo con el token del cliente.// clave va como Idempotency-Key (vacía en las consultas).func llamar(ctx context.Context, c *motor.Cliente, metodo, ruta, clave string, cuerpo, destino any) error { var r io.Reader if cuerpo != nil { b, err := json.Marshal(cuerpo) if err != nil { return err } r = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, metodo, c.Base+ruta, r) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+c.Token) if cuerpo != nil { req.Header.Set("Content-Type", "application/json") } if clave != "" { req.Header.Set("Idempotency-Key", clave) } res, err := c.HTTP.Do(req) if err != nil { return err } defer res.Body.Close() if res.StatusCode >= 300 { return fmt.Errorf("motor: %d", res.StatusCode) } if destino == nil { return nil } return json.NewDecoder(res.Body).Decode(destino)}
// En tu código:c := motor.Nuevo("https://motor.ejemplo.com", apiKey)var pagina struct { Labels []struct { LabelID string `json:"label_id"` Nombre string `json:"nombre"` Color int `json:"color"` Chats int `json:"chats"` } `json:"labels"` NextCursor string `json:"next_cursor"` HasMore bool `json:"has_more"`}err := llamar(ctx, c, http.MethodGet, "/v1/instances/"+instanciaID+"/labels", "", nil, &pagina)const base = 'https://motor.ejemplo.com';
// llamar: una petición a /v1 con el token; clave va como Idempotency-Key.async function llamar(metodo, ruta, { clave, cuerpo } = {}) { const headers = { Authorization: `Bearer ${apiKey}` }; if (cuerpo !== undefined) headers['Content-Type'] = 'application/json'; if (clave) headers['Idempotency-Key'] = clave; const res = await fetch(base + ruta, { method: metodo, headers, body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo), }); const datos = await res.json().catch(() => null); if (!res.ok) throw new Error(`${res.status} ${datos?.type ?? ''} ${datos?.detail ?? ''}`); return datos;}
const { labels, next_cursor, has_more } = await llamar('GET', `/v1/instances/${instanciaId}/labels`);{ "labels": [ { "label_id": "19", "nombre": "Pedido pendiente", "color": 3, "eliminada": false, "chats": 4, "mensajes": 0, "actualizada_en": "2026-10-24T15:50:00Z" } ], "has_more": false}2. Crea, edita y elimina etiquetas
Sección titulada «2. Crea, edita y elimina etiquetas»| Operación | Ruta | Cuerpo | Evento |
|---|---|---|---|
| Crear | POST …/labels |
nombre (1 a 100 caracteres) y color (0 a 19; por omisión, 0) |
label.created |
| Editar | PATCH …/labels/{label_id} |
nombre y/o color: lo que no mandas queda igual |
label.updated |
| Eliminar | DELETE …/labels/{label_id} |
sin cuerpo | label.deleted |
Los tres son el comando label.edit. Al crear, el motor trae primero las etiquetas del teléfono y
usa el ID siguiente al mayor, así que no pisa una que ya existe ni reutiliza el de una eliminada.
Eliminar la quita también de los chats y los mensajes que la tenían.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/labels \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: etiqueta-pedido-pendiente" \ -d '{"nombre": "Pedido pendiente", "color": 3}'
curl -X PATCH https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/labels/19 \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: etiqueta-19-color-9" \ -d '{"color": 9}'// llamar: la función auxiliar del paso 1.ruta := "/v1/instances/" + instanciaID + "/labels"
var cmd motor.Comandoerr := llamar(ctx, c, http.MethodPost, ruta, "etiqueta-pedido-pendiente", map[string]any{"nombre": "Pedido pendiente", "color": 3}, &cmd)
err = llamar(ctx, c, http.MethodPatch, ruta+"/19", "etiqueta-19-color-9", map[string]any{"color": 9}, &cmd)
err = llamar(ctx, c, http.MethodDelete, ruta+"/19", "etiqueta-19-eliminar", nil, &cmd)const ruta = `/v1/instances/${instanciaId}/labels`;
await llamar('POST', ruta, { clave: 'etiqueta-pedido-pendiente', cuerpo: { nombre: 'Pedido pendiente', color: 3 },});
await llamar('PATCH', `${ruta}/19`, { clave: 'etiqueta-19-color-9', cuerpo: { color: 9 } });
await llamar('DELETE', `${ruta}/19`, { clave: 'etiqueta-19-eliminar' });3. Pon o quita una etiqueta de un chat
Sección titulada «3. Pon o quita una etiqueta de un chat»POST …/labels/{label_id}/associations con destino (el chat: telefono, jid o peer_ref) y
asociar (true la pone, false la quita) es el comando label.chat. Con objetivo
(provider_id de un mensaje de ese chat) es label.message y la etiqueta va en el mensaje. Llega
label.associated o label.dissociated.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/labels/19/associations \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: etiqueta-19-chat-cliente-1042" \ -d '{"destino": {"peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1"}, "asociar": true}'// llamar: la función auxiliar del paso 1.var cmd motor.Comandoerr := llamar(ctx, c, http.MethodPost, "/v1/instances/"+instanciaID+"/labels/19/associations", "etiqueta-19-chat-cliente-1042", map[string]any{ "destino": map[string]string{"peer_ref": chatRef}, "asociar": true, }, &cmd)await llamar('POST', `/v1/instances/${instanciaId}/labels/19/associations`, { clave: 'etiqueta-19-chat-cliente-1042', cuerpo: { destino: { peer_ref: chatRef }, asociar: true },});4. Revisa la sincronización
Sección titulada «4. Revisa la sincronización»Las etiquetas viven en la colección regular del appstate (regular_low y regular_high llevan
el estado de los chats: fijar, archivar, leído, silenciar, destacar, borrar).
GET …/appstate dice, por colección, su estado (pendiente, al_dia, error o
clave_faltante), su version, el último fullSync, el error con su proximo_intento_en y si
el motor le pidió una copia al teléfono porque una clave no llegaba (recuperacion_en).
POST …/appstate/resync (comando appstate.resync, scope chats:manage) vuelve a traer completas
las colecciones que indiques (regular, regular_low, regular_high; vacío, las tres).
Repetirlo no duplica nada.
curl https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/appstate \ -H "Authorization: Bearer TU_API_KEY"
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/appstate/resync \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: resync-etiquetas-2026-10-24" \ -d '{"colecciones": ["regular"]}'// llamar: la función auxiliar del paso 1.var sync struct { Colecciones []struct { Coleccion string `json:"coleccion"` Estado string `json:"estado"` Version int64 `json:"version"` } `json:"colecciones"`}err := llamar(ctx, c, http.MethodGet, "/v1/instances/"+instanciaID+"/appstate", "", nil, &sync)
var cmd motor.Comandoerr = llamar(ctx, c, http.MethodPost, "/v1/instances/"+instanciaID+"/appstate/resync", "resync-etiquetas-2026-10-24", map[string][]string{"colecciones": {"regular"}}, &cmd)const { colecciones } = await llamar('GET', `/v1/instances/${instanciaId}/appstate`);
await llamar('POST', `/v1/instances/${instanciaId}/appstate/resync`, { clave: 'resync-etiquetas-2026-10-24', cuerpo: { colecciones: ['regular'] },});5. Consulta el perfil comercial
Sección titulada «5. Consulta el perfil comercial»GET /v1/instances/{id}/business/profile con uno de telefono, jid o peer_ref (ninguno: el
número de la instancia) devuelve es_negocio, propio, categorias, descripcion, direccion,
correo, sitios, zona_horaria, horario (por día, con modo specific_hours, open_24h o
appointment_only, y abre y cierra en minutos desde la medianoche), areas_servicio (un negocio
sin dirección física), opciones y la cuota de la app. Los campos que el negocio no puso no
vienen.
Un número que no es Business devuelve es_negocio: false y ningún campo: es la respuesta real
del servidor, no un perfil vacío inventado. El catálogo y las colecciones están en
Catálogo.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/business/profile?telefono=%2BNUMERO_DEL_CONTACTO" \ -H "Authorization: Bearer TU_API_KEY"perfil, err := c.ConsultarPerfilComercial(ctx, instanciaID, motor.Destino{Telefono: "+NUMERO_DEL_CONTACTO"})if err != nil { return err}if !perfil.EsNegocio { // No es una cuenta de WhatsApp Business.}
// El del propio número: Destino vacío.propio, err := c.ConsultarPerfilComercial(ctx, instanciaID, motor.Destino{})const tel = encodeURIComponent('+NUMERO_DEL_CONTACTO');const perfil = await llamar('GET', `/v1/instances/${instanciaId}/business/profile?telefono=${tel}`);if (!perfil.es_negocio) { // No es una cuenta de WhatsApp Business.}{ "jid": "CONTACTO@s.whatsapp.net", "es_negocio": true, "propio": false, "categorias": [{ "id": "CATEGORIA_ID", "nombre": "Beauty, cosmetic & personal care" }], "zona_horaria": "America/Lima", "horario": [ { "dia": "mon", "modo": "appointment_only" }, { "dia": "tue", "modo": "specific_hours", "abre": "540", "cierra": "1080" } ], "opciones": { "commerce_experience": "catalog" }, "consultado_en": "2026-10-24T16:00:00Z", "cuota": { "cuota": 500, "usadas": 3, "maximo_por_consulta": 20, "renueva": "2026-10-25T00:00:00Z" }}El comando y sus eventos
Sección titulada «El comando y sus eventos»Los comandos de etiquetas (label.edit, label.chat, label.message) y appstate.resync nacen
aceptado y terminan completado o fallido. Un error pasajero (sin conexión, una clave del
appstate que falta) vuelve a la cola y se reintenta a los 30 s, 1, 2 y 5 minutos. El efecto llega
con su command_id y desde: motor; lo que cambia el teléfono, con desde: whatsapp.
Todos los eventos del appstate traen momento (el del cambio en WhatsApp), version (la de la
colección), full_sync (llegó en una sincronización completa: el estado ya existía) y desde.
label.created, label.updated y label.deleted
Sección titulada «label.created, label.updated y label.deleted»Traen la etiqueta completa. label.updated trae además anterior, con el nombre y el color de
antes.
{ "etiqueta": { "label_id": "19", "nombre": "Pedido pendiente", "color": 9, "eliminada": false, "chats": 4, "mensajes": 0, "actualizada_en": "2026-10-24T15:55:00Z" }, "anterior": { "label_id": "19", "nombre": "Pedido pendiente", "color": 3 }, "momento": "2026-10-24T15:55:00Z", "version": 190, "full_sync": false, "desde": "motor", "coleccion": "regular"}label.associated y label.dissociated
Sección titulada «label.associated y label.dissociated»Una etiqueta se puso o se quitó de un chat (o de un mensaje: trae mensaje con su provider_id).
etiqueta viene vacía si el motor todavía no conoce esa etiqueta.
{ "label_id": "19", "etiqueta": { "label_id": "19", "nombre": "Pedido pendiente", "color": 9 }, "chat": { "peer_ref": "…", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"] }, "momento": "2026-10-24T15:56:00Z", "version": 191, "full_sync": false, "desde": "motor"}appstate.changed
Sección titulada «appstate.changed»Cambió el estado de un chat en la app de WhatsApp. accion es fijar, desfijar, archivar,
desarchivar, silenciar, reactivar, marcar_leido, marcar_no_leido, destacar,
quitar_destacado, borrar_chat, poner_candado o quitar_candado; estado es el del chat
después del cambio.
{ "accion": "archivar", "chat": { "peer_ref": "…", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": [] }, "momento": "2026-10-24T16:01:00Z", "coleccion": "regular_low", "version": 207, "full_sync": false, "desde": "whatsapp"}appstate.sync
Sección titulada «appstate.sync»Una colección se sincronizó o falló. estado es al_dia, error o clave_faltante; error es
un código acotado (clave_faltante, mac_invalido, tiempo_excedido, sin_sesion, rechazado,
no_capturado, otro) y recuperacion: true marca la copia que mandó el teléfono a pedido del
motor.
{ "coleccion": "regular", "estado": "al_dia", "version": 205, "full_sync": true, "recuperacion": false }Errores comunes
Sección titulada «Errores comunes»| HTTP | error_code |
Cuándo |
|---|---|---|
| 403 | — | A tu key le falta el scope (labels:read, labels:manage, chats:read, chats:manage o contacts:lookup). |
| 404 | etiqueta_desconocida |
Esa etiqueta no existe en esta instancia (o se eliminó). |
| 409 | no_conectada |
El perfil comercial con la instancia sin conexión. |
| 409 | — | La misma Idempotency-Key con otros datos. |
| 422 | — | Una etiqueta nueva sin nombre o con más de 100 caracteres, un color fuera de 0 a 19, una colección que no existe, o en el perfil comercial un peer_ref que no es válido o un número inválido. |
| 429 | cuota_agotada |
El perfil comercial con la cuota de la app agotada (Retry-After). |
Los error_code de un comando que falla: rechazado_por_whatsapp (WhatsApp rechazó el cambio) o,
tras agotar los reintentos, sin_sesion, clave_faltante, no_capturado o appstate_fallido.
Límites de WhatsApp
Sección titulada «Límites de WhatsApp»- En un número normal, las etiquetas son las Listas (caso A39, 16/10/2026): el servidor no las rechaza y crear, renombrar, poner en un chat, quitar y eliminar se ven en el teléfono. Pero el cliente normal no muestra el color ni los mensajes etiquetados, así que eso no se puede verificar. Ver Etiquetas en números normales.
- El cliente Business probado no etiqueta mensajes (caso A39, 20/10/2026, Business 2.26): pone
las listas en un chat o en un número, no en un mensaje;
mensajesqueda en 0. Desde el motor, crear, editar el color, asociar, quitar y eliminar pasaron, con su evento cada uno. Colores confirmados en el teléfono: 4 es gris y 9, amarillento. - Un ID reutilizado por el teléfono (falla F16, provisional): una lista creada en el teléfono
con el ID de una eliminada llegó una vez sin
label.created. Desde el motor no pasa (nunca reutiliza un ID); del lado del teléfono no se volvió a reproducir (20/10/2026). - Una etiqueta eliminada sigue contando sus chats (caso A39, 16/10/2026):
chatspuede quedar en 1 aunque el chat ya no la muestre. - El perfil comercial (caso A71, 20 y 22/10/2026): trae lo mismo que muestra el teléfono, sin
inventar los campos que el negocio no puso; el área de servicio llega cuando el negocio no
tiene dirección física. Una cuenta que no es Business da
es_negocio: false. Cada consulta gastó una de la cuota.