Contactos
Para qué sirve
Sección titulada «Para qué sirve»Estas rutas dicen lo que el número vinculado sabe de las personas con las que habla:
- los contactos de transporte: cómo los guardó el teléfono, el nombre que mandan (push name), su nombre de negocio, su foto y su Info;
- si un número está en WhatsApp, y el perfil público y la foto de otros usuarios;
- la resolución de identidades: el
peer_refestable de un contacto y sus alias comprobados (su JID de teléfono, PN, y su LID).
Requisitos
Sección titulada «Requisitos»- Una API key con estos scopes:
| Operación | Ruta | Scope |
|---|---|---|
| Listar los contactos | GET /v1/instances/{id}/contacts |
contacts:read |
| Ver un contacto | GET /v1/instances/{id}/contacts/{peer_ref} |
contacts:read |
| Traer la libreta del store | POST /v1/instances/{id}/contacts/sync |
contacts:read |
| Resolver una identidad | GET /v1/instances/{id}/identities |
contacts:read |
| ¿Está en WhatsApp? | POST /v1/instances/{id}/contacts/check |
contacts:lookup |
| Perfil público de otros | POST /v1/instances/{id}/contacts/info |
contacts:lookup |
| La foto de otro | POST /v1/instances/{id}/contacts/picture |
contacts:lookup |
| La cuota de consultas de hoy | GET /v1/instances/{id}/contacts/quota |
contacts:lookup |
- Para las consultas con
contacts:lookup, una instanciaconectado: se hacen en el momento y sin conexión responden 409no_conectada(no quedan en cola).
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 contactos, así que es HTTP directo con el token del cliente.
1. Lista los contactos de transporte
Sección titulada «1. Lista los contactos de transporte»GET /v1/instances/{id}/contacts devuelve los contactos en el orden en que el motor los conoció,
de a limit (1 a 200; por omisión, 50), con un next_cursor estable. Filtros: libreta=true
(solo los guardados en el teléfono) y buscar (un teléfono internacional o un JID, exacto).
Cada contacto trae su contacto (peer), nombre y primer_nombre
(cómo lo guardó el teléfono), en_libreta, push_name (el que manda el contacto),
nombre_negocio (el verificado de una cuenta Business), foto_id, recado (su Info, si la
comparte) y telefono (solo si el motor comprobó su PN).
POST …/contacts/sync pasa a la lista lo que el SDK guardó de la libreta y los nombres que
aprendió (también los del historial). Ocurre solo al conectar; no consulta al servidor, no gasta
cuota y no deja eventos. Responde con cambiados.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/contacts?libreta=true&limit=50" \ -H "Authorization: Bearer TU_API_KEY"
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/contacts?buscar=%2BNUMERO_DEL_CONTACTO" \ -H "Authorization: Bearer TU_API_KEY"import motor "github.com/projectx27/motor-whatsapp/sdk/go"
// llamar: el SDK no trae los contactos, así que es HTTP directo con el token del cliente.func llamar(ctx context.Context, c *motor.Cliente, metodo, ruta 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") } 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 { Contactos []struct { Contacto struct{ PeerRef string `json:"peer_ref"` } `json:"contacto"` Nombre string `json:"nombre"` PushName string `json:"push_name"` EnLibreta bool `json:"en_libreta"` Telefono string `json:"telefono"` } `json:"contactos"` NextCursor string `json:"next_cursor"` HasMore bool `json:"has_more"`}err := llamar(ctx, c, http.MethodGet, "/v1/instances/"+instanciaID+"/contacts?libreta=true", nil, &pagina)const base = 'https://motor.ejemplo.com';
// llamar: una petición a /v1 con el token.async function llamar(metodo, ruta, cuerpo) { const headers = { Authorization: `Bearer ${apiKey}` }; if (cuerpo !== undefined) headers['Content-Type'] = 'application/json'; 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 { contactos, next_cursor, has_more } = await llamar( 'GET', `/v1/instances/${instanciaId}/contacts?libreta=true`);{ "contacto": { "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@s.whatsapp.net", "clase": "usuario", "aliases": ["CONTACTO@lid"] }, "telefono": "+NUMERO_DEL_CONTACTO", "nombre": "Nombre en la libreta", "primer_nombre": "Nombre", "en_libreta": true, "push_name": "Nombre del contacto", "foto_id": "FOTO_ID", "recado": "Disponible", "creado_en": "2026-10-20T12:00:00Z", "actualizado_en": "2026-10-24T15:08:13Z"}2. Resuelve una identidad
Sección titulada «2. Resuelve una identidad»GET /v1/instances/{id}/identities con uno de telefono, jid o peer_ref devuelve el peer
estable y sus alias comprobados por esta instancia: pn y lid cuando el servidor los informó
juntos, y telefono solo si hay un PN comprobado. contacto dice si es un contacto de transporte.
No consulta al servidor ni a otras instancias: lo que esta instancia no comprobó no aparece y la
respuesta es 404 identidad_desconocida. Un LID solo nunca da un teléfono.
curl "https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/identities?jid=CONTACTO@lid" \ -H "Authorization: Bearer TU_API_KEY"// llamar: la función auxiliar del paso 1.var id struct { PeerRef string `json:"peer_ref"` PN string `json:"pn"` LID string `json:"lid"` Telefono string `json:"telefono"` Aliases []string `json:"aliases"`}err := llamar(ctx, c, http.MethodGet, "/v1/instances/"+instanciaID+"/identities?jid="+url.QueryEscape("CONTACTO@lid"), nil, &id)const jid = encodeURIComponent('CONTACTO@lid');const identidad = await llamar('GET', `/v1/instances/${instanciaId}/identities?jid=${jid}`);{ "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1", "jid": "CONTACTO@lid", "clase": "usuario", "aliases": ["CONTACTO@s.whatsapp.net"], "lid": "CONTACTO@lid", "pn": "CONTACTO@s.whatsapp.net", "telefono": "+NUMERO_DEL_CONTACTO", "contacto": true}3. Consulta si un número está en WhatsApp
Sección titulada «3. Consulta si un número está en WhatsApp»POST …/contacts/check con telefonos (números internacionales, con o sin +; los repetidos
cuentan una vez) responde, por cada uno, si está registrado y, si lo está, su jid canónico
(puede ser un LID), su pn, su lid, si es una cuenta Business verificada (negocio,
nombre_verificado) y su peer_ref si la instancia ya lo conocía. En ese caso, el par PN↔LID que
dio el servidor queda comprobado en su peer. Una consulta no crea contactos.
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/contacts/check \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"telefonos": ["+NUMERO_DEL_CONTACTO", "+OTRO_NUMERO"]}'// llamar: la función auxiliar del paso 1.var r struct { Resultados []struct { Telefono string `json:"telefono"` Registrado bool `json:"registrado"` JID string `json:"jid"` } `json:"resultados"` Cuota struct { Cuota int `json:"cuota"` Usadas int `json:"usadas"` } `json:"cuota"`}err := llamar(ctx, c, http.MethodPost, "/v1/instances/"+instanciaID+"/contacts/check", map[string][]string{"telefonos": {"+NUMERO_DEL_CONTACTO", "+OTRO_NUMERO"}}, &r)// Un 429 cuota_agotada trae Retry-After: la cuota vuelve a cero a las 00:00 UTC.const { resultados, cuota } = await llamar('POST', `/v1/instances/${instanciaId}/contacts/check`, { telefonos: ['+NUMERO_DEL_CONTACTO', '+OTRO_NUMERO'],});{ "resultados": [ { "telefono": "+NUMERO_DEL_CONTACTO", "registrado": true, "jid": "CONTACTO@lid", "pn": "CONTACTO@s.whatsapp.net", "lid": "CONTACTO@lid", "negocio": false, "peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1" }, { "telefono": "+OTRO_NUMERO", "registrado": false } ], "cuota": { "cuota": 500, "usadas": 27, "maximo_por_consulta": 20, "renueva": "2026-10-25T00:00:00Z" }}4. El perfil público y la foto de otros
Sección titulada «4. El perfil público y la foto de otros»POST …/contacts/infocondestinos(usuarios:telefono,jidopeer_ref; no grupos) devuelve, de los que tienen cuenta, surecado(si lo comparte con este número),foto_id,nombre_verificado,negocio,lidy cuántosdispositivostiene. Un usuario sin cuenta no aparece. Si la instancia ya lo conocía, su Info y su foto quedan en el contacto y, si cambiaron, llegancontact.aboutycontact.pictureconorigen: consulta. Gasta una consulta por destino.POST …/contacts/picturecondestino(yvista_previa: truepara la miniatura) devuelve lafotocon suestado:disponible(conidy unaurltemporal del CDN de WhatsApp, que vence en horas),oculta(su privacidad no se la muestra a este número; no es un error) osin_foto. Gasta una consulta.GET …/contacts/quotadice la cuota de la app hoy:cuota,usadas,maximo_por_consulta,renueva(00:00 UTC) y undesglosepor tipo (numeros,perfiles,foto,perfil_comercial). El catálogo usa otra cuota (ver Catálogo).
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/contacts/info \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"destinos": [{"peer_ref": "4b7d9e01-0000-4000-8000-0000000000b1"}]}'
curl -X POST https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/contacts/picture \ -H "Authorization: Bearer TU_API_KEY" \ -H "Content-Type: application/json" \ -d '{"destino": {"telefono": "+NUMERO_DEL_CONTACTO"}, "vista_previa": true}'
curl https://motor.ejemplo.com/v1/instances/INSTANCIA_ID/contacts/quota \ -H "Authorization: Bearer TU_API_KEY"// llamar: la función auxiliar del paso 1.ruta := "/v1/instances/" + instanciaID + "/contacts"
var info struct { Perfiles []struct { JID string `json:"jid"` Recado string `json:"recado"` FotoID string `json:"foto_id"` Dispositivos int `json:"dispositivos"` } `json:"perfiles"`}err := llamar(ctx, c, http.MethodPost, ruta+"/info", map[string]any{"destinos": []map[string]string{{"peer_ref": contactoRef}}}, &info)
var foto struct { Foto struct { Estado string `json:"estado"` URL string `json:"url"` } `json:"foto"`}err = llamar(ctx, c, http.MethodPost, ruta+"/picture", map[string]any{"destino": map[string]string{"peer_ref": contactoRef}, "vista_previa": true}, &foto)const ruta = `/v1/instances/${instanciaId}/contacts`;
const { perfiles } = await llamar('POST', `${ruta}/info`, { destinos: [{ peer_ref: contactoRef }],});
const { foto } = await llamar('POST', `${ruta}/picture`, { destino: { peer_ref: contactoRef }, vista_previa: true,}); // foto.estado: "disponible", "oculta" o "sin_foto"
const cuota = await llamar('GET', `${ruta}/quota`);Los eventos
Sección titulada «Los eventos»Este dominio no tiene comandos: las consultas responden en el momento y la lista cambia con lo que
informa WhatsApp. Cada cambio de un contacto llega como un evento con la misma forma: el
contacto completo (como en GET …/contacts/{peer_ref}), el campo que cambió, momento y
origen (whatsapp: lo informó WhatsApp; consulta: lo trajo una consulta de perfil).
full_sync: true marca lo que llegó en una sincronización completa.
| Evento | campo |
Cuándo |
|---|---|---|
contact.updated |
libreta |
El teléfono guardó, renombró o borró el contacto de la libreta. Trae accion: guardado, renombrado o borrado (con contacto.nombre vacío). |
contact.push_name |
push_name |
El contacto mandó otro nombre (llega con un mensaje suyo). |
contact.business_name |
negocio |
Cambió su nombre verificado de negocio. |
contact.picture |
foto |
Cambió su foto de perfil (contacto.foto_id). |
contact.about |
recado |
Cambió su Info, si la comparte con este número. |
{ "contacto": { "contacto": { "peer_ref": "…", "jid": "CONTACTO@s.whatsapp.net", "clase": "usuario", "aliases": [] }, "telefono": "+NUMERO_DEL_CONTACTO", "nombre": "Nombre nuevo en la libreta", "en_libreta": true, "push_name": "Nombre del contacto", "creado_en": "2026-10-20T12:00:00Z", "actualizado_en": "2026-10-24T15:30:00Z" }, "campo": "libreta", "accion": "renombrado", "momento": "2026-10-24T15:30:00Z", "origen": "whatsapp"}{ "contacto": { "contacto": { "peer_ref": "…", … }, "recado": "De vacaciones", "en_libreta": true, … }, "campo": "recado", "momento": "2026-10-24T15:35:00Z", "origen": "whatsapp"}Errores comunes
Sección titulada «Errores comunes»| HTTP | error_code |
Cuándo |
|---|---|---|
| 403 | — | A tu key le falta contacts:read o contacts:lookup. |
| 404 | — | Ese peer_ref no existe en esta instancia. |
| 404 | identidad_desconocida |
Esta instancia no comprobó esa identidad (no la busca en otras ni deduce un teléfono de un LID). |
| 409 | no_conectada |
Una consulta (check, info, picture, sync) sin conexión: no queda en cola. |
| 422 | — | Números inválidos (con sus posiciones; no se consultó ninguno), más de los permitidos por consulta, un grupo como destino, un peer_ref que no es un UUID, o en identities ninguno o más de uno de telefono, jid y peer_ref. |
| 429 | cuota_agotada |
La app usó su cuota de hoy. Retry-After dice cuánto falta para las 00:00 UTC. No se consultó nada. |
| 429 | limitado_por_whatsapp |
WhatsApp limitó las consultas del número. Espera antes de volver a consultar (Retry-After: 60). |
La cuota de fábrica es de 500 consultas por app y día (limites.consultas_identidad_dia) y de
20 números por consulta (limites.numeros_por_consulta, hasta 50). El operador puede darle a
cada app la suya.
Límites de WhatsApp
Sección titulada «Límites de WhatsApp»- La cuota cuenta solo lo válido (caso A42, falla F24, 20/10/2026): con la cuota bajada para
la prueba, un valor inválido dio 422 sin gastar nada, cada número válido gastó una consulta y,
al agotarse, la siguiente dio 429 con
Retry-Afterhasta las 00:00 UTC. - La libreta (caso A43, 22/10/2026): guardar, renombrar y borrar un contacto en el teléfono
dejaron un
contact.updatedcada uno, en orden, conaccionguardado,renombradoyborrado. Elpush_namenuevo llegó comocontact.push_namecon el primer mensaje del contacto. - La foto de otro (casos A43 y A49, 21 y 23/10/2026): poner o quitar la foto llegó como
contact.pictureen 1 s, pero solo a las instancias que tienen a ese contacto. La consulta de la foto diodisponible,oculta(con la privacidad de la foto en «Nadie» o en «Mis contactos», sin ser contacto) ysin_foto, y cada una gastó una consulta. - LID sin teléfono (caso A44, 16/10/2026): en un grupo grande, los participantes que ocultan
su número aparecen solo con su LID;
GET …/identitieslos devuelve con supeer_refy sin teléfono. Ninguna otra instancia les pone número. - La Info del propio número se ve como
contact.abouten las otras instancias que lo tienen de contacto, pero no en la propia (falla F34). Ver Info propia cambiada desde el teléfono.