SDK de Go
El SDK de Go vive en sdk/go, dentro del repositorio del motor, como un módulo aparte. Tiene
tres paquetes y dos falsos para tus tests:
| Paquete | Para qué |
|---|---|
github.com/projectx27/motor-whatsapp/sdk/go (motor) |
El cliente de /v1: instancias, mensajes de texto e interactivos, comandos, eventos, suscripciones, entregas, replay y el catálogo. |
…/sdk/go/webhook |
El receptor de webhooks: verifica la firma y guarda cada evento una sola vez en un inbox. |
…/sdk/go/stream |
El cliente del stream de eventos por SSE, con reconexión por cursor. |
…/sdk/go/webhook/webhooktest |
Un receptor falso para tests, con respuestas programables. |
…/sdk/go/stream/streamtest |
Un stream falso en memoria para tests. |
No cubre toda la API: lo que falta se hace con net/http y el mismo token (ver
Lo que no trae).
Instalación y compatibilidad
Sección titulada «Instalación y compatibilidad»go get github.com/projectx27/motor-whatsapp/sdk/go@COMMIT_DEL_MOTOR- El repositorio del motor no es público. El SDK llega con el acceso: con él, marca
el módulo como privado (
GOPRIVATE=github.com/projectx27/*) para quego getno lo busque en el proxy público, o apunta a tu copia con una directivareplace. - Solo la biblioteca estándar. No arrastra el motor, ni whatsmeow, ni ninguna dependencia: su
go.modno tienerequire. - Go 1.27 o posterior (la directiva
gode sugo.mod). - Sin versiones etiquetadas. El motor no publica tags: fija el SDK al commit del motor que corres, así sus tipos coinciden con el contrato que sirve.
- El CI del motor corre las pruebas del SDK en un paso propio.
El cliente
Sección titulada «El cliente»import motor "github.com/projectx27/motor-whatsapp/sdk/go"
c := motor.Nuevo("https://motor.ejemplo.com", apiKey)motor.Nuevo arma un *motor.Cliente con un http.Client de 30 s de timeout. Sus campos son
públicos: Base, Token y HTTP (cámbialo por el tuyo si necesitas otro timeout o un
transporte propio). Cada pedido manda Authorization: Bearer <token> y, si lleva cuerpo,
Content-Type: application/json.
Los errores
Sección titulada «Los errores»Una respuesta de error del motor llega como *motor.Error:
| Campo | Qué es |
|---|---|
Status |
El código HTTP. |
Tipo |
El type del problem+json: urn:motor-whatsapp:error:<código> si es tipado. |
Titulo · Detalle |
title y detail. Detalle viene en español neutro, listo para mostrar. |
RetryAfter |
El Retry-After de la respuesta, si vino. |
Codigo() |
El código tipado (cuota_agotada, no_listo…), o vacío. |
Un 429 de ritmo en un envío no es un error para el SDK: el comando quedó guardado y llega
en el resultado, con su NextTry. Ver Errores y
Cuotas y límites.
cmd, err := c.EnviarTexto(ctx, instanciaID, "pedido-1042-confirmacion", motor.Texto{ Destino: motor.Destino{PeerRef: peerRef}, Texto: "Hola, tu pedido 1042 está confirmado.",})var e *motor.Errorif errors.As(err, &e) { log.Printf("motor: %d %s %s", e.Status, e.Codigo(), e.Detalle)}Los métodos
Sección titulada «Los métodos»Todos reciben un context.Context primero. clave es la Idempotency-Key.
| Método | Ruta | Devuelve |
|---|---|---|
CrearInstancia(ctx, clave, nombre, ownerRef) |
POST /v1/instances |
Instancia |
Instancia(ctx, id) |
GET /v1/instances/{id} |
Instancia |
Instancias(ctx) |
GET /v1/instances |
[]Instancia (solo la primera página; ver abajo) |
EnviarTexto(ctx, instanciaID, clave, Texto) |
POST /v1/instances/{id}/messages (tipo: text) |
Comando |
EnviarInteractivo(ctx, instanciaID, clave, EnvioInteractivo) |
POST /v1/instances/{id}/messages (tipo: interactive) |
Comando |
Comando(ctx, id) |
GET /v1/commands/{id} |
Comando |
CancelarComando(ctx, id) |
POST /v1/commands/{id}/cancel |
Comando |
Eventos(ctx, instanciaID, afterSeq, tipos, limite) |
GET /v1/instances/{id}/events |
PaginaEventos |
CrearSuscripcion(ctx, NuevaSuscripcion) |
POST /v1/subscriptions |
Suscripcion, Secreto |
Suscripciones(ctx) |
GET /v1/subscriptions |
[]Suscripcion |
CambiarEstadoSuscripcion(ctx, id, estado) |
PATCH /v1/subscriptions/{id} (activa o pausada) |
Suscripcion |
RotarSecreto(ctx, id) |
POST /v1/subscriptions/{id}/rotate |
Secreto |
EliminarSuscripcion(ctx, id) |
DELETE /v1/subscriptions/{id} |
— |
Replay(ctx, suscripcionID, clave, PedidoReplay) |
POST /v1/subscriptions/{id}/replays |
Replay |
Entregas(ctx, estado) |
GET /v1/deliveries |
[]Entrega (las 200 más nuevas) |
SeguimientoInteractivo(ctx, instanciaID, providerID) |
GET /v1/instances/{id}/interactive/{provider_id} |
Seguimiento |
RespuestasInteractivas(ctx, instanciaID, providerID, cursor, limite) |
GET /v1/instances/{id}/interactive-responses |
PaginaRespuestas |
ConsultarPerfilComercial(ctx, instanciaID, Destino) |
GET /v1/instances/{id}/business/profile |
PerfilComercial |
Catalogo(ctx, instanciaID, Negocio, limite, cursor) |
GET /v1/instances/{id}/business/catalog |
PaginaCatalogo |
Colecciones(ctx, instanciaID, Negocio, LimitesColecciones, cursor) |
GET /v1/instances/{id}/business/collections |
VistaColecciones |
Para armar un interactivo hay constructores: motor.Botones(cuerpo, botones...),
motor.Lista(cuerpo, botonLista, secciones...) y motor.Carrusel(cuerpo, tarjetas...), y los
botones motor.Responder(id, texto), motor.CopiarCodigo(texto, codigo),
motor.AbrirEnlace(texto, enlace), motor.Llamar(texto, telefono) y motor.BotonPIX(pix). Las
reglas de cada variante están en Interactivos.
cmd, err := c.EnviarInteractivo(ctx, instanciaID, "encuesta-entrega-1042", motor.EnvioInteractivo{ Destino: motor.Destino{PeerRef: peerRef}, Interactivo: motor.Botones("¿Recibiste tu pedido?", motor.Responder("si", "Sí"), motor.Responder("no", "Todavía no")),})La paginación
Sección titulada «La paginación»Hay dos formas, según la ruta:
- El ledger, con
seq:EventosdevuelvePaginaEventosconEventos,NextAfterSeqyHasMore. Pide la siguiente conafterSeq = NextAfterSeqmientrasHasMoreseatrue(limitede 1 a 500; 0 usa el del motor, 100). - Un cursor opaco:
RespuestasInteractivas,CatalogoyColeccionesdevuelvenNextCursor; pásalo tal cual en la llamada siguiente.
desde := cursorGuardado // el último seq que procesastefor { p, err := c.Eventos(ctx, instanciaID, desde, []string{"message.received"}, 200) if err != nil { return err } for _, ev := range p.Eventos { procesar(ev) // idempotente por ev.EventID } desde = p.NextAfterSeq guardarCursor(desde) if !p.HasMore { break }}El receptor de webhooks
Sección titulada «El receptor de webhooks»El paquete webhook resuelve las tres reglas de un receptor: verificar la firma sobre el cuerpo
crudo antes de parsear nada, guardar cada evento una sola vez y responder 2xx solo después
del commit.
import "github.com/projectx27/motor-whatsapp/sdk/go/webhook"
inbox := webhook.InboxSQL{DB: db, Cliente: "motor-principal"} // Tabla: mw_inbox por omisiónif err := inbox.Crear(ctx); err != nil { return err}http.Handle("/webhooks/motor", webhook.Receptor{ // key_id → secreto. Durante una rotación, las dos claves. Verificador: webhook.Verificador{Claves: map[string]string{secreto.KeyID: secreto.Valor}}, Inbox: inbox, Efecto: func(ctx context.Context, tx *sql.Tx, ev webhook.Evento) error { // Tu lógica, en la MISMA transacción que el inbox. return nil },})| Pieza | Qué hace |
|---|---|
Verificador |
Comprueba X-MW-Signature (v1, HMAC-SHA256) contra Claves (key_id → secreto), en tiempo constante, y rechaza un X-MW-Timestamp a más de Tolerancia (5 min por omisión). Errores: ErrSinFirma, ErrVencida, ErrFirma, ErrKeyDesconocida. |
Receptor |
El http.Handler. Lee hasta MaxBody (8 MiB por omisión), verifica, parsea a webhook.Evento y llama a Inbox.Aceptar con tu Efecto. AlFallar (opcional) recibe el error de un evento que no se pudo guardar. |
Inbox |
La interfaz: Aceptar(ctx, ev, efecto) (duplicado bool, err error). |
InboxSQL |
El inbox sobre Postgres (database/sql): una tabla con clave (cliente, instancia, event_id). Un evento repetido devuelve duplicado sin volver a aplicar el efecto. Cliente distingue de qué motor vienen los eventos si recibes de más de uno. |
Lo que responde el Receptor, y lo que hace el motor con eso:
| Situación | Respuesta | El motor… |
|---|---|---|
| Guardado (o ya estaba) | 204 | La da por entregada. |
| El timestamp está fuera de tolerancia (un reloj desfasado) | 408 | Reintenta con una firma nueva; no suspende. |
La firma no vale o ninguna key_id es conocida |
401 | Suspende la suscripción y abre una alerta. |
El cuerpo pasa MaxBody o no es un evento |
400 | La marca fallida, sin reintentos. |
| El efecto o el inbox fallan | 500 | Reintenta con backoff. |
No es POST |
405 | La marca fallida. |
El receptor registra solo el event_id, el tipo y la clase del error de un evento que no pudo
guardar: nunca su texto, que puede traer datos del mensaje. El formato de la firma, la rotación y
los reintentos están en Webhooks.
Para tus tests
Sección titulada «Para tus tests»webhooktest.Nuevo(claves) levanta un receptor falso (un httptest.Server) que verifica la firma
como el de verdad y guarda en memoria. FijarResponder decide el status de cada pedido,
FijarDemora agrega latencia, y Recibidos() y Efectos() dicen qué llegó.
El stream
Sección titulada «El stream»El paquete stream lee el stream de eventos por SSE y reconecta solo con el último cursor
entregado: no salta ni repite eventos.
import "github.com/projectx27/motor-whatsapp/sdk/go/stream"
s := stream.Abrir(stream.Opciones{ Base: "https://motor.ejemplo.com", Token: apiKey, // scope events:read Tipos: []string{"message.received", "message.updated"}, Cursor: cursorGuardado, // vacío: desde ahora})defer s.Cerrar()for { e, err := s.Siguiente(ctx) if err != nil { return err // ver la tabla de abajo } procesar(e.Evento) // idempotente por event_id guardarCursor(e.Cursor) // DESPUÉS de procesar}| Opción | Qué es |
|---|---|
Base · Token |
La URL del motor (sin /v1) y la API key. |
Instancia |
Solo una instancia (/v1/instances/{id}/events/stream). Vacío: toda la app. |
Instancias |
Un filtro de instancias del stream de la app. |
Tipos |
Solo estos tipos. |
Cursor |
Desde dónde reanudar. Vacío: desde ahora. |
Contrato |
La versión del contrato de eventos que esperas (vacío: la vigente). |
HTTP |
Tu http.Client, sin Timeout (el stream es largo). |
Espera · MaxEspera |
El backoff entre reconexiones: de 1 s a 30 s por omisión. |
Siguiente reconecta sola ante un corte reanudable (la red, un reinicio del motor, un cliente
lento). Devuelve error cuando reconectar no sirve:
| Error | Qué pasó | Qué hacer |
|---|---|---|
*stream.CursorVencido |
El cursor tiene eventos más viejos que la retención del stream. Primer es el primer cursor disponible. |
Decide: reanudar con Primer acepta el salto; o lee lo perdido de otra forma. |
*stream.Cerrado |
El motor cerró sin reanudación: Motivo es credencial_revocada o sin_scope. |
Cambia la key. |
stream.ErrRechazado |
El motor rechazó el stream al abrir (401, 403, 404 o 422). | Revisa la key, el scope o el filtro. |
stream.ErrCerrado |
Llamaste a Cerrar. |
— |
El error de ctx |
Venció tu contexto. La conexión sigue abierta para la próxima llamada. | — |
Cursor() dice el último entregado. Para tus tests, streamtest.Nueva(cursor) es una
stream.Fuente en memoria: le agregas eventos con Agregar y cortes con Fallar. El
protocolo del stream está en Streams SSE y WebSocket.
Lo que no trae
Sección titulada «Lo que no trae»Para esto, usa HTTP directo con el mismo token (c.Base, c.Token y c.HTTP sirven):
GET /v1/whoamiyGET /v1/capabilities.- Vincular un número (
/v1/instances/{id}/pairing), las operaciones de la instancia (reconectar, pausar, desvincular, archivar) y cambiar su configuración (PATCH /v1/instances/{id}). - Los mensajes que no son texto ni interactivos: multimedia, ubicación, contactos, reacciones, edición, borrado, reenvío, encuestas y status.
- Subir y descargar multimedia (
/v1/instances/{id}/media/uploads,/v1/media/{id}). - Chats, contactos, consultas de identidad, grupos, comunidades, canales, perfil, privacidad, etiquetas, historial, presencia, lectura y llamadas.
- Probar una suscripción (
POST /v1/subscriptions/{id}/test), cambiarle otros campos que no sean el estado y ver una entrega con sus intentos (GET /v1/deliveries/{id}). - El stream por WebSocket y
GET /v1/events/streams.
// Lo que el SDK no trae: HTTP directo con el mismo cliente.req, _ := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+"/v1/whoami", nil)req.Header.Set("Authorization", "Bearer "+c.Token)res, err := c.HTTP.Do(req)Todas las rutas están en la Referencia de /v1.