Canales
Para qué sirve
Sección titulada «Para qué sirve»Un canal (newsletter, …@newsletter) es una difusión de una vía: su dueño y sus
administradores publican y los seguidores leen, ven y reaccionan. Con estas rutas tu app:
- lista los canales que el número sigue o administra, y consulta un enlace sin seguirlo;
- crea un canal, lo sigue, lo deja, lo silencia;
- publica en un canal propio (por el mismo outbox que los mensajes);
- consulta las publicaciones con sus vistas y reacciones, las marca como vistas y reacciona.
Cada canal se identifica por su peer_ref en la instancia (canal.peer_ref de la lista o de
un evento): es el {canal_ref} de las rutas. Las publicaciones tienen dos identificadores que
no se confunden: el server_id, el número que asigna WhatsApp (lo usan las vistas, las
reacciones y los contadores), y el provider_id, el ID de mensaje.
Requisitos
Sección titulada «Requisitos»- Una instancia
conectado. Las consultas a WhatsApp responden 409no_conectadasin conexión; un comando sin conexión vuelve a la cola y se reintenta. - Una API key con estos scopes:
| Operación | Ruta | Scope |
|---|---|---|
| Listar los canales | GET /v1/instances/{id}/newsletters |
newsletters:read |
| Ver un canal | GET /v1/instances/{id}/newsletters/{canal_ref} |
newsletters:read |
| Consultar un enlace sin seguirlo | POST /v1/instances/{id}/newsletters/invite/info |
newsletters:read |
| Las publicaciones | GET …/newsletters/{canal_ref}/messages |
newsletters:read |
| Traer vistas y reacciones | POST …/newsletters/{canal_ref}/updates |
newsletters:read |
| Recibir en vivo | POST …/newsletters/{canal_ref}/live |
newsletters:read |
| Crear un canal | POST /v1/instances/{id}/newsletters |
newsletters:write |
| Seguir | POST /v1/instances/{id}/newsletters/follow |
newsletters:write |
| Dejar de seguir | POST …/newsletters/{canal_ref}/unfollow |
newsletters:write |
| Silenciar o reactivar | PUT …/newsletters/{canal_ref}/mute |
newsletters:write |
| Marcar como vistas | POST …/newsletters/{canal_ref}/view |
newsletters:write |
| Reaccionar | POST …/newsletters/{canal_ref}/reaction |
newsletters:write |
| Publicar | POST /v1/instances/{id}/messages |
messages:send |
Los comandos piden una Idempotency-Key (de 1 a 200 caracteres). Para recibir los eventos,
además, events:read o una suscripción de webhooks.
Los ejemplos de Go y JavaScript usan la función auxiliar llamar que se define en el primer paso:
el SDK de Go no trae métodos de canales, así que es HTTP directo con el token del cliente.
1. Lista los canales y consulta un enlace
Sección titulada «1. Lista los canales y consulta un enlace»GET /v1/instances/{id}/newsletters devuelve los canales según lo último que dijo WhatsApp, de a
limit (1 a 200; por omisión, 50), con next_cursor y has_more. Cada uno trae su rol
(owner y admin publican; subscriber lo sigue; guest, no), siguiendo, silenciado,
suscriptores, verificado y el enlace en invitacion. Con actualizar=true el motor pide antes
la lista a WhatsApp: los que ya no están quedan siguiendo: false.
POST …/newsletters/invite/info con enlace (https://whatsapp.com/channel/… o solo su código)
dice qué canal es sin seguirlo: jid, nombre, descripcion, suscriptores, verificado,
estado y creado_en. Uno que no existe es 404 canal_no_encontrado.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters?actualizar=true" \ -H "Authorization: Bearer TU_API_KEY"
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters/invite/info \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"enlace": "https://whatsapp.com/channel/CODIGO_DEL_CANAL"}'import motor "github.com/projectx27/motor-whatsapp/sdk/go"
// llamar: el SDK no trae los canales, 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)ruta := "/v1/instances/" + instanciaID + "/newsletters"
var lista struct { Canales []struct { Canal struct{ PeerRef string `json:"peer_ref"` } `json:"canal"` Nombre string `json:"nombre"` Rol string `json:"rol"` Siguiendo bool `json:"siguiendo"` } `json:"canales"` HasMore bool `json:"has_more"`}err := llamar(ctx, c, http.MethodGet, ruta+"?actualizar=true", "", nil, &lista)
var info struct { JID string `json:"jid"` Nombre string `json:"nombre"` Suscriptores int `json:"suscriptores"`}err = llamar(ctx, c, http.MethodPost, ruta+"/invite/info", "", map[string]string{"enlace": enlace}, &info)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 ruta = `/v1/instances/${instanciaId}/newsletters`;const { canales } = await llamar('GET', `${ruta}?actualizar=true`);const info = await llamar('POST', `${ruta}/invite/info`, { cuerpo: { enlace } });{ "canal": { "canal": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000c1", "jid": "CANAL@newsletter", "clase": "canal", "aliases": [] }, "nombre": "Novedades de la tienda", "descripcion": "Ofertas y horarios", "invitacion": "https://whatsapp.com/channel/CODIGO_DEL_CANAL", "rol": "owner", "siguiendo": true, "silenciado": false, "suscriptores": 128, "verificado": false, "reacciones": "all", "en_vivo": false, "version": 4, "actualizado_en": "2026-10-24T16:00:00Z" }}2. Crea, sigue, deja o silencia un canal
Sección titulada «2. Crea, sigue, deja o silencia un canal»| Operación | Ruta | Cuerpo | Tipo de comando |
|---|---|---|---|
| Crear | POST …/newsletters |
nombre (1 a 100), descripcion (hasta 2048), foto_media_id |
newsletter.create |
| Seguir | POST …/newsletters/follow |
{"canal": {"enlace": "…"}} o {"canal": {"jid": "…"}} |
newsletter.follow |
| Dejar de seguir | POST …/newsletters/{canal_ref}/unfollow |
sin cuerpo | newsletter.follow |
| Silenciar o reactivar | PUT …/newsletters/{canal_ref}/mute |
{"silenciar": true} o false |
newsletter.mute |
Al crear, el result del comando trae el JID del canal (canal.jid) y su enlace. La foto es un
JPEG subido antes a esta instancia, ya listo, de hasta 2 MB y de 192 a 2048 píxeles por lado (ver
Multimedia). Si el proceso cae a mitad o WhatsApp no contesta, la creación queda
ambiguo con resultado_incierto: el motor nunca la manda dos veces.
Al seguir, el canal queda siguiendo: true con el rol que diga WhatsApp (subscriber).
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: canal-novedades" \ -d '{"nombre": "Novedades de la tienda", "descripcion": "Ofertas y horarios"}'
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters/follow \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: seguir-canal-proveedor" \ -d '{"canal": {"enlace": "https://whatsapp.com/channel/CODIGO_DEL_CANAL"}}'
curl -X PUT https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters/CANAL_REF/mute \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: silenciar-canal-proveedor" \ -d '{"silenciar": true}'// llamar: la función auxiliar del paso 1.ruta := "/v1/instances/" + instanciaID + "/newsletters"
var cmd motor.Comandoerr := llamar(ctx, c, http.MethodPost, ruta, "canal-novedades", map[string]string{"nombre": "Novedades de la tienda", "descripcion": "Ofertas y horarios"}, &cmd)
err = llamar(ctx, c, http.MethodPost, ruta+"/follow", "seguir-canal-proveedor", map[string]any{"canal": map[string]string{"enlace": enlace}}, &cmd)
err = llamar(ctx, c, http.MethodPut, ruta+"/"+canalRef+"/mute", "silenciar-canal-proveedor", map[string]bool{"silenciar": true}, &cmd)const ruta = `/v1/instances/${instanciaId}/newsletters`;
await llamar('POST', ruta, { clave: 'canal-novedades', cuerpo: { nombre: 'Novedades de la tienda', descripcion: 'Ofertas y horarios' },});
await llamar('POST', `${ruta}/follow`, { clave: 'seguir-canal-proveedor', cuerpo: { canal: { enlace } },});
await llamar('PUT', `${ruta}/${canalRef}/mute`, { clave: 'silenciar-canal-proveedor', cuerpo: { silenciar: true },});3. Publica en un canal propio
Sección titulada «3. Publica en un canal propio»Publicar es un envío como cualquier mensaje: POST /v1/instances/{id}/messages con scope
messages:send, destino.jid el JID del canal (o destino.peer_ref, su peer_ref) y la
Idempotency-Key obligatoria. Sale por el mismo outbox, con su ritmo, y nace en_cola.
- Se publica texto, imagen, vídeo o nota de voz (
tipotext,image,videooptt), sin cita ni menciones. Otro tipo es 422tipo_no_admitido_en_canal. - Solo el dueño o un administrador publican: si el número no lo es según lo último que dijo
WhatsApp, 403
sin_permiso. Si la instancia no conoce el canal, 404: actualiza la lista. - Cuando el servidor lo acepta, el
resultdel comando trae elserver_idde la publicación y suprovider_message_id, y elmessage.senttraeserver_id.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/messages \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: canal-novedades-oferta-42" \ -d '{ "destino": {"jid": "CANAL@newsletter"}, "tipo": "image", "medio": {"media_id": "8a1d2c3e-0000-4000-8000-0000000000e1"}, "texto": "Esta semana, envío sin cargo." }'// Un texto con el SDK:cmd, err := c.EnviarTexto(ctx, instanciaID, "canal-novedades-aviso-43", motor.Texto{ Destino: motor.Destino{JID: "CANAL@newsletter"}, Texto: "Hoy abrimos hasta las 21 h.",})
// Una imagen (el SDK no trae los medios: es HTTP directo con llamar, la función del paso 1):err = llamar(ctx, c, http.MethodPost, "/v1/instances/"+instanciaID+"/messages", "canal-novedades-oferta-42", map[string]any{ "destino": map[string]string{"jid": "CANAL@newsletter"}, "tipo": "image", "medio": map[string]string{"media_id": mediaID}, "texto": "Esta semana, envío sin cargo.", }, &cmd)const cmd = await llamar('POST', `/v1/instances/${instanciaId}/messages`, { clave: 'canal-novedades-oferta-42', cuerpo: { destino: { jid: 'CANAL@newsletter' }, tipo: 'image', medio: { media_id: mediaId }, texto: 'Esta semana, envío sin cargo.', },}); // 202: { command_id, type: "message.image", status: "en_cola", … }{ "server_id": 105, "provider_message_id": "PROVIDER_MESSAGE_ID" }Los seguidores que son instancias del motor reciben la publicación como message.received en el
chat del canal, con el mismo server_id.
4. Consulta las publicaciones, las vistas y las reacciones
Sección titulada «4. Consulta las publicaciones, las vistas y las reacciones»GET …/newsletters/{canal_ref}/messages devuelve las publicaciones que el motor conoce, de la más
nueva a la más vieja, de a limit (1 a 200; por omisión, 50). Cada una trae server_id,
provider_id, direccion (entrante o saliente), tipo, momento, vistas, reacciones
(emoji → cantidad), reaccion_propia y visto.
- Con
actualizar=trueel motor pide antes a WhatsAppcantidadpublicaciones (1 a 100; por omisión, 20) y guarda sus contadores; una consulta repetida no duplica nada. Solo entonces cada publicación traecontenido(el tipo y el texto o el pie, como enmessage.received). El archivo de una publicación consultada no se descarga. - Para la página siguiente (más vieja), pasa
antescon elnext_antesde la respuesta mientrashas_moreseatrue.
POST …/newsletters/{canal_ref}/updates trae solo los contadores que cambiaron: desde un momento
(desde) o de las publicaciones con un server_id mayor que despues_de. Responde con
recibidas (cuántas informó el servidor), y las que cambiaron dejan un newsletter.updates con
desde: consulta.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters/CANAL_REF/messages?actualizar=true&cantidad=20" \ -H "Authorization: Bearer TU_API_KEY"
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters/CANAL_REF/updates \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"despues_de": 99}'// llamar: la función auxiliar del paso 1.ruta := "/v1/instances/" + instanciaID + "/newsletters/" + canalRef
var pagina struct { Publicaciones []struct { ServerID int64 `json:"server_id"` Vistas int `json:"vistas"` Reacciones map[string]int `json:"reacciones"` } `json:"publicaciones"` NextAntes int64 `json:"next_antes"` HasMore bool `json:"has_more"`}err := llamar(ctx, c, http.MethodGet, ruta+"/messages?actualizar=true&cantidad=20", "", nil, &pagina)
var act struct { Recibidas int `json:"recibidas"`}err = llamar(ctx, c, http.MethodPost, ruta+"/updates", "", map[string]int64{"despues_de": 99}, &act)const ruta = `/v1/instances/${instanciaId}/newsletters/${canalRef}`;
const { publicaciones, next_antes, has_more } = await llamar( 'GET', `${ruta}/messages?actualizar=true&cantidad=20`);
const { recibidas } = await llamar('POST', `${ruta}/updates`, { cuerpo: { despues_de: 99 } });{ "publicaciones": [ { "server_id": 105, "provider_id": "PROVIDER_MESSAGE_ID", "direccion": "entrante", "tipo": "imagen", "momento": "2026-10-24T16:05:00Z", "vistas": 3, "reacciones": { "👍": 2, "❤️": 1 }, "reaccion_propia": "👍", "visto": true, "version": 3, "conteo_en": "2026-10-24T16:20:00Z", "contenido": { "tipo": "imagen", "texto": "Esta semana, envío sin cargo." } } ], "next_antes": 105, "has_more": true}5. Marca como vistas y reacciona
Sección titulada «5. Marca como vistas y reacciona»Las dos operaciones usan el server_id, nunca el ID de mensaje:
POST …/newsletters/{canal_ref}/viewconserver_ids(de 1 a 100): suma al contador de vistas (newsletter.view). No marca nada como leído en los otros dispositivos del número.POST …/newsletters/{canal_ref}/reactionconserver_idyemoji(hasta 32 caracteres; vacío quita la reacción):newsletter.reaction. El motor fija el ID de la reacción por comando, así que un reintento manda la misma; elresultlo trae enreaccion_id.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters/CANAL_REF/view \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: canal-proveedor-visto-105" \ -d '{"server_ids": [104, 105]}'
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/newsletters/CANAL_REF/reaction \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: canal-proveedor-reaccion-105" \ -d '{"server_id": 105, "emoji": "👍"}'// llamar: la función auxiliar del paso 1.ruta := "/v1/instances/" + instanciaID + "/newsletters/" + canalRef
var cmd motor.Comandoerr := llamar(ctx, c, http.MethodPost, ruta+"/view", "canal-proveedor-visto-105", map[string][]int64{"server_ids": {104, 105}}, &cmd)
err = llamar(ctx, c, http.MethodPost, ruta+"/reaction", "canal-proveedor-reaccion-105", map[string]any{"server_id": 105, "emoji": "👍"}, &cmd)const ruta = `/v1/instances/${instanciaId}/newsletters/${canalRef}`;
await llamar('POST', `${ruta}/view`, { clave: 'canal-proveedor-visto-105', cuerpo: { server_ids: [104, 105] },});
await llamar('POST', `${ruta}/reaction`, { clave: 'canal-proveedor-reaccion-105', cuerpo: { server_id: 105, emoji: '👍' },});6. Recibir en vivo
Sección titulada «6. Recibir en vivo»POST …/newsletters/{canal_ref}/live pide a WhatsApp que mande en vivo las vistas y reacciones
del canal por un rato, el que diga el servidor: la respuesta trae en_vivo_hasta, y el canal
muestra en_vivo mientras no venza. Hay que volver a pedirlo cuando vence. Lo que llegue entra
como newsletter.updates.
El comando y sus eventos
Sección titulada «El comando y sus eventos»Los comandos de control (newsletter.create, newsletter.follow, newsletter.mute,
newsletter.view y newsletter.reaction) nacen aceptado y terminan completado o fallido;
una creación sin respuesta, ambiguo. Una publicación es un envío (message.text,
message.image, message.video o message.ptt) y termina como cualquier mensaje: enviado con
message.sent, o fallido.
El efecto llega al ledger con su command_id y desde: motor. Lo que llega de WhatsApp (un
cambio hecho desde el teléfono) trae desde: whatsapp y no trae command_id; lo que el motor
descubre al consultar, desde: consulta. Cada evento trae el canal como
peer, con clase: "canal".
newsletter.joined
Sección titulada «newsletter.joined»El número empezó a seguir un canal (o lo creó).
{ "canal": { "peer_ref": "…", "jid": "CANAL@newsletter", "clase": "canal", "aliases": [] }, "nombre": "Novedades del proveedor", "rol": "subscriber", "desde": "motor"}newsletter.left
Sección titulada «newsletter.left»El número dejó de seguirlo. rol es el que informó el aviso (suele venir vacío; en las pruebas
llegó guest).
{ "canal": { "peer_ref": "…", "jid": "CANAL@newsletter", "clase": "canal", "aliases": [] }, "rol": "guest", "desde": "whatsapp"}newsletter.muted
Sección titulada «newsletter.muted»Cambió el silencio del canal.
{ "canal": { "peer_ref": "…", "jid": "CANAL@newsletter", "clase": "canal", "aliases": [] }, "silenciado": true, "desde": "motor"}newsletter.changed
Sección titulada «newsletter.changed»Cambió la ficha del canal. campos trae solo lo que cambió: nombre, descripcion, estado
(active, suspended o geosuspended, lo que dice WhatsApp), verificado, rol, siguiendo o
silenciado.
{ "canal": { "peer_ref": "…", "jid": "CANAL@newsletter", "clase": "canal", "aliases": [] }, "campos": { "rol": "subscriber", "siguiendo": true, "silenciado": true }, "desde": "whatsapp"}newsletter.updates
Sección titulada «newsletter.updates»Cambiaron las vistas o las reacciones de una o más publicaciones. Solo trae las que cambiaron, con sus valores completos: las vistas nunca bajan y, entre dos datos, gana el más nuevo.
{ "canal": { "peer_ref": "…", "jid": "CANAL@newsletter", "clase": "canal", "aliases": [] }, "publicaciones": [ { "server_id": 100, "provider_id": "PROVIDER_MESSAGE_ID", "vistas": 3, "reacciones": { "👍": 1 } }, { "server_id": 101, "vistas": 1, "reacciones": { "❤️": 1 } } ], "desde": "consulta"}Las publicaciones de un canal que el número sigue llegan como message.received en el chat del
canal, con server_id (ver Mensajes).
Errores comunes
Sección titulada «Errores comunes»| HTTP | error_code |
Cuándo |
|---|---|---|
| 403 | — | A tu key le falta el scope (newsletters:read, newsletters:write o messages:send para publicar). |
| 403 | sin_permiso |
Publicar en un canal que el número no administra. |
| 404 | — | La instancia no conoce ese canal: actualiza la lista con actualizar=true. |
| 404 | canal_no_encontrado |
WhatsApp dice que el canal (o el enlace) no existe. |
| 405 | — | Silenciar con POST: la ruta es PUT …/mute. |
| 409 | no_conectada |
Una consulta a WhatsApp con la instancia sin conexión. |
| 409 | — | La misma Idempotency-Key con otros datos. |
| 422 | tipo_no_admitido_en_canal |
Publicar un tipo que no sea texto, imagen, vídeo o nota de voz, o con cita o menciones. |
| 422 | — | Un cuerpo inválido: nombre fuera de 1 a 100 caracteres, el canal con más de uno de jid, peer_ref y enlace, server_ids vacío. |
Los error_code de un comando que falla:
error_code |
Qué pasó |
|---|---|
canal_no_encontrado |
WhatsApp dice que el canal no existe. |
sin_permiso |
WhatsApp no deja: el número no tiene el rol. |
rechazado_por_whatsapp |
WhatsApp rechazó el pedido; server_code trae su código. |
medio_no_disponible |
La foto del canal nuevo no se pudo leer: no salió nada. |
resultado_incierto |
Una creación sin respuesta: queda ambiguo. |
Límites de WhatsApp
Sección titulada «Límites de WhatsApp»- Sin contadores en vivo (caso A66, 21/10/2026 y repetido después): «Recibir en vivo» abre la ventana, pero WhatsApp no empuja las vistas ni las reacciones a un dispositivo vinculado. Los contadores solo se ven al consultarlos, y las vistas pueden salir en 0 aunque las haya. Ver Actualizaciones en vivo de canales.
- El silencio cambiado desde el teléfono no llega (caso A64, 22/10/2026): silenciar o quitar
el silencio en el teléfono no produce ningún evento. Dejar el canal y volver a seguirlo sí
(
newsletter.left, y luegonewsletter.changedynewsletter.joined), y al volver el silencio aparece reflejado. Ver Silencio de un canal desde el teléfono. - Al volver a seguir, el silencio no se conserva (caso A64, 21/10/2026): un canal que se dejó
y se volvió a seguir quedó
silenciado: true, aunque antes no lo estaba. - Publicar (caso A65, 21/10/2026): un texto, una imagen con pie y una nota de voz salieron
enviado, cada uno con suserver_iddistinto delprovider_message_id, y la instancia que seguía el canal los recibió comomessage.receivedcon el mismoserver_id. WhatsApp no rechazó ningún tipo, pero publicar en canales con un número no oficial es experimental.