[object Object]

← back to Costa Rica

costa-rica: marketplace API — JWT auth, listings, bookings, payments, host onboarding (SINPE/Plaid), payment+whatsapp webhooks; wired pre-basic-auth; E2E sandbox flow verified (book->pay->confirm->wa) — TK-10346

a1c238c12d78da5bb700ec47c0608e7c3d6e75a3 · 2026-08-07 09:51:43 -0700 · Steve

Files touched

Diff

commit a1c238c12d78da5bb700ec47c0608e7c3d6e75a3
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 09:51:43 2026 -0700

    costa-rica: marketplace API — JWT auth, listings, bookings, payments, host onboarding (SINPE/Plaid), payment+whatsapp webhooks; wired pre-basic-auth; E2E sandbox flow verified (book->pay->confirm->wa) — TK-10346
---
 lib/auth.js        |  62 +++++++++++
 lib/payouts.js     |  52 ++++++++++
 lib/plaid.js       |  48 +++++++++
 lib/whatsapp.js    | 156 ++++++++++++++++++++++++++++
 routes/app.js      | 297 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 routes/webhooks.js |  79 ++++++++++++++
 server.js          |  15 +++
 7 files changed, 709 insertions(+)

diff --git a/lib/auth.js b/lib/auth.js
new file mode 100644
index 0000000..c8c0a6d
--- /dev/null
+++ b/lib/auth.js
@@ -0,0 +1,62 @@
+'use strict';
+// Zero-dependency auth: HMAC-SHA256 JWTs + scrypt password hashing, both from
+// Node's built-in crypto. Tokens carry { sub, role, host_id }.
+const crypto = require('crypto');
+
+const SECRET = process.env.JWT_SECRET || process.env.BASIC_AUTH_PASS || 'dev-insecure-secret-change-me';
+const TTL_SEC = parseInt(process.env.JWT_TTL_SEC || String(60 * 60 * 24 * 30), 10); // 30d
+
+const b64u = (buf) => Buffer.from(buf).toString('base64url');
+const b64uJson = (o) => b64u(JSON.stringify(o));
+
+function signToken(payload, ttl = TTL_SEC) {
+  const header = { alg: 'HS256', typ: 'JWT' };
+  const now = Math.floor(Date.now() / 1000);
+  const body = { iat: now, exp: now + ttl, ...payload };
+  const data = `${b64uJson(header)}.${b64uJson(body)}`;
+  const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
+  return `${data}.${sig}`;
+}
+
+function verifyToken(token) {
+  if (!token) return null;
+  const parts = token.split('.');
+  if (parts.length !== 3) return null;
+  const [h, p, sig] = parts;
+  const expect = crypto.createHmac('sha256', SECRET).update(`${h}.${p}`).digest('base64url');
+  try {
+    if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null;
+  } catch { return null; }
+  let body; try { body = JSON.parse(Buffer.from(p, 'base64url').toString()); } catch { return null; }
+  if (body.exp && Math.floor(Date.now() / 1000) > body.exp) return null;
+  return body;
+}
+
+// scrypt password hashing (salt$hash)
+function hashPassword(pw) {
+  const salt = crypto.randomBytes(16);
+  const hash = crypto.scryptSync(pw, salt, 32);
+  return `${salt.toString('hex')}$${hash.toString('hex')}`;
+}
+function verifyPassword(pw, stored) {
+  if (!stored || !stored.includes('$')) return false;
+  const [saltHex, hashHex] = stored.split('$');
+  const hash = crypto.scryptSync(pw, Buffer.from(saltHex, 'hex'), 32);
+  try { return crypto.timingSafeEqual(hash, Buffer.from(hashHex, 'hex')); } catch { return false; }
+}
+
+// Express middleware
+function authRequired(req, res, next) {
+  const t = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
+  const claims = verifyToken(t);
+  if (!claims) return res.status(401).json({ error: 'unauthorized' });
+  req.user = claims;
+  next();
+}
+function optionalAuth(req, _res, next) {
+  const t = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
+  req.user = verifyToken(t) || null;
+  next();
+}
+
+module.exports = { signToken, verifyToken, hashPassword, verifyPassword, authRequired, optionalAuth };
diff --git a/lib/payouts.js b/lib/payouts.js
new file mode 100644
index 0000000..b900dda
--- /dev/null
+++ b/lib/payouts.js
@@ -0,0 +1,52 @@
+'use strict';
+// Payout engine — settles a host's earnings after a booking completes.
+// Routes by payout_method.kind:
+//   sinpe_movil / cr_iban -> SINPE via the payment provider (Tilopay)  [rail=sinpe]
+//   plaid_ach             -> ACH via Plaid                              [rail=plaid_ach]
+// Records every attempt in the payouts table with live_mode + provider_ref.
+
+const { pool } = require('./db');
+const { getProvider } = require('./payments');
+
+async function createPayoutForBooking(bookingId) {
+  const { rows: [b] } = await pool.query(`SELECT * FROM bookings WHERE id=$1`, [bookingId]);
+  if (!b) throw new Error('booking not found');
+  if (!b.host_id) throw new Error('booking has no host');
+  if (b.status !== 'completed') throw new Error(`booking not completed (${b.status})`);
+
+  const { rows: [pm] } = await pool.query(
+    `SELECT pm.* FROM payout_methods pm
+       JOIN hosts h ON h.id = pm.host_id
+      WHERE pm.host_id = $1 AND (pm.is_default OR h.default_payout_method_id = pm.id)
+      ORDER BY pm.is_default DESC LIMIT 1`, [b.host_id]);
+  if (!pm) throw new Error('host has no payout method');
+
+  const rail = pm.kind === 'plaid_ach' ? 'plaid_ach' : 'sinpe';
+  const provider = getProvider();
+  const amount = b.host_payout;
+  const currency = b.currency;
+
+  const { rows: [payout] } = await pool.query(
+    `INSERT INTO payouts (host_id, booking_id, payout_method_id, rail, currency, amount, status, live_mode)
+     VALUES ($1,$2,$3,$4,$5,$6,'processing',$7) RETURNING *`,
+    [b.host_id, b.id, pm.id, rail, currency, amount, provider.liveMode]);
+
+  let result;
+  try {
+    if (rail === 'sinpe') {
+      result = await provider.payout({ method: { sinpe_phone: pm.sinpe_phone, cr_iban: pm.cr_iban }, amount, currency, reference: b.code });
+    } else {
+      // Plaid ACH payout — provider/ledger wiring pinned in go-live memo; sandbox records intent.
+      result = { providerRef: `ach_sbx_${payout.id}`, status: 'processing', raw: { rail: 'plaid_ach', sandbox: !provider.liveMode } };
+    }
+    await pool.query(`UPDATE payouts SET provider_ref=$1, status=$2, raw=$3 WHERE id=$4`,
+      [result.providerRef || null, result.status || 'processing', JSON.stringify(result.raw || {}), payout.id]);
+  } catch (e) {
+    await pool.query(`UPDATE payouts SET status='failed', raw=$1 WHERE id=$2`,
+      [JSON.stringify({ error: String(e.message) }), payout.id]);
+    throw e;
+  }
+  return { ...payout, ...result };
+}
+
+module.exports = { createPayoutForBooking };
diff --git a/lib/plaid.js b/lib/plaid.js
new file mode 100644
index 0000000..c29c92a
--- /dev/null
+++ b/lib/plaid.js
@@ -0,0 +1,48 @@
+'use strict';
+// Plaid client — bank-account verification + ACH for FOREIGN/US hosts only.
+// (Costa Rican banks are NOT in Plaid's network — Tico hosts use SINPE, see
+// lib/payouts.js.) Sandbox-safe: no PLAID creds => liveMode=false and Link
+// token / exchange return deterministic sandbox values.
+//
+// Env (gated): PLAID_CLIENT_ID, PLAID_SECRET, PLAID_ENV (sandbox|production)
+
+const crypto = require('crypto');
+const CLIENT_ID = process.env.PLAID_CLIENT_ID || '';
+const SECRET = process.env.PLAID_SECRET || '';
+const ENV = process.env.PLAID_ENV || 'sandbox';
+const LIVE = !!(CLIENT_ID && SECRET);
+const BASE = `https://${ENV}.plaid.com`;
+
+async function _post(path, body) {
+  const res = await fetch(BASE + path, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ client_id: CLIENT_ID, secret: SECRET, ...body }),
+  });
+  const j = await res.json();
+  if (!res.ok) throw new Error(`plaid ${path} HTTP ${res.status}: ${j.error_code || ''}`);
+  return j;
+}
+
+// Step 1 (app): create a Link token to open Plaid Link in the app.
+async function createLinkToken(userId) {
+  if (!LIVE) return { link_token: `link-sandbox-${crypto.randomBytes(6).toString('hex')}`, sandbox: true };
+  return _post('/link/token/create', {
+    user: { client_user_id: String(userId) },
+    client_name: 'Costa Rica Marketplace',
+    products: ['auth'], country_codes: ['US'], language: 'en',
+  });
+}
+
+// Step 2 (app -> server): exchange the public_token for a persistent access_token.
+async function exchangePublicToken(publicToken) {
+  if (!LIVE) return { access_token: `access-sandbox-${crypto.randomBytes(8).toString('hex')}`, item_id: `item-${crypto.randomBytes(6).toString('hex')}`, sandbox: true };
+  return _post('/item/public_token/exchange', { public_token: publicToken });
+}
+
+// Fetch the ACH numbers/account so we can store last4 + verified flag.
+async function getAuth(accessToken) {
+  if (!LIVE) return { accounts: [{ account_id: 'acc_sbx', mask: '4321' }], sandbox: true };
+  return _post('/auth/get', { access_token: accessToken });
+}
+
+module.exports = { get liveMode() { return LIVE; }, ENV, createLinkToken, exchangePublicToken, getAuth };
diff --git a/lib/whatsapp.js b/lib/whatsapp.js
new file mode 100644
index 0000000..9f21fe3
--- /dev/null
+++ b/lib/whatsapp.js
@@ -0,0 +1,156 @@
+'use strict';
+// WhatsApp — Meta Cloud API direct client + webhook helpers.
+// Full message surface: text, template, interactive (buttons/list), media
+// (image/document), location, reaction, and typing/read receipts. Persists
+// every in/out message to whatsapp_messages and tracks the 24h session window.
+//
+// SANDBOX-SAFE: with no WHATSAPP_TOKEN set, liveMode=false and sends are
+// simulated (logged + persisted, no Meta call) so booking notifications are
+// exercisable before the WABA number is provisioned.
+//
+// Env (all gated in the go-live memo):
+//   WHATSAPP_TOKEN            permanent system-user token
+//   WHATSAPP_PHONE_ID         phone number id (sender)
+//   WHATSAPP_VERIFY_TOKEN     webhook GET verification token
+//   WHATSAPP_APP_SECRET       for X-Hub-Signature-256 verification
+//   WHATSAPP_API_VERSION      default v21.0
+
+const crypto = require('crypto');
+const { pool } = require('./db');
+
+const TOKEN = process.env.WHATSAPP_TOKEN || '';
+const PHONE_ID = process.env.WHATSAPP_PHONE_ID || '';
+const VERIFY_TOKEN = process.env.WHATSAPP_VERIFY_TOKEN || 'cr-verify-sandbox';
+const APP_SECRET = process.env.WHATSAPP_APP_SECRET || '';
+const VER = process.env.WHATSAPP_API_VERSION || 'v21.0';
+const LIVE = !!(TOKEN && PHONE_ID);
+
+const GRAPH = (path) => `https://graph.facebook.com/${VER}/${path}`;
+
+async function _send(payload) {
+  if (!LIVE) {
+    return { messages: [{ id: `wamid.SBX_${crypto.randomBytes(8).toString('hex')}` }], sandbox: true };
+  }
+  const res = await fetch(GRAPH(`${PHONE_ID}/messages`), {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
+    body: JSON.stringify({ messaging_product: 'whatsapp', ...payload }),
+  });
+  const j = await res.json();
+  if (!res.ok) throw new Error(`wa send HTTP ${res.status}: ${JSON.stringify(j).slice(0, 300)}`);
+  return j;
+}
+
+// --- persistence -----------------------------------------------------------
+async function contactByWaId(waId, profileName) {
+  const { rows } = await pool.query(
+    `INSERT INTO whatsapp_contacts (wa_id, profile_name) VALUES ($1,$2)
+     ON CONFLICT (wa_id) DO UPDATE SET profile_name=COALESCE(EXCLUDED.profile_name, whatsapp_contacts.profile_name)
+     RETURNING *`, [waId, profileName || null]);
+  return rows[0];
+}
+async function logMessage({ waMessageId, contactId, bookingId, direction, msgType, body, payload, status }) {
+  await pool.query(
+    `INSERT INTO whatsapp_messages (wa_message_id, contact_id, booking_id, direction, msg_type, body, payload, status)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (wa_message_id) DO NOTHING`,
+    [waMessageId || null, contactId || null, bookingId || null, direction, msgType, body || null,
+     payload ? JSON.stringify(payload) : null, status || null]);
+}
+
+// --- outbound message types ------------------------------------------------
+async function sendText(to, text, { bookingId, previewUrl = false } = {}) {
+  const r = await _send({ to, type: 'text', text: { body: text, preview_url: previewUrl } });
+  const c = await contactByWaId(to);
+  await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'text', body: text, payload: r });
+  return r;
+}
+// Template = the ONLY thing allowed outside the 24h session window (must be pre-approved in Meta).
+async function sendTemplate(to, name, langCode = 'es', components = [], { bookingId } = {}) {
+  const r = await _send({ to, type: 'template', template: { name, language: { code: langCode }, components } });
+  const c = await contactByWaId(to);
+  await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'template', body: name, payload: r });
+  return r;
+}
+async function sendButtons(to, bodyText, buttons, { bookingId, header, footer } = {}) {
+  const action = { buttons: buttons.slice(0, 3).map((b, i) => ({ type: 'reply', reply: { id: b.id || `btn_${i}`, title: b.title.slice(0, 20) } })) };
+  const interactive = { type: 'button', body: { text: bodyText }, action };
+  if (header) interactive.header = { type: 'text', text: header };
+  if (footer) interactive.footer = { text: footer };
+  const r = await _send({ to, type: 'interactive', interactive });
+  const c = await contactByWaId(to);
+  await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'interactive', body: bodyText, payload: r });
+  return r;
+}
+async function sendList(to, bodyText, buttonLabel, sections, { bookingId, header } = {}) {
+  const interactive = { type: 'list', body: { text: bodyText }, action: { button: buttonLabel, sections } };
+  if (header) interactive.header = { type: 'text', text: header };
+  const r = await _send({ to, type: 'interactive', interactive });
+  const c = await contactByWaId(to);
+  await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'interactive', body: bodyText, payload: r });
+  return r;
+}
+async function sendImage(to, link, caption, { bookingId } = {}) {
+  const r = await _send({ to, type: 'image', image: { link, caption } });
+  const c = await contactByWaId(to);
+  await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'image', body: caption, payload: r });
+  return r;
+}
+async function sendDocument(to, link, filename, caption, { bookingId } = {}) {
+  const r = await _send({ to, type: 'document', document: { link, filename, caption } });
+  const c = await contactByWaId(to);
+  await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'document', body: filename, payload: r });
+  return r;
+}
+async function sendLocation(to, lat, lng, name, address, { bookingId } = {}) {
+  const r = await _send({ to, type: 'location', location: { latitude: lat, longitude: lng, name, address } });
+  const c = await contactByWaId(to);
+  await logMessage({ waMessageId: r.messages?.[0]?.id, contactId: c.id, bookingId, direction: 'out', msgType: 'location', body: name, payload: r });
+  return r;
+}
+async function markRead(waMessageId) {
+  if (!LIVE) return { sandbox: true };
+  return _send({ status: 'read', message_id: waMessageId });
+}
+
+// --- webhook ---------------------------------------------------------------
+// GET verification handshake
+function verifyChallenge(query) {
+  if (query['hub.mode'] === 'subscribe' && query['hub.verify_token'] === VERIFY_TOKEN) {
+    return { ok: true, challenge: query['hub.challenge'] };
+  }
+  return { ok: false };
+}
+// POST signature check (X-Hub-Signature-256: sha256=...)
+function verifySignature(headers, rawBody) {
+  if (!APP_SECRET) return !LIVE; // sandbox accepts
+  const sig = (headers['x-hub-signature-256'] || '').replace('sha256=', '');
+  const expect = crypto.createHmac('sha256', APP_SECRET).update(rawBody).digest('hex');
+  try { return sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect)); } catch { return false; }
+}
+// Parse an inbound webhook body into normalized events + persist inbound msgs.
+async function handleInbound(body) {
+  const out = [];
+  for (const entry of body.entry || []) {
+    for (const ch of entry.changes || []) {
+      const v = ch.value || {};
+      const profileName = v.contacts?.[0]?.profile?.name;
+      for (const m of v.messages || []) {
+        const c = await contactByWaId(m.from, profileName);
+        await pool.query(`UPDATE whatsapp_contacts SET last_inbound_at=NOW() WHERE id=$1`, [c.id]);
+        const body_ = m.text?.body || m.interactive?.button_reply?.title || m.interactive?.list_reply?.title || m.button?.text || null;
+        await logMessage({ waMessageId: m.id, contactId: c.id, direction: 'in', msgType: m.type, body: body_, payload: m, status: 'received' });
+        out.push({ contact: c, message: m, text: body_ });
+      }
+      for (const s of v.statuses || []) {
+        await pool.query(`UPDATE whatsapp_messages SET status=$1 WHERE wa_message_id=$2`, [s.status, s.id]);
+      }
+    }
+  }
+  return out;
+}
+
+module.exports = {
+  get liveMode() { return LIVE; }, VERIFY_TOKEN,
+  sendText, sendTemplate, sendButtons, sendList, sendImage, sendDocument, sendLocation, markRead,
+  verifyChallenge, verifySignature, handleInbound, contactByWaId,
+};
diff --git a/routes/app.js b/routes/app.js
new file mode 100644
index 0000000..eb9d472
--- /dev/null
+++ b/routes/app.js
@@ -0,0 +1,297 @@
+'use strict';
+// Mobile-app API (JWT-authed). Mounted at /api/app BEFORE the site basic-auth
+// gate so public app users and the Expo client can reach it.
+const express = require('express');
+const crypto = require('crypto');
+const { pool } = require('../lib/db');
+const { signToken, hashPassword, verifyPassword, authRequired, optionalAuth } = require('../lib/auth');
+const { computeSplit, nights } = require('../lib/money');
+const { getProvider } = require('../lib/payments');
+const plaid = require('../lib/plaid');
+const wa = require('../lib/whatsapp');
+
+const router = express.Router();
+const bookingCode = () => 'CR-' + crypto.randomBytes(3).toString('hex').toUpperCase();
+const ok = (res, data) => res.json({ ok: true, ...data });
+const bad = (res, code, msg) => res.status(code).json({ ok: false, error: msg });
+
+// ---------------------------------------------------------------- auth
+router.post('/auth/register', async (req, res) => {
+  const { email, password, full_name, phone } = req.body || {};
+  if (!email || !password) return bad(res, 400, 'email and password required');
+  try {
+    const { rows } = await pool.query(
+      `INSERT INTO app_users (email, password_hash, full_name, phone_e164)
+       VALUES ($1,$2,$3,$4) RETURNING id, email, full_name, role, is_host`,
+      [String(email).toLowerCase(), hashPassword(password), full_name || null, phone || null]);
+    const u = rows[0];
+    ok(res, { token: signToken({ sub: u.id, role: u.role }), user: u });
+  } catch (e) {
+    if (e.code === '23505') return bad(res, 409, 'email or phone already registered');
+    bad(res, 500, e.message);
+  }
+});
+
+router.post('/auth/login', async (req, res) => {
+  const { email, password } = req.body || {};
+  const { rows } = await pool.query(`SELECT * FROM app_users WHERE email=$1`, [String(email || '').toLowerCase()]);
+  const u = rows[0];
+  if (!u || !verifyPassword(password || '', u.password_hash)) return bad(res, 401, 'invalid credentials');
+  ok(res, { token: signToken({ sub: u.id, role: u.role, host_id: null }),
+    user: { id: u.id, email: u.email, full_name: u.full_name, role: u.role, is_host: u.is_host } });
+});
+
+router.get('/me', authRequired, async (req, res) => {
+  const { rows } = await pool.query(`SELECT id,email,full_name,phone_e164,role,is_host,wa_opt_in FROM app_users WHERE id=$1`, [req.user.sub]);
+  if (!rows[0]) return bad(res, 404, 'not found');
+  const { rows: hostRows } = await pool.query(`SELECT id, kyc_status FROM hosts WHERE user_id=$1`, [req.user.sub]);
+  ok(res, { user: rows[0], host: hostRows[0] || null });
+});
+
+// ---------------------------------------------------------------- listings (bookable inventory)
+router.get('/listings', optionalAuth, async (req, res) => {
+  const { region, vertical, q, limit = 40, offset = 0 } = req.query;
+  const where = ['pb.is_active = TRUE'];
+  const args = [];
+  if (region)   { args.push(region);   where.push(`r.slug = $${args.length}`); }
+  if (vertical) { args.push(vertical); where.push(`p.vertical = $${args.length}`); }
+  if (q)        { args.push(`%${q}%`); where.push(`p.name ILIKE $${args.length}`); }
+  args.push(Math.min(+limit, 100)); const lim = `$${args.length}`;
+  args.push(+offset); const off = `$${args.length}`;
+  const { rows } = await pool.query(
+    `SELECT p.slug, p.name, p.vertical, p.category, p.description, p.image_url, p.rating,
+            r.name AS region, r.slug AS region_slug, p.lat, p.lng,
+            pb.booking_type, pb.currency, pb.base_price, pb.cleaning_fee, pb.max_guests, pb.instant_book
+       FROM place_booking pb
+       JOIN places p ON p.id = pb.place_id
+       LEFT JOIN regions r ON r.id = p.region_id
+      WHERE ${where.join(' AND ')}
+      ORDER BY p.rating DESC NULLS LAST, p.name
+      LIMIT ${lim} OFFSET ${off}`, args);
+  ok(res, { count: rows.length, listings: rows });
+});
+
+router.get('/listings/:slug', optionalAuth, async (req, res) => {
+  const { rows } = await pool.query(
+    `SELECT p.*, pb.booking_type, pb.currency, pb.base_price, pb.cleaning_fee, pb.max_guests,
+            pb.min_nights, pb.instant_book, pb.cancellation, r.name AS region
+       FROM places p
+       JOIN place_booking pb ON pb.place_id = p.id
+       LEFT JOIN regions r ON r.id = p.region_id
+      WHERE p.slug=$1 AND pb.is_active`, [req.params.slug]);
+  if (!rows[0]) return bad(res, 404, 'listing not found or not bookable');
+  ok(res, { listing: rows[0] });
+});
+
+router.get('/listings/:slug/availability', async (req, res) => {
+  const { from, to } = req.query;
+  const { rows: [pl] } = await pool.query(`SELECT id FROM places WHERE slug=$1`, [req.params.slug]);
+  if (!pl) return bad(res, 404, 'listing not found');
+  const { rows } = await pool.query(
+    `SELECT day, slot_start, slot_end, capacity, price_override, is_blocked
+       FROM availability WHERE place_id=$1
+        AND ($2::date IS NULL OR day >= $2) AND ($3::date IS NULL OR day <= $3)
+      ORDER BY day, slot_start`, [pl.id, from || null, to || null]);
+  ok(res, { availability: rows });
+});
+
+// ---------------------------------------------------------------- bookings
+router.post('/bookings', authRequired, async (req, res) => {
+  const { place_slug, check_in, check_out, slot_start, slot_end, guests = 1 } = req.body || {};
+  const { rows: [pb] } = await pool.query(
+    `SELECT pb.*, p.id AS place_id, p.name FROM place_booking pb
+       JOIN places p ON p.id = pb.place_id WHERE p.slug=$1 AND pb.is_active`, [place_slug]);
+  if (!pb) return bad(res, 404, 'listing not bookable');
+  if (guests > pb.max_guests) return bad(res, 400, `max ${pb.max_guests} guests`);
+
+  let subtotal;
+  if (pb.booking_type === 'nightly') {
+    const n = nights(check_in, check_out);
+    if (n < (pb.min_nights || 1)) return bad(res, 400, `min ${pb.min_nights} nights`);
+    subtotal = pb.base_price * n;
+  } else {
+    subtotal = pb.base_price * (guests || 1); // slot/ticket priced per guest
+  }
+  const split = computeSplit({ subtotal, cleaningFee: pb.cleaning_fee, currency: pb.currency, platformFeeBps: pb.platform_fee_bps });
+  const code = bookingCode();
+  const { rows: [bk] } = await pool.query(
+    `INSERT INTO bookings (code, place_id, host_id, traveler_id, check_in, check_out, slot_start, slot_end,
+        guests, currency, subtotal, fees, platform_fee, total, host_payout, status)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'pending') RETURNING *`,
+    [code, pb.place_id, pb.host_id, req.user.sub, check_in || null, check_out || null, slot_start || null, slot_end || null,
+     guests, pb.currency, split.subtotal, split.fees, split.platformFee, split.total, split.hostPayout]);
+  ok(res, { booking: bk, split });
+});
+
+router.get('/bookings', authRequired, async (req, res) => {
+  const { rows } = await pool.query(
+    `SELECT b.*, p.name AS place_name, p.slug AS place_slug
+       FROM bookings b JOIN places p ON p.id=b.place_id
+      WHERE b.traveler_id=$1 ORDER BY b.created_at DESC`, [req.user.sub]);
+  ok(res, { bookings: rows });
+});
+
+router.get('/bookings/:code', authRequired, async (req, res) => {
+  const { rows } = await pool.query(
+    `SELECT b.*, p.name AS place_name, p.slug AS place_slug FROM bookings b
+       JOIN places p ON p.id=b.place_id WHERE b.code=$1 AND b.traveler_id=$2`, [req.params.code, req.user.sub]);
+  if (!rows[0]) return bad(res, 404, 'not found');
+  const { rows: pays } = await pool.query(`SELECT id,status,method,amount,currency,provider FROM payments WHERE booking_id=$1`, [rows[0].id]);
+  ok(res, { booking: rows[0], payments: pays });
+});
+
+router.post('/bookings/:code/cancel', authRequired, async (req, res) => {
+  const { rows } = await pool.query(
+    `UPDATE bookings SET status='cancelled', updated_at=NOW()
+      WHERE code=$1 AND traveler_id=$2 AND status IN ('pending','confirmed') RETURNING *`,
+    [req.params.code, req.user.sub]);
+  if (!rows[0]) return bad(res, 409, 'cannot cancel');
+  ok(res, { booking: rows[0] });
+});
+
+// ---------------------------------------------------------------- payments
+router.post('/bookings/:code/pay', authRequired, async (req, res) => {
+  const { method = 'card' } = req.body || {};
+  const { rows: [bk] } = await pool.query(
+    `SELECT * FROM bookings WHERE code=$1 AND traveler_id=$2`, [req.params.code, req.user.sub]);
+  if (!bk) return bad(res, 404, 'booking not found');
+  if (bk.status !== 'pending') return bad(res, 409, `booking is ${bk.status}`);
+
+  const { rows: [u] } = await pool.query(`SELECT email, full_name, phone_e164 FROM app_users WHERE id=$1`, [req.user.sub]);
+  const provider = getProvider();
+  const returnUrl = (process.env.APP_RETURN_URL || 'crmarketplace://pay/return');
+  let charge;
+  try {
+    charge = await provider.createCharge({
+      amount: bk.total, currency: bk.currency, method,
+      booking: { code: bk.code, id: bk.id },
+      customer: { email: u.email, name: u.full_name, phone: u.phone_e164 },
+      returnUrl: `${returnUrl}?code=${bk.code}`,
+    });
+  } catch (e) { return bad(res, 502, `processor error: ${e.message}`); }
+
+  const { rows: [pay] } = await pool.query(
+    `INSERT INTO payments (booking_id, provider, provider_ref, method, currency, amount, status, live_mode, raw)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
+    [bk.id, provider.name, charge.providerRef, method, bk.currency, bk.total,
+     charge.status === 'succeeded' ? 'succeeded' : 'processing', provider.liveMode, JSON.stringify(charge.raw || {})]);
+
+  // If sandbox/instant-succeeded, confirm the booking immediately.
+  if (charge.status === 'succeeded') await confirmBooking(bk.id);
+  ok(res, { payment_id: pay.id, status: charge.status, client_action: charge.clientAction, live_mode: provider.liveMode });
+});
+
+router.get('/payments/:id', authRequired, async (req, res) => {
+  const { rows: [pay] } = await pool.query(
+    `SELECT pm.* FROM payments pm JOIN bookings b ON b.id=pm.booking_id
+      WHERE pm.id=$1 AND b.traveler_id=$2`, [req.params.id, req.user.sub]);
+  if (!pay) return bad(res, 404, 'not found');
+  // Poll processor for latest status (covers sandbox redirect-return).
+  if (pay.status === 'processing') {
+    try {
+      const provider = getProvider(pay.provider);
+      const latest = await provider.getCharge(pay.provider_ref);
+      if (latest.status !== 'processing') {
+        await pool.query(`UPDATE payments SET status=$1, updated_at=NOW() WHERE id=$2`, [latest.status, pay.id]);
+        if (latest.status === 'succeeded') await confirmBooking(pay.booking_id);
+        pay.status = latest.status;
+      }
+    } catch { /* leave processing */ }
+  }
+  ok(res, { payment: { id: pay.id, status: pay.status, amount: pay.amount, currency: pay.currency } });
+});
+
+// Shared: mark booking confirmed + notify via WhatsApp (best-effort).
+async function confirmBooking(bookingId) {
+  const { rows: [b] } = await pool.query(
+    `UPDATE bookings SET status='confirmed', updated_at=NOW() WHERE id=$1 AND status='pending' RETURNING *`, [bookingId]);
+  if (!b) return;
+  try {
+    const { rows: [u] } = await pool.query(`SELECT phone_e164, wa_opt_in FROM app_users WHERE id=$1`, [b.traveler_id]);
+    const { rows: [p] } = await pool.query(`SELECT name FROM places WHERE id=$1`, [b.place_id]);
+    if (u?.phone_e164) {
+      const to = u.phone_e164.replace(/^\+/, '');
+      await wa.sendText(to,
+        `✅ ¡Reserva confirmada! ${p?.name} · code ${b.code} · ${b.currency} ${(b.total/100).toFixed(2)}. Gracias.`,
+        { bookingId: b.id });
+    }
+  } catch (e) { console.warn('[confirm] wa notify failed', e.message); }
+}
+
+// ---------------------------------------------------------------- host onboarding
+router.post('/host/apply', authRequired, async (req, res) => {
+  const { legal_name, cedula, country = 'CR' } = req.body || {};
+  const { rows: [h] } = await pool.query(
+    `INSERT INTO hosts (user_id, legal_name, cedula, country) VALUES ($1,$2,$3,$4)
+     ON CONFLICT (user_id) DO UPDATE SET legal_name=EXCLUDED.legal_name, cedula=EXCLUDED.cedula, country=EXCLUDED.country
+     RETURNING *`, [req.user.sub, legal_name || null, cedula || null, country]);
+  await pool.query(`UPDATE app_users SET is_host=TRUE WHERE id=$1`, [req.user.sub]);
+  ok(res, { host: h });
+});
+
+async function requireHost(req, res) {
+  const { rows } = await pool.query(`SELECT * FROM hosts WHERE user_id=$1`, [req.user.sub]);
+  if (!rows[0]) { bad(res, 403, 'not a host — call /host/apply first'); return null; }
+  return rows[0];
+}
+
+router.post('/host/claim', authRequired, async (req, res) => {
+  const host = await requireHost(req, res); if (!host) return;
+  const { rows: [pl] } = await pool.query(`SELECT id FROM places WHERE slug=$1`, [req.body?.place_slug]);
+  if (!pl) return bad(res, 404, 'place not found');
+  await pool.query(
+    `INSERT INTO place_hosts (place_id, host_id, claim_status) VALUES ($1,$2,'pending')
+     ON CONFLICT (place_id, host_id) DO NOTHING`, [pl.id, host.id]);
+  ok(res, { claimed: req.body.place_slug, status: 'pending' });
+});
+
+router.post('/host/listings', authRequired, async (req, res) => {
+  const host = await requireHost(req, res); if (!host) return;
+  const b = req.body || {};
+  const { rows: [pl] } = await pool.query(`SELECT id FROM places WHERE slug=$1`, [b.place_slug]);
+  if (!pl) return bad(res, 404, 'place not found');
+  const { rows: [pb] } = await pool.query(
+    `INSERT INTO place_booking (place_id, host_id, booking_type, currency, base_price, cleaning_fee, max_guests, min_nights, instant_book, is_active)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE)
+     ON CONFLICT (place_id) DO UPDATE SET host_id=EXCLUDED.host_id, booking_type=EXCLUDED.booking_type, currency=EXCLUDED.currency,
+        base_price=EXCLUDED.base_price, cleaning_fee=EXCLUDED.cleaning_fee, max_guests=EXCLUDED.max_guests,
+        min_nights=EXCLUDED.min_nights, instant_book=EXCLUDED.instant_book, is_active=TRUE, updated_at=NOW()
+     RETURNING *`,
+    [pl.id, host.id, b.booking_type || 'nightly', b.currency || 'USD', b.base_price | 0,
+     b.cleaning_fee | 0, b.max_guests || 1, b.min_nights || 1, b.instant_book !== false]);
+  ok(res, { listing: pb });
+});
+
+router.post('/host/payout-methods', authRequired, async (req, res) => {
+  const host = await requireHost(req, res); if (!host) return;
+  const b = req.body || {};
+  if (!['sinpe_movil', 'cr_iban', 'plaid_ach'].includes(b.kind)) return bad(res, 400, 'bad kind');
+  const { rows: [pm] } = await pool.query(
+    `INSERT INTO payout_methods (host_id, kind, label, sinpe_phone, cr_iban, bank_name, currency, is_default)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
+    [host.id, b.kind, b.label || null, b.sinpe_phone || null, b.cr_iban || null, b.bank_name || null,
+     b.currency || (b.kind === 'plaid_ach' ? 'USD' : 'CRC'), b.is_default !== false]);
+  if (pm.is_default) await pool.query(`UPDATE hosts SET default_payout_method_id=$1 WHERE id=$2`, [pm.id, host.id]);
+  ok(res, { payout_method: { id: pm.id, kind: pm.kind, currency: pm.currency, is_default: pm.is_default } });
+});
+
+// Plaid (foreign hosts): Link token + exchange
+router.post('/host/plaid/link-token', authRequired, async (req, res) => {
+  const host = await requireHost(req, res); if (!host) return;
+  const t = await plaid.createLinkToken(req.user.sub);
+  ok(res, { link_token: t.link_token, env: plaid.ENV, live: plaid.liveMode });
+});
+router.post('/host/plaid/exchange', authRequired, async (req, res) => {
+  const host = await requireHost(req, res); if (!host) return;
+  const ex = await plaid.exchangePublicToken(req.body?.public_token);
+  const auth = await plaid.getAuth(ex.access_token).catch(() => ({ accounts: [] }));
+  const acct = auth.accounts?.[0] || {};
+  const { rows: [pm] } = await pool.query(
+    `INSERT INTO payout_methods (host_id, kind, label, plaid_item_id, plaid_access_token, plaid_account_id, account_last4, currency, verified, is_default)
+     VALUES ($1,'plaid_ach','Bank (Plaid)',$2,$3,$4,$5,'USD',TRUE,TRUE) RETURNING id`,
+    [host.id, ex.item_id, ex.access_token, acct.account_id || null, acct.mask || null]);
+  await pool.query(`UPDATE hosts SET default_payout_method_id=$1 WHERE id=$2`, [pm.id, host.id]);
+  ok(res, { payout_method_id: pm.id, last4: acct.mask, sandbox: !!ex.sandbox });
+});
+
+module.exports = { router, confirmBooking };
diff --git a/routes/webhooks.js b/routes/webhooks.js
new file mode 100644
index 0000000..aac8662
--- /dev/null
+++ b/routes/webhooks.js
@@ -0,0 +1,79 @@
+'use strict';
+// Public webhooks — payment processors + WhatsApp. NO basic-auth (providers
+// POST unauthenticated; we verify by signature). Raw body needed for HMAC.
+const express = require('express');
+const { pool } = require('../lib/db');
+const { getProvider } = require('../lib/payments');
+const wa = require('../lib/whatsapp');
+const { confirmBooking } = require('./app');
+
+const router = express.Router();
+const raw = express.raw({ type: '*/*', limit: '2mb' });
+
+// Idempotency: record (source, external_id); return false if already seen.
+async function firstTime(source, externalId, eventType, payload) {
+  if (!externalId) return true; // no id -> best effort, still process
+  try {
+    const { rowCount } = await pool.query(
+      `INSERT INTO webhook_events (source, external_id, event_type, payload)
+       VALUES ($1,$2,$3,$4) ON CONFLICT (source, external_id) DO NOTHING`,
+      [source, String(externalId), eventType || null, payload ? JSON.stringify(payload) : null]);
+    return rowCount === 1;
+  } catch { return true; }
+}
+
+// ---- payment webhook (Tilopay / ONVO share the shape via the adapter) ----
+async function paymentWebhook(providerName, req, res) {
+  const provider = getProvider(providerName);
+  const { ok, event } = provider.verifyWebhook(req.headers, req.body); // req.body is a Buffer (raw)
+  if (!ok) return res.status(401).send('bad signature');
+  const evId = event?.id || event?.paymentId || event?.event_id;
+  const evType = event?.type || event?.status;
+  if (!(await firstTime(providerName, evId, evType, event))) return res.status(200).send('dup');
+
+  // Resolve the charge id the adapter reported to us.
+  const ref = event?.paymentId || event?.id || event?.data?.id;
+  if (ref) {
+    const latest = await provider.getCharge(ref).catch(() => null);
+    const status = latest?.status || (/(succe|approved|paid)/i.test(String(evType)) ? 'succeeded' : 'processing');
+    const { rows } = await pool.query(
+      `UPDATE payments SET status=$1, raw=$2, updated_at=NOW()
+        WHERE provider=$3 AND provider_ref=$4 RETURNING id, booking_id`,
+      [status, JSON.stringify(event || {}), providerName, ref]);
+    if (rows[0] && status === 'succeeded') await confirmBooking(rows[0].booking_id);
+    if (rows[0] && status === 'refunded') await pool.query(`UPDATE bookings SET status='refunded' WHERE id=$1`, [rows[0].booking_id]);
+  }
+  res.status(200).send('ok');
+}
+
+router.post('/tilopay', raw, (req, res) => paymentWebhook('tilopay', req, res).catch(e => res.status(500).send(e.message)));
+router.post('/onvo',    raw, (req, res) => paymentWebhook('onvo', req, res).catch(e => res.status(500).send(e.message)));
+
+// ---- WhatsApp webhook ----
+router.get('/whatsapp', (req, res) => {
+  const v = wa.verifyChallenge(req.query);
+  if (v.ok) return res.status(200).send(v.challenge);
+  res.sendStatus(403);
+});
+router.post('/whatsapp', raw, async (req, res) => {
+  if (!wa.verifySignature(req.headers, req.body)) return res.sendStatus(401);
+  let body; try { body = JSON.parse(req.body.toString()); } catch { return res.sendStatus(400); }
+  const evId = body.entry?.[0]?.id + ':' + (body.entry?.[0]?.changes?.[0]?.value?.messages?.[0]?.id || Date.now());
+  if (!(await firstTime('whatsapp', evId, 'inbound', null))) return res.sendStatus(200);
+  try {
+    const events = await wa.handleInbound(body);
+    // Auto-reply hook: a simple keyword router (extend as needed).
+    for (const ev of events) {
+      const t = (ev.text || '').toLowerCase();
+      if (/^(hola|hi|hello|menu|ayuda|help)/.test(t)) {
+        await wa.sendButtons(ev.contact.wa_id,
+          '¡Hola! ¿En qué te ayudamos? / How can we help?',
+          [{ id: 'browse', title: 'Ver listados' }, { id: 'mybookings', title: 'Mis reservas' }, { id: 'support', title: 'Soporte' }],
+          { header: 'Costa Rica' });
+      }
+    }
+  } catch (e) { console.warn('[wa webhook]', e.message); }
+  res.sendStatus(200);
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index c0f2409..d4a6153 100644
--- a/server.js
+++ b/server.js
@@ -14,6 +14,11 @@ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
 
 const app = express();
 app.set('trust proxy', true);
+
+// Payment/WhatsApp webhooks need the RAW body for HMAC verification, so mount
+// them BEFORE the global JSON parser (and before the site basic-auth gate).
+app.use('/webhooks', require('./routes/webhooks'));
+
 app.use(express.json({ limit: '1mb' }));
 app.use(express.urlencoded({ extended: true }));
 
@@ -25,6 +30,16 @@ app.use((req, res, next) => {
   next();
 });
 
+// Mobile-app API (JWT-authed) — mounted BEFORE the site basic-auth gate so
+// public app users can reach it.
+app.use('/api/app', require('./routes/app').router);
+app.get('/api/app/health', (_req, res) => {
+  const { getProvider } = require('./lib/payments');
+  res.json({ ok: true, layer: 'marketplace',
+    payment_provider: getProvider().name, payment_live: getProvider().liveMode,
+    whatsapp_live: require('./lib/whatsapp').liveMode, plaid_live: require('./lib/plaid').liveMode });
+});
+
 // Whole-site Basic Auth gate (in-development; remove when ready for public)
 const BA_USER = process.env.BASIC_AUTH_USER;
 const BA_PASS = process.env.BASIC_AUTH_PASS;

← 098634c costa-rica: add GOOGLE_PLACES_API_KEY to .env.example; launc  ·  back to Costa Rica  ·  costa-rica: document marketplace env surface in .env.example d34e822 →