Integración con el motor de reservas
El widget muestra crédito monetario mediante el proxy autorizado del motor de reservas. Mantén la clave de API de GuestMaker en tu servidor, con guests:read para cálculos y guests:write para retenciones, confirmaciones y liberaciones. Nunca pongas una clave privada en marcado público, una URL de iframe ni un callback del navegador.
</body> en la página de reservas.<!-- Mount and callback must exist before the async loader executes. -->
<div id="gm-loyalty-credit"></div>
<p id="gm-credit-status" role="status"></p>
<script>
window.onLoyaltyCreditApply = function (payload) {
document.getElementById('gm-credit-status').textContent =
'Reserved ' + payload.amount + ' ' + payload.currency + ' credit';
// Display only. Your server reads its saved hold and computes the charge.
// Never authorize a discount from this callback or a browser hold ID.
};
</script>
<script
src="https://www.guestmaker.ai/loyalty-widget/credit-widget.v1.js"
data-api-base-url="/api/guestmaker-credit"
data-on-apply="onLoyaltyCreditApply"
async
></script>data-api-base-urlObligatorio. Ruta relativa a la raíz en el origen de tu sitio de reservas, como /api/guestmaker-credit. Tu servidor debe implementar GET /quote y POST /hold bajo ella. Se rechazan URL externas, cadenas de consulta y credenciales.
data-on-applyOptional. Name of the global window function receiving the display-only payload (default onLoyaltyCreditApply). Define it before the loader executes.
El iframe pide al cargador un cálculo o retención; el cargador llama a tu servidor del mismo origen con la cookie de sesión de reserva. Ambos verifican el origen y la ventana del mensaje. Tu proxy solo acepta un entero de puntos para retener, rechaza identidad y URL del cliente, limita el crédito según el carrito de referencia e impide que las respuestas se guarden en cachés compartidas.
// Run ONLY on your booking engine's server (Web Request/Response, Node 20+).
// Implement the required adapters using your existing guest auth and cart store.
function createCreditProxy({ partnerOrigin, getVerifiedBookingContext,
getTenantApiKey, withBookingLock, saveCreditIntent, saveCreditHold,
fetchImpl = fetch }) {
const reply = (status, data) => Response.json(data, { status,
headers: { 'Cache-Control': 'private, no-store', 'Pragma': 'no-cache', 'Vary': 'Cookie' } });
const fail = (status, message) => reply(status, { error: { message } });
// Match GuestMaker's pointsToAmount: decimal stabilization and half-to-even cents.
const pointsMinor = (points, rate) => {
const value = Number((points / rate * 100).toFixed(10));
const floor = Math.floor(value), fraction = value - floor;
return fraction > 0.5 ? floor + 1 : fraction < 0.5 ? floor : floor % 2 === 0 ? floor : floor + 1;
};
const validContext = (c) => c && typeof c.tenantId === 'string' &&
typeof c.cartId === 'string' && typeof c.memberEmail === 'string' &&
c.memberEmail.length <= 254 && c.memberEmail.includes('@') &&
Number.isSafeInteger(c.totalMinor) && c.totalMinor > 0 && c.totalMinor <= 100000000 &&
/^[A-Z]{3}$/.test(c.currency) && typeof c.creditReferenceId === 'string' &&
c.creditReferenceId.length > 0 && c.creditReferenceId.length <= 200;
return async function handle(request) {
const url = new URL(request.url);
const isQuote = request.method === 'GET' && url.pathname === '/api/guestmaker-credit/quote';
const isHold = request.method === 'POST' && url.pathname === '/api/guestmaker-credit/hold';
if (url.origin !== partnerOrigin || url.search || (!isQuote && !isHold))
return fail(404, 'Unknown credit operation');
const site = request.headers.get('sec-fetch-site');
const origin = request.headers.get('origin');
if ((site && site !== 'same-origin') || (origin && origin !== partnerOrigin) ||
(isHold && origin !== partnerOrigin) || (isQuote && site !== 'same-origin'))
return fail(403, 'Same-origin booking request required');
let points;
if (isHold) {
if (request.headers.get('content-type')?.split(';')[0].trim() !== 'application/json')
return fail(415, 'JSON required');
const reader = request.body?.getReader();
if (!reader) return fail(400, 'Points required');
const decoder = new TextDecoder();
let text = '', bytes = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 1024) { await reader.cancel(); return fail(413, 'Body too large'); }
text += decoder.decode(chunk.value, { stream: true });
}
let body;
try { body = JSON.parse(text + decoder.decode()); } catch { return fail(400, 'Invalid JSON'); }
if (!body || Array.isArray(body) || Object.keys(body).join(',') !== 'points' ||
!Number.isSafeInteger(body.points) || body.points < 1 || body.points > 100000000)
return fail(400, 'Only positive integer points are accepted');
points = body.points;
}
// Verify the guest's session AND cart ownership. Load tenant/member/cart
// values from your server, never browser parameters, email or hold IDs.
const verified = await getVerifiedBookingContext(request);
if (!validContext(verified)) return fail(401, 'Verified guest booking required');
try {
return await withBookingLock(verified.cartId, async () => {
// Re-read under a durable, cross-process cart lock shared with checkout.
const c = await getVerifiedBookingContext(request);
if (!validContext(c) || c.cartId !== verified.cartId || c.tenantId !== verified.tenantId ||
c.memberEmail !== verified.memberEmail) return fail(401, 'Booking session changed');
const key = await getTenantApiKey(c.tenantId); // server-only tenant-key lookup
const signal = AbortSignal.any([request.signal, AbortSignal.timeout(12000)]);
const upstream = async (operation, body) => {
const endpoint = new URL('https://www.guestmaker.ai/api/v1/loyalty/credit/' + operation);
if (operation === 'quote') endpoint.search = new URLSearchParams({
surface: 'booking_direct', email: c.memberEmail, currency: c.currency,
max_amount: String(c.totalMinor / 100) }).toString();
const response = await fetchImpl(endpoint.toString(), {
method: operation === 'quote' ? 'GET' : 'POST', cache: 'no-store', redirect: 'error', signal,
headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}) });
if (!response.ok) throw new Error('Credit service rejected the operation');
return (await response.json()).data;
};
if (!isHold || !c.creditIntent) {
const q = await upstream('quote');
if (!isHold) {
// Return only widget fields; omit member identifiers and other holds.
const data = {};
for (const field of ['surface_enabled', 'spendable_points', 'redemption_rate', 'currency',
'max_amount', 'max_points', 'min_points', 'step_points', 'presets']) data[field] = q[field];
return reply(200, { success: true, data });
}
if (!q.surface_enabled || q.currency !== c.currency || !Number.isFinite(q.redemption_rate) ||
q.redemption_rate <= 0 || points < q.min_points || points > q.max_points ||
!Number.isSafeInteger(q.step_points) || q.step_points < 1 || points % q.step_points !== 0 ||
pointsMinor(points, q.redemption_rate) < 1 || pointsMinor(points, q.redemption_rate) > c.totalMinor)
return fail(422, 'Credit exceeds the server booking quote');
// Persist BEFORE calling hold. An uncertain response retries the same intent.
c.creditIntent = { points, currency: c.currency, referenceId: c.creditReferenceId,
amountMinor: pointsMinor(points, q.redemption_rate),
tenantId: c.tenantId, memberEmail: c.memberEmail, cartId: c.cartId };
await saveCreditIntent(c, c.creditIntent);
}
const intent = c.creditIntent;
if (intent.points !== points || intent.currency !== c.currency ||
intent.referenceId !== c.creditReferenceId || intent.amountMinor > c.totalMinor ||
intent.tenantId !== c.tenantId || intent.memberEmail !== c.memberEmail || intent.cartId !== c.cartId)
return fail(409, 'Booking credit intent changed; reconcile before retrying');
const hold = await upstream('hold', { email: c.memberEmail, surface: 'booking_direct',
channel: 'website', currency: c.currency, external_reference_id: intent.referenceId, points });
if (hold.points !== points || hold.currency !== c.currency ||
Math.round(hold.amount_value * 100) !== intent.amountMinor ||
hold.status !== 'active' || !(Date.parse(hold.expires_at) > Date.now()))
return fail(502, 'Hold differs from the saved booking intent');
await saveCreditHold(c, hold);
return reply(200, { success: true, data: { ...hold, external_reference_id: intent.referenceId } });
});
} catch { return fail(502, 'Credit unavailable; retry the same booking intent'); }
};
}// In YOUR server's /api/guestmaker-credit/quote and /hold routes:
const handle = createCreditProxy({
partnerOrigin: 'https://booking.example.com',
getVerifiedBookingContext, // your verified guest-session + authorized cart lookup
getTenantApiKey, // your server secret store, indexed by verified tenant
withBookingLock, // your durable cart lock shared with checkout/cancellation
saveCreditIntent, // your durable cart write before the upstream hold
saveCreditHold, // your durable cart write used for server-side pricing
});
// Next.js route handlers can export GET = handle and POST = handle.
// No GuestMaker endpoint implements this partner-owned proxy for you.El adaptador de contexto debe verificar la sesión del huésped y la titularidad del carrito y devolver tenantId, cartId, memberEmail, totalMinor (céntimos enteros para este ejemplo EUR), currency y un creditReferenceId estable creado en el servidor. También carga cualquier creditIntent guardado. Bloquea conjuntamente los cambios de precios, retención, compra y cancelación del carrito; conserva la intención y la retención de forma persistente para que los reintentos usen el mismo socio, puntos y referencia. Limita estas rutas a tu origen de reservas, aplica límites por sesión y asegúrate de que el service worker no intercepte solicitudes privadas de API. La API de GuestMaker no verifica por ti la sesión del huésped. El ejemplo usa el canal website, que debe estar activado para crédito monetario.
Las inserciones antiguas basadas en claves siguen siendo compatibles durante la migración, pero no se admiten para nuevas integraciones públicas. Sustitúyelas por el modo proxy y elimina la clave del navegador. Si se expuso una clave privada, su propietario debe sustituirla después de migrar las integraciones dependientes; no rotes credenciales ajenas automáticamente.
window.onLoyaltyCreditApply antes de cargar el script.interface LoyaltyCreditApplyPayload {
hold_id: string;
points: number;
amount: number; // major units, server-selected booking currency
currency: string;
expires_at: string; // ISO 8601 timestamp
external_reference_id: string; // server-owned booking credit reference
}
declare global {
interface Window {
onLoyaltyCreditApply?: (payload: LoyaltyCreditApplyPayload) => void;
}
}// Server-side, under your booking lock. Read the saved intent and hold
// from your cart, verify ownership/expiry, then compute the final charge.
// After the booking succeeds, confirm using that server-owned reference.
const res = await fetch('https://www.guestmaker.ai/api/v1/loyalty/credit/confirm', {
method: 'POST',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(12000),
headers: {
'Authorization': 'Bearer ' + await getTenantApiKey(booking.tenantId),
'Content-Type': 'application/json',
},
body: JSON.stringify({
external_reference_id: booking.creditIntent.referenceId,
}),
});
if (!res.ok) {
// Persist a reconciliation job and retry confirm with the SAME reference.
// A timeout can follow a committed operation; never auto-release or roll back.
throw new Error('Cash credit confirmation needs reconciliation');
}
// Release only after your server verifies that the booking was abandoned:
// POST /api/v1/loyalty/credit/release { external_reference_id: savedReference }
// A completed redemption instead uses /reverse; see the endpoint reference.
// Store the reservation association in your own booking records; confirm accepts
// hold_id or external_reference_id, not a reservation_id field.Pasa el mismo external_reference_id al confirmar que al retener. La idempotencia se basa en ese valor (regla #630), por lo que es seguro reintentar cualquiera de las dos llamadas.