Inicio rápido
En cinco pasos pasas de una API key a un webhook firmado que tu app verifica. Los ejemplos usan
https://motor.ejemplo.com como dirección del motor y TU_API_KEY como token: reemplázalos por
los tuyos. El motor vive en tu red privada; no se expone a internet.
1. Crea la app y su API key
Sección titulada «1. Crea la app y su API key»Una app cliente es tu sistema visto desde el motor: tiene sus instancias, sus webhooks y sus keys, aisladas de las demás apps. Las apps y las keys las crea un operador con rol superadmin en el panel del motor:
- En Clientes, elige Nueva app y ponle un nombre.
- En la ficha de la app, elige Emitir key. Escoge el entorno (Producción da
mw_live_…; Prueba,mw_test_…), los scopes y, si quieres, las instancias a las que alcanza. - Copia el token del diálogo Key emitida. Se muestra una sola vez: si lo pierdes, rota la key y usa la nueva.
Para esta guía, la key necesita estos scopes: instances:create, instances:read,
instances:pair, messages:send, messages:read, subscriptions:manage y events:read.
Comprueba que la key funciona con GET /v1/whoami: no pide ningún scope y te dice qué app y qué
permisos tiene. Una instancias vacía significa «todas las instancias de la app».
curl https://motor.ejemplo.com/v1/whoami \ -H "Authorization: Bearer TU_API_KEY"// El SDK de Go no trae whoami: es una petición HTTP con el mismo token.req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://motor.ejemplo.com/v1/whoami", nil)req.Header.Set("Authorization", "Bearer "+apiKey)res, err := http.DefaultClient.Do(req)const res = await fetch('https://motor.ejemplo.com/v1/whoami', { headers: { Authorization: `Bearer ${apiKey}` },});console.log(await res.json());{ "cliente": { "id": "8a1d2c3e-0000-4000-8000-000000000001", "nombre": "Mi CRM" }, "key": { "id": "8a1d2c3e-0000-4000-8000-000000000002", "prefijo": "mw_live_1a2b3c4d", "entorno": "produccion", "scopes": ["events:read", "instances:create", "instances:pair", "instances:read", "messages:read", "messages:send", "subscriptions:manage"], "instancias": [] }}2. Crea una instancia
Sección titulada «2. Crea una instancia»Una instancia es un número de WhatsApp vinculado al motor. Créala con POST /v1/instances
(scope instances:create). owner_ref es una referencia opaca tuya, por ejemplo el ID de la
sucursal en tu sistema.
Manda una Idempotency-Key (opcional aquí, hasta 200 caracteres): si repites el pedido con la
misma clave y los mismos datos, recibes la misma instancia en lugar de una nueva; con la misma
clave y otros datos, un 409. Si la app ya llegó a su cupo de instancias, la respuesta es un
429.
La instancia nace en estado nuevo. Guarda su id: lo usan todos los pasos siguientes.
curl -X POST https://motor.ejemplo.com/v1/instances \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: alta-sucursal-centro" \ -d '{"nombre": "Sucursal centro", "owner_ref": "sucursal-centro"}'import motor "github.com/projectx27/motor-whatsapp/sdk/go"
c := motor.Nuevo("https://motor.ejemplo.com", apiKey)// La clave es la Idempotency-Key del alta.inst, err := c.CrearInstancia(ctx, "alta-sucursal-centro", "Sucursal centro", "sucursal-centro")if err != nil { return err}fmt.Println(inst.ID, inst.Estado) // … nuevoconst res = await fetch('https://motor.ejemplo.com/v1/instances', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'alta-sucursal-centro', }, body: JSON.stringify({ nombre: 'Sucursal centro', owner_ref: 'sucursal-centro' }),});const instancia = await res.json(); // 201: { id, estado: "nuevo", … }3. Vincula el número
Sección titulada «3. Vincula el número»Pide el emparejamiento con POST /v1/instances/{id}/pairing (scope instances:pair). Hay dos
modos:
codigo: el motor pide un código de 8 caracteres para el número que indicas entelefono(completo, con+y el código de país). En el teléfono, abre Dispositivos vinculados → Vincular un dispositivo → Vincular con el número de teléfono y escríbelo.qr: el motor genera un QR.reto.valores el texto del QR, no una imagen: dibújalo con tu biblioteca de QR y escanéalo desde Dispositivos vinculados.
La respuesta es un 202: el reto llega por separado. Consúltalo con
GET /v1/instances/{id}/pairing; reto.expira_en dice cuándo vence. Cada pedido nuevo abre una
generación nueva y reemplaza al anterior, así que un QR vencido se resuelve pidiendo otro.
Cuando el teléfono acepta, el ledger de la instancia registra pairing.available, paired y
connected, y la instancia queda en estado conectado con su numero.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/pairing \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"modo": "codigo", "telefono": "+TU_NUMERO_CON_CODIGO_DE_PAIS"}'
# El reto vigente:curl https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/pairing \ -H "Authorization: Bearer TU_API_KEY"// El SDK todavía no trae el emparejamiento: es HTTP directo.cuerpo := strings.NewReader(`{"modo": "codigo", "telefono": "+TU_NUMERO_CON_CODIGO_DE_PAIS"}`)req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/v1/instances/"+inst.ID+"/pairing", cuerpo)req.Header.Set("Authorization", "Bearer "+c.Token)req.Header.Set("Content-Type", "application/json")res, err := c.HTTP.Do(req) // 202
// Cuando el teléfono acepta:inst, err = c.Instancia(ctx, inst.ID) // inst.Estado == "conectado"const base = `https://motor.ejemplo.com/v1/instances/${instancia.id}/pairing`;const auth = { Authorization: `Bearer ${apiKey}` };
await fetch(base, { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ modo: 'codigo', telefono: '+TU_NUMERO_CON_CODIGO_DE_PAIS' }),}); // 202
const { reto } = await (await fetch(base, { headers: auth })).json();// reto: { tipo: "codigo", valor: "…", expira_en: "…" }{ "generacion": 1, "modo": "codigo", "fase": "esperando_codigo", "reto": { "tipo": "codigo", "valor": "ABCD-EFGH", "expira_en": "2026-10-24T15:04:05Z" }, "creado_en": "2026-10-24T15:02:05Z"}4. Manda el primer mensaje
Sección titulada «4. Manda el primer mensaje»POST /v1/instances/{id}/messages (scope messages:send) no manda el mensaje en la
respuesta: lo guarda como un comando y responde 202 con status: en_cola. Desde ese
momento el envío es durable: sobrevive a un reinicio del motor.
destinolleva exactamente uno detelefono(número internacional),jidopeer_ref(el que traen los eventos de esta instancia).- La
Idempotency-Keyes obligatoria. Si la red se corta y no sabes si el pedido llegó, repítelo con la misma clave: recibes el mismo comando, nunca un segundo mensaje. La misma clave con otro mensaje es un 409.
El resultado llega después: message.sent cuando el servidor de WhatsApp lo acepta, y
message.updated con estado entregado o leido. También puedes consultarlo con
GET /v1/commands/{id}. Lo explica a fondo
Comandos asíncronos y eventos.
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: pedido-1042-confirmacion" \ -d '{ "destino": {"telefono": "+NUMERO_DEL_CONTACTO"}, "tipo": "text", "texto": "Hola, tu pedido 1042 está confirmado." }'cmd, err := c.EnviarTexto(ctx, inst.ID, "pedido-1042-confirmacion", motor.Texto{ Destino: motor.Destino{Telefono: "+NUMERO_DEL_CONTACTO"}, Texto: "Hola, tu pedido 1042 está confirmado.",})if err != nil { return err}fmt.Println(cmd.ID, cmd.Estado) // … en_cola
// Más tarde:cmd, err = c.Comando(ctx, cmd.ID) // cmd.Estado == "enviado"const res = await fetch(`https://motor.ejemplo.com/v1/instances/${instancia.id}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'pedido-1042-confirmacion', }, body: JSON.stringify({ destino: { telefono: '+NUMERO_DEL_CONTACTO' }, tipo: 'text', texto: 'Hola, tu pedido 1042 está confirmado.', }),});const comando = await res.json(); // 202: { command_id, status: "en_cola", … }{ "command_id": "5c2e7a10-0000-4000-8000-0000000000c1", "instance_id": "8a1d2c3e-0000-4000-8000-0000000000a1", "idempotency_key": "pedido-1042-confirmacion", "type": "message.text", "generation": 1, "status": "en_cola", "created_at": "2026-10-24T15:10:00Z", "updated_at": "2026-10-24T15:10:00Z"}5. Recibe el primer webhook
Sección titulada «5. Recibe el primer webhook»Suscríbete con POST /v1/subscriptions (scope subscriptions:manage). tipos vacío recibe
todos los tipos; instancias vacío, todas las instancias de la app. Con ordenado (por omisión,
true) cada instancia se entrega en orden de seq.
La respuesta trae secreto (key_id y valor) una sola vez: guárdalo sellado en tu app.
El host de la URL tiene que estar en los destinos permitidos del motor o de tu app: si no, el
alta responde 422. Pídeselo al operador.
Cada entrega es un POST con el sobre del evento y estos
encabezados: X-MW-Key-Id, X-MW-Timestamp, X-MW-Delivery-Id, X-MW-Event-Id y
X-MW-Signature: v1=<hex>, un HMAC-SHA256 con el secreto sobre
timestamp + "." + delivery_id + "." + body. En la ventana de una rotación llegan dos firmas
separadas por comas, en el mismo orden que los X-MW-Key-Id.
Tu receptor tiene que:
- Verificar la firma sobre el cuerpo crudo, antes de parsear nada, y rechazar un timestamp fuera de los 5 minutos con un 408 (es un reloj desfasado: el motor lo reintenta), nunca con un 401.
- Guardar el evento y deduplicar por
event_id: la entrega es «al menos una vez». - Responder 2xx solo después de guardarlo. Un error de red, un timeout, un 408, un 429 o un 5xx se reintentan; un 401 o un 403 suspenden la suscripción, y cualquier otro 3xx o 4xx deja la entrega como fallida, sin reintentos (se recupera con un replay).
Para probar el destino sin esperar un mensaje, POST /v1/subscriptions/{id}/test manda un
webhook.test firmado.
curl -X POST https://motor.ejemplo.com/v1/subscriptions \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "nombre": "CRM principal", "url": "https://crm.ejemplo.com/webhooks/motor", "tipos": ["message.received", "message.updated", "command.failed"] }'
# Un webhook.test firmado al destino:curl -X POST https://motor.ejemplo.com/v1/subscriptions/SUSCRIPCION_ID/test \ -H "Authorization: Bearer TU_API_KEY"import "github.com/projectx27/motor-whatsapp/sdk/go/webhook"
// Al crear la suscripción (una vez): guarda sec.KeyID y sec.Valor sellados.sus, sec, err := c.CrearSuscripcion(ctx, motor.NuevaSuscripcion{ Nombre: "CRM principal", URL: "https://crm.ejemplo.com/webhooks/motor", Tipos: []string{"message.received", "message.updated", "command.failed"},})
// El receptor: verifica la firma, guarda en el inbox (UNIQUE por event_id)// y aplica tu efecto en la misma transacción. Responde 204 tras el commit.inbox := webhook.InboxSQL{DB: db, Cliente: "motor-principal"}if err := inbox.Crear(ctx); err != nil { return err}http.Handle("/webhooks/motor", webhook.Receptor{ Verificador: webhook.Verificador{Claves: claves}, // key_id → secreto Inbox: inbox, Efecto: func(ctx context.Context, tx *sql.Tx, ev webhook.Evento) error { // Tu lógica, dentro de la transacción del inbox. return nil },})import { createHmac, timingSafeEqual } from 'node:crypto';
// El motor firma cada intento con la hora de ese intento: un timestamp fuera de// los 5 minutos es un reloj desfasado. Responde 408 (se reintenta), no 401// (suspende la suscripción).function timestampVigente(h) { const ts = Number(h['x-mw-timestamp']); return Number.isFinite(ts) && Math.abs(Date.now() / 1000 - ts) <= 300;}
// claves: key_id → secreto, guardados sellados en tu app.function firmaValida(h, cuerpo, claves) { const ts = h['x-mw-timestamp']; if (!ts) return false; const ids = String(h['x-mw-key-id'] ?? '').split(','); const firmas = String(h['x-mw-signature'] ?? '').split(','); return ids.some((id, i) => { const secreto = claves[id.trim()]; const firma = firmas[i]?.trim() ?? ''; if (!secreto || !firma.startsWith('v1=')) return false; const esperada = createHmac('sha256', secreto) .update(`${ts}.${h['x-mw-delivery-id']}.`) .update(cuerpo) // el cuerpo crudo, como Buffer .digest(); const recibida = Buffer.from(firma.slice(3), 'hex'); return recibida.length === esperada.length && timingSafeEqual(recibida, esperada); });}
// En tu handler: sin timestamp vigente, 408; si la firma no vale, 401. Si vale,// guarda el evento (único por event_id) y responde 204 solo después de guardarlo.{ "suscripcion": { "id": "0f3b9d22-0000-4000-8000-0000000000d1", "nombre": "CRM principal", "url": "https://crm.ejemplo.com/webhooks/motor", "tipos": ["message.received", "message.updated", "command.failed"], "ordenado": true, "estado": "activa" }, "secreto": { "key_id": "SECRETO_KEY_ID", "valor": "TU_SECRETO_DE_WEBHOOK" }}Siguientes pasos
Sección titulada «Siguientes pasos»- Comandos asíncronos y eventos: el ledger,
seq, la idempotencia y qué hacer con un comandoambiguo. - Catálogo de eventos: qué trae cada tipo.
- Limitaciones de WhatsApp: lo que no depende del motor, antes de prometerlo a tus usuarios.
- Referencia de /v1: todas las operaciones.