Ir al contenido principal

Integración Kunas → HubSpot con Zapier

Conozca como las reservas nuevas en Kunas pueden crear/actualizar un Contacto en HubSpot vía Zapier

Escrito por Experto Kunas

1. Objetivo y alcance

Cada reserva nueva en Kunas crea o actualiza automáticamente el registro del huésped en HubSpot (portal 44081578), sin trabajo manual.

Todo el flujo se implementa dentro de Zapier: un Catch Hook recibe el aviso de reserva nueva desde Kunas, y dos pasos de código (Code by Zapier, en JavaScript) hacen el resto del trabajo.

Limitación importante de este alcance (solo Contacto): un Contacto en HubSpot representa a la persona, no a una reserva específica. Si un mismo huésped reserva varias veces, los campos de la reserva (fechas, valor, canal) del Contacto se van a sobreescribir con los datos de la reserva más reciente — no queda historial de reservas anteriores en el Contacto mismo. Si más adelante se necesita ver el historial completo de estadías por huésped, hay que pasar a la opción "Contacto + Negocio", donde cada reserva es un Deal asociado al Contacto.

2. Arquitectura (dentro de Zapier)

Paso 1 — Code Step (JavaScript): hace login a Kunas/HotelSync (con caché del pkey para no reloguear en cada ejecución) y llama a Get guest para completar nombre, email y teléfono del huésped a partir del ID que trae el webhook.

Paso 2 — Code Step (JavaScript): con esos datos, busca en HubSpot si el contacto ya existe por email, y crea o actualiza según corresponda.

[Kunas / HotelSync]
|
v
[Zapier - Catch Hook]
|
v
[Code Step 1 - JS]
Login a Kunas (con caché de pkey)
+ Get guest (contacto)
|
v
[Code Step 2 - JS]
Buscar contacto por email en HubSpot
Crear o actualizar

3. Autenticación con HubSpot

Se usa una Private App del portal 44081578, que da un access_token permanente mientras no se revoque.

Scopes necesarios: crm.objects.contacts.read y crm.objects.contacts.write.

Authorization: Bearer <access_token de la Private App>
Content-Type: application/json

4. Buscar contacto existente (find before create)

HubSpot obliga a que el email sea único por contacto — pero igual conviene buscar primero para saber si hay que crear o actualizar, y para no perder datos ya cargados en el contacto (como etapa_funel u otras propiedades existentes).

POST https://api.hubapi.com/crm/v3/objects/contacts/search
Content-Type: application/json

{
"filterGroups": [
{
"filters": [
{ "propertyName": "email", "operator": "EQ", "value": "<email del huésped>" }
]
}
],
"limit": 1
}

Si results viene vacío → no existe, hay que crear. Si trae un resultado → usar ese id para actualizar.

5. Crear o actualizar

Crear

POST https://api.hubapi.com/crm/v3/objects/contacts
{
"properties": { "email": "...", "firstname": "...", "lastname": "...", "phone": "...", ... }
}

Actualizar

6. Mapeo de campos y propiedades personalizadas a crear en HubSpot

Origen (Kunas)

Propiedad en HubSpot

¿Existe ya?

first_name

firstname (nativa)

last_name

lastname (nativa)

email

email (nativa)

phone

phone (nativa)

id_reservations

id_reserva_kunas (texto)

Crear

date_arrival

fecha_llegada_kunas (fecha)

Crear

date_departure

fecha_salida_kunas (fecha)

Crear

total_price

valor_reserva_kunas (número)

Crear

id_channels

canal_reserva_kunas (texto)

Crear

Estas 5 propiedades personalizadas hay que crearlas en HubSpot antes de correr el flujo (Configuración > Propiedades > Contacto).

7. Pendientes / riesgos abiertos

  1. Crear las 5 propiedades personalizadas en HubSpot antes de activar el flujo (ver punto 6).

  2. Confirmar los scopes exactos de la Private App — si falta alguno, las llamadas de creación/búsqueda van a devolver 403.

  3. Definir tabla de traducción id_channels → nombre de canal (Airbnb/Booking/directo) — pendiente localizar el endpoint correspondiente en Kunas/HotelSync.

  4. Decidir si conviene marcar estos contactos como "marketing" o "non-marketing" al crearlos — el portal está cerca del límite de contactos de marketing. Recomendable crearlos como "non-marketing" por defecto.

  5. Revisar si ya existe una automatización que toque estos mismos contactos (por ejemplo la sincronización Intercom↔HubSpot) para evitar que dos procesos distintos se pisen los campos entre sí.

8. Código — Paso 1 (Kunas)

Pegar en el primer paso "Code by Zapier" del Zap, justo después del Catch Hook.

Input Data a configurar en ese paso: kunas_token, kunas_username, kunas_password, kunas_id_properties, kunas_env

// ==============================================================
// PASO 1 — Code by Zapier (JavaScript)
// Trigger anterior: Catch Hook (webhook registrado en Kunas/HotelSync)
// Este paso: hace login (con cache de pkey), llama a "Get guest" y
// devuelve un objeto plano listo para mapear en el paso siguiente.
// ==============================================================
//
// CONFIGURA ESTOS "INPUT DATA" EN EL PASO DE ZAPIER (no los escribas
// aquí en el código — así quedan cifrados por Zapier, no en texto plano):
// kunas_token -> el token fijo (ej: 3445e04856573160b11498994e9feac342628488)
// kunas_username -> usuario del cliente en Kunas
// kunas_password -> contraseña del cliente en Kunas
// kunas_id_properties -> ID de la propiedad en Kunas (ej: 93)
// kunas_env -> "production" o "staging"
//
// CAMPOS DEL WEBHOOK: verifica los nombres reales en el "Test trigger"
// del Catch Hook — Zapier aplana JSON anidado con "__", así que es
// probable que sea inputData['data__id_reservations'], etc. Ajusta
// las líneas marcadas con "// AJUSTAR" según lo que veas ahí.

const BASE_URL =
inputData.kunas_env === 'staging'
? 'https://beta.hotelsync.com'
: 'https://app.hotelsync.com';

const STORE_SECRET = 'kunas-hotelsync-pkey'; // namespace fijo del store
const store = StoreClient(STORE_SECRET);

async function login() {
const res = await fetch(`${BASE_URL}/api/user/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: inputData.kunas_token,
username: inputData.kunas_username,
password: inputData.kunas_password,
remember: 0,
}),
});

if (!res.ok) {
throw new Error(`Login falló: ${res.status} ${await res.text()}`);
}

const data = await res.json();
const pkey = data.userInf && data.userInf.pkey;

if (!pkey) {
throw new Error('Login OK pero no se encontró "pkey" en la respuesta. Revisa el shape real del JSON.');
}

await store.set('pkey', pkey);
return pkey;
}

async function getPkey() {
const cached = await store.get('pkey');
if (cached) return cached;
return login();
}

async function getGuest(pkey, idGuest) {
const res = await fetch(`${BASE_URL}/api/guests/data/guest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: inputData.kunas_token,
key: pkey,
id_properties: Number(inputData.kunas_id_properties),
id_guests: Number(idGuest),
}),
});
return res;
}

async function run() {
// AJUSTAR: nombres reales de campos del payload del webhook
const idReservation = inputData['data__id_reservations'];
const idGuest = inputData['data__id_primary_guests'];
const dateArrival = inputData['data__date_arrival'];
const dateDeparture = inputData['data__date_departure'];
const totalPrice = inputData['data__total_price'];
const idChannels = inputData['data__id_channels'];
const status = inputData['data__status'];

if (!idGuest) {
throw new Error('No llegó id_primary_guests en el payload del webhook — revisa el mapeo de campos.');
}

let pkey = await getPkey();
let guestRes = await getGuest(pkey, idGuest);

// Si la sesión expiró, relogueamos una vez y reintentamos
if (guestRes.status === 401) {
pkey = await login();
guestRes = await getGuest(pkey, idGuest);
}

if (!guestRes.ok) {
throw new Error(`Get guest falló: ${guestRes.status} ${await guestRes.text()}`);
}

const guest = await guestRes.json();

output = {
id_reservation: idReservation,
id_guest: idGuest,
status: status,
date_arrival: dateArrival,
date_departure: dateDeparture,
total_price: totalPrice,
id_channels: idChannels,
first_name: guest.first_name || '',
last_name: guest.last_name || '',
email: guest.email || '',
phone: guest.phone || '',
};
}

await run();

9. Código — Paso 2 (HubSpot)

Pegar en el segundo paso "Code by Zapier" del Zap, inmediatamente después del Paso 1.

Input Data a configurar en ese paso: hubspot_access_token

// ==============================================================
// PASO 2 (versión HubSpot) — Code by Zapier (JavaScript)
// Recibe el output del Paso 1 (login a Kunas + datos del huésped)
// y crea/actualiza el Contacto en HubSpot.
// ==============================================================
//
// ⚠️ Antes de producción, crear en HubSpot (portal 44081578) las 5
// propiedades personalizadas de Contacto listadas en el instructivo
// (§6): id_reserva_kunas, fecha_llegada_kunas, fecha_salida_kunas,
// valor_reserva_kunas, canal_reserva_kunas.
//
// CONFIGURA ESTE "INPUT DATA" EN EL PASO DE ZAPIER:
// hubspot_access_token -> token de la Private App del portal 44081578
//
// Los datos de la reserva llegan como inputData del Paso 1:
// inputData.id_reservation, .first_name, .last_name, .email, .phone,
// .date_arrival, .date_departure, .total_price, .id_channels

const HUBSPOT_BASE = 'https://api.hubapi.com';

function hubspotHeaders() {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${inputData.hubspot_access_token}`,
};
}

// 1) Buscar contacto existente por email
async function findContactByEmail(email) {
if (!email) return null;

const res = await fetch(`${HUBSPOT_BASE}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: hubspotHeaders(),
body: JSON.stringify({
filterGroups: [{ filters: [{ propertyName: 'email', operator: 'EQ', value: email }] }],
limit: 1,
}),
});

if (!res.ok) throw new Error(`Búsqueda de contacto falló: ${res.status} ${await res.text()}`);

const data = await res.json();
return data.results && data.results.length ? data.results[0] : null;
}

// 2) Construir las propiedades del contacto a partir de los datos de la reserva
function buildProperties() {
const props = {
firstname: inputData.first_name || undefined,
lastname: inputData.last_name || undefined,
email: inputData.email || undefined,
phone: inputData.phone || undefined,
id_reserva_kunas: inputData.id_reservation ? String(inputData.id_reservation) : undefined,
fecha_llegada_kunas: inputData.date_arrival || undefined,
fecha_salida_kunas: inputData.date_departure || undefined,
valor_reserva_kunas: inputData.total_price ? Number(inputData.total_price) : undefined,
canal_reserva_kunas: inputData.id_channels ? String(inputData.id_channels) : undefined,
};

// HubSpot no acepta claves con valor undefined en el body
Object.keys(props).forEach((k) => props[k] === undefined && delete props[k]);
return props;
}

async function createContact(properties) {
const res = await fetch(`${HUBSPOT_BASE}/crm/v3/objects/contacts`, {
method: 'POST',
headers: hubspotHeaders(),
body: JSON.stringify({ properties }),
});
if (!res.ok) throw new Error(`Crear contacto falló: ${res.status} ${await res.text()}`);
return res.json();
}

async function updateContact(contactId, properties) {
const res = await fetch(`${HUBSPOT_BASE}/crm/v3/objects/contacts/${contactId}`, {
method: 'PATCH',
headers: hubspotHeaders(),
body: JSON.stringify({ properties }),
});
if (!res.ok) throw new Error(`Actualizar contacto falló: ${res.status} ${await res.text()}`);
return res.json();
}

async function run() {
if (!inputData.email) {
throw new Error('No llegó email del huésped desde el Paso 1 — no se puede crear/buscar el contacto sin email.');
}

const properties = buildProperties();
const existing = await findContactByEmail(inputData.email);

if (existing) {
const result = await updateContact(existing.id, properties);
output = { action: 'updated', contact_id: existing.id, result };
} else {
const result = await createContact(properties);
output = { action: 'created', contact_id: result.id, result };
}
}

await run();

10. Notas de seguridad

  • El access_token de la Private App de HubSpot debe configurarse como Input Data cifrado en Zapier, nunca en el código ni en documentos compartidos.

  • Si en algún momento se sospecha que el token se expuso, se puede rotar directamente desde la configuración de la Private App en HubSpot sin afectar otras integraciones.

¿Ha quedado contestada tu pregunta?