VitrinaAPI

Buscar, crear y fusionar contactos

Las personas con las que habla un workspace, sus empresas y sus canales.

Un workspace guarda una fila por cada persona con la que habla. La fila no cambia de forma según el rubro. La misma sirve para un paciente que pide hora, para alguien que compra un auto, para un arrendatario o para un alumno. Lo propio de cada rubro vive en los atributos que el workspace define, nunca en la forma del contacto.

Leer pide contacts:read; escribir, contacts:write. Las empresas usan su propio par: companies:read / companies:write.

La forma de un contacto

curl "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c" \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": {
    "id": "5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c",
    "external_id": null,
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "lifecycle_stage": "prospect",
    "job_title": "Gerente de operaciones",
    "company_id": "9a1b2c3d-4e5f-4a6b-8c7d-8e9f0a1b2c3d",
    "origin_channel": "manual",
    "email_consent": false,
    "email_status": "subscribed",
    "merged_into_contact_id": null,
    "created_at": "2026-09-21T14:03:11.000Z",
    "display_name": "María González",
    "named": true,
    "channels": ["whatsapp"],
    "lead_sources": ["website"]
  }
}

Tres campos merecen una lectura antes de usarlos.

display_name y named van juntos. display_name nunca viene vacío: si el contacto no tiene nombre, cae al teléfono formateado, al correo o a un handle. named dice si display_name es el nombre de la persona. Revisa named antes de poner display_name en un texto que lee el cliente. Si no, una plantilla saludará a alguien por su propio número de teléfono.

lead_sources manda sobre origin_channel. Los dos responden «de dónde salió esta persona». lead_sources son los orígenes reales de sus leads; origin_channel es lo que alguien escribió a mano. Lo derivado gana: origin_channel solo es la respuesta cuando lead_sources viene vacío.

id es un uuid y external_id es una etiqueta. El uuid es la identidad del contacto en Vitrina. external_id es tu propia llave: el id del sistema desde el que importaste, un meli:{id}, un wa_id. Es lo que usa la importación para reconocer una fila que ya existe.

Crear no deduplica; importar sí

POST /contacts escribe una fila y nada más. Manda dos veces el mismo correo y tendrás dos contactos.

curl -X POST https://api.vitrinadev.com/api/v1/contacts \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6b1f0b3a-9d12-4a4e-9b60-5bb4f2a51f11" \
  -d '{
    "name": "María González",
    "email": "[email protected]",
    "phone": "+56912345678",
    "lifecycle_stage": "prospect"
  }'

Solo name es obligatorio. Un contacto sin correo ni teléfono se crea igual, y queda inalcanzable por todos los canales hasta que se le agregue uno. lifecycle_stage parte en unknown y email_consent parte en false: crear un contacto nunca otorga consentimiento.

Si lo que quieres es «crear o actualizar», usa POST /contacts/import, que sí compara identidad:

curl -X POST https://api.vitrinadev.com/api/v1/contacts/import \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "csv": "name,email,phone\nPedro Ramírez,[email protected],+56987654321\n",
    "dry_run": true
  }'
{
  "data": {
    "total": 1,
    "created": 1,
    "updated": 0,
    "skipped": 0,
    "failed": 0,
    "dry_run": true,
    "rows": [{ "row": 1, "action": "create", "name": "Pedro Ramírez" }]
  }
}

La comparación va en orden: external_id, después email, después phone. Lo que coincide se actualiza rellenando vacíos, nunca pisando un valor que ya estaba. dry_run: true corre todo el proceso, incluida la búsqueda real de duplicados, y no escribe nada. El informe que devuelve tiene la misma forma que el definitivo, así que puedes mostrarlo y pedir confirmación antes de tocar la base.

Buscar

curl "https://api.vitrinadev.com/api/v1/contacts/search?q=gonz&limit=25" \
  -H "Authorization: Bearer $VITRINA_KEY"

q busca a la vez en nombre, correo, teléfono y external_id, sin distinguir mayúsculas ni tildes («Jose» encuentra «José»).

meta.total es la cantidad de coincidencias, no el tamaño de la página. Es el denominador que necesita un paginador; pedir limit=1000 no significa que haya mil.

Los filtros se combinan: lifecycle_stage (uno o varios separados por coma), channel, lead_source, tag_id, company_id y exclude_id. company_id=none selecciona los contactos que no pertenecen a ninguna empresa.

Para los denominadores de la vista completa, GET /contacts/stats los calcula sobre todo el libro, no sobre la página cargada.

Fusionar duplicados

La fusión no se deshace. El contacto de la ruta sobrevive; cada id de secondary_ids queda como lápida.

curl -X POST "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c/merge" \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "secondary_ids": ["8c41b0d2-6e5f-4a19-b73c-0d5e9f2a1b84"] }'
{
  "data": {
    "primary": { "id": "5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c", "email": "[email protected]" },
    "merged_count": 1,
    "conversations_reassigned": 3,
    "filled_fields": ["email"]
  }
}

Se mueven al sobreviviente las conversaciones, las identidades de canal, los leads, los tickets y los documentos comerciales. Nombre, correo, teléfono, idioma, país, marca, avatar y el RUT con su tipo se rellenan solo donde el sobreviviente estaba vacío. filled_fields nombra las columnas que ganaron algo: nombres de campo, nunca valores.

Las lápidas siguen respondiendo a GET /contacts/{id}, con merged_into_contact_id apuntando al sobreviviente. Por eso un id guardado hace meses no se pierde: se sigue el puntero.

Para encontrar qué fusionar hay dos lecturas distintas: GET /contacts/duplicates barre todo el libro y devuelve grupos, y GET /contacts/{id}/duplicates propone candidatos para un contacto.

Canales: por dónde se le llega

El campo phone del contacto es un dato de la ficha. Lo que decide a qué persona llega un mensaje entrante es la tabla de canales.

curl -X POST "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c/channels" \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "kind": "whatsapp", "identifier": "+56912345678", "label": "Personal", "verified": true }'

kind es uno de email, phone, whatsapp, instagram, messenger, web, sms, tiktok. Un correo que la persona mencionó en una conversación es solo un dato hasta que existe como canal. Solo entonces su próximo mensaje llega como ella y no como una desconocida. verified afirma que el proveedor confirmó la identidad, no que alguien le creyó.

Los canales sobreviven a una fusión: se mueven al sobreviviente.

Atributos: aquí vive el rubro

La forma de un contacto es la misma en todos los workspaces. «Presupuesto», «previsión» o «curso» son atributos que cada workspace define.

curl -X PUT "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c/attributes" \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "attributes": [{ "key": "presupuesto", "value": "18000000" }] }'

La lectura mezcla los valores guardados con las definiciones del workspace, así que una sola llamada basta para dibujar un formulario con sus etiquetas y tipos. Un atributo definido pero sin valor vuelve con id: null y value: null: así sabes que hay un campo que dibujar vacío.

Un atributo de tipo file no se escribe por aquí. Sus bytes van por POST /contacts/{id}/attributes/{key}/file, un multipart/form-data con una parte file y un máximo de 25 MB. El valor guardado es un descriptor, nunca una URL. La descarga pasa por la API, no por un enlace firmado.

Consentimiento

Hay dos superficies y responden preguntas distintas.

email_consent es la puerta del marketing por correo, y el número de contactos alcanzables con consentimiento es la unidad que se factura. Un contacto es alcanzable cuando tiene un correo válido, email_consent en true, email_status en subscribed, y no está fusionado, archivado ni bloqueado: las cuatro cosas, no solo el consentimiento.

curl "https://api.vitrinadev.com/api/v1/contacts/marketable-count" \
  -H "Authorization: Bearer $VITRINA_KEY"
{ "data": { "marketable": 62 } }

POST /contacts/bulk-consent levanta la bandera para hasta mil contactos. Registra una afirmación; no obtiene el consentimiento ni guarda cuándo, cómo ni sobre qué texto se dio. Quien la llama está declarando que el consentimiento existe y sigue siendo responsable de probarlo. flipped suele ser menor que la cantidad de ids enviados, porque se saltan los que ya consentían y los que no tienen correo. Eso no es un error.

La otra superficie sí es evidencia. POST /contacts/{id}/outbound-preferences agrega un hecho al registro de preferencias: un channel, un scope (marketing, service, promised_followup, all_proactive), un status y, si la hay, el mensaje del cliente que lo prueba.

curl -X POST "https://api.vitrinadev.com/api/v1/contacts/5f3a9c1e-2b7d-4e8a-9c0f-1d2e3f4a5b6c/outbound-preferences" \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "email",
    "scope": "marketing",
    "status": "blocked",
    "legal_basis": "Pidió por escrito no recibir promociones"
  }'

Es un registro que solo crece: revocar es volver a publicar con el status contrario, y la fila anterior se queda legible. Por eso no hay PATCH ni DELETE. Un bloqueo de marketing no bloquea los mensajes de servicio; un all_proactive vigente manda sobre todos los ámbitos. La política de envíos hace cumplir estos hechos. Un envío posterior a ese contacto en ese ámbito se rechaza con un Bloqueo, no se descarta en silencio.

Moderación

Tres interruptores comparten cuerpo ({ "value": true }) y consecuencias distintas.

EndpointQué hace
POST /contacts/{id}/blockMarca blocked_at y lleva lifecycle_stage a blocked. La próxima conversación nace filtrada.
POST /contacts/{id}/report-spamLo mismo, con otro motivo registrado.
POST /contacts/{id}/archiveSolo orden en el directorio: desaparece de la lista y de los conteos, y todo lo que lo referencia queda intacto. No toca lifecycle_stage.

Al limpiar un bloqueo, lifecycle_stage vuelve a unknown: la etapa comercial anterior no se recupera, así que léela antes si importa.

Archivar es lo más parecido a borrar que existe: un contacto no se elimina, porque hay conversaciones, leads y documentos que lo nombran.

Para callar a la IA sin sacar la conversación de la bandeja está POST /contacts/{id}/bot-replies-disabled. Es otra cosa: el mensaje entra igual, la conversación se ve y se asigna, y la responde una persona.

Empresas

Una empresa agrupa contactos. No es la entidad legal. El RUT, la razón social, el giro y el representante viven en el contacto que representa a la empresa (person_kind: "juridica"). Es a ese contacto al que se le hace un contrato o una factura.

curl -X POST https://api.vitrinadev.com/api/v1/companies \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Constructora Andes SpA", "domain": "andes.cl", "industry": "Construcción" }'

El vínculo vive en el contacto: se pone con PATCH /contacts/{id} y company_id, se quita con company_id: null, y la gente de una empresa se lista con GET /contacts/search?company_id=<uuid>. GET /companies trae cada empresa con su contact_count de contactos vivos.

Borrar una empresa es permanente y no borra a nadie: los contactos se quedan con company_id en null.

Qué avisa Vitrina

Tres eventos cuentan lo que pasa con un contacto.

EventoCuándo
contact.createdApareció una persona nueva, por cualquier camino: la API, una importación (uno por fila creada), un mensaje entrante de un número desconocido, un lead de portal, un formulario del sitio, una llamada. Una vez por contacto.
contact.updatedSe escribieron columnas del contacto. data.updated_fields nombra cuáles: nombres de campo, nunca valores.
contact.mergedSe fusionaron contactos. resource.id es el sobreviviente y changes.contact_id.from lista las lápidas.

La entrega por defecto es el aviso: qué pasó, sobre qué recurso, quién lo hizo y cuándo, con una resource.url que lees con tu propia credencial. Ahí se aplican los permisos y queda registro de la lectura. Si activas «Incluir datos del recurso» llega también data. Aun así hay dos cosas que nunca viajan en ningún evento de contacto: la identidad legal (RUT, razón social, giro, representante) y la dirección estructurada. Se leen con GET /contacts/{id} y una credencial con contacts:read.

contact.merged es el que conviene escuchar si guardas ids de Vitrina. El aviso, sin data, ya trae en changes.contact_id los ids que murieron y el que quedó. Con eso reapuntas lo tuyo sin pedir datos personales.

Quién puede ver qué

El directorio es del workspace. La visibilidad por registro («ve solo lo asignado») acota conversaciones, leads y tickets en sus propios endpoints; la ficha del contacto queda fuera de ese recorte. Un token personal lee exactamente lo que lee la sesión de esa persona.

Lo que sí acota el contacto son los permisos. Una credencial con contacts:write pero sin contacts:read recibe las respuestas de escritura sin la identidad legal y sin la dirección estructurada. El resto vuelve igual, y el recorte es el mismo en todas las superficies.

Cosas que se olvidan

  • POST /contacts no deduplica. Busca antes, o importa.
  • meta.total de la búsqueda son coincidencias, no filas devueltas.
  • La fusión no se deshace y las lápidas siguen respondiendo.
  • Revisa named antes de usar display_name en un texto que lee el cliente.
  • bulk-consent cambia la cuenta que se factura y no tiene revocación masiva.
  • Un POST publicado acepta Idempotency-Key: reintenta con la misma llave y recibes la misma respuesta en vez de un segundo contacto.

En esta página