Skip to main content

Hostaway Connector Specification

Summary

This document defines the complete technical specification for the Hostaway connector, which enables bi-directional synchronization between TVL and Hostaway PMS (Property Management System). Hostaway acts as a meta-channel that connects to multiple OTAs (Airbnb, VRBO, Booking.com, etc.), allowing TVL to manage properties across all channels through a single integration.

The connector handles:

  • Inbound sync: Bookings, cancellations, and modifications from OTAs via webhooks
  • Outbound sync: Property listings, availability, and pricing updates to OTAs via REST API
  • Field mapping: Transform data between TVL and Hostaway schemas
  • Error handling: Retry logic, circuit breakers, and dead letter queues
  • Rate limiting: Respect Hostaway API quotas and implement backoff strategies

Table of Contents


Architecture Overview

Component Diagram

Data Flow

Inbound Flow (OTA → TVL)

  1. Guest books on Airbnb
  2. Airbnb notifies Hostaway
  3. Hostaway sends webhook to TVL
  4. Webhook Handler validates signature
  5. Connector Service parses payload
  6. Field Mapper transforms data
  7. Booking Service creates reservation
  8. Availability Service creates block

Outbound Flow (TVL → OTA)

  1. Manager updates availability in TVL
  2. Availability Service emits event
  3. Connector Service receives event
  4. Sync job queued for each listing
  5. API Client fetches listing data
  6. Field Mapper transforms data
  7. API Client calls Hostaway API
  8. Hostaway syncs to OTAs

Authentication & Authorization

Hostaway API Authentication

Hostaway uses API Key authentication with the following credentials:

interface HostawayCredentials {
apiKey: string; // Primary API key
accountId: string; // Hostaway account identifier
apiEndpoint: string; // Base URL: https://api.hostaway.com/v1
webhookSecret: string; // HMAC secret for webhook validation
}

Credential Storage

Security Requirements:

  • Credentials stored encrypted in Google Secret Manager
  • Never logged or exposed in API responses
  • Rotated every 90 days (manual for MVP)
  • Access restricted to Owner role only

Storage Schema:

// In channels table
{
id: "ch_hostaway_123",
channel_type: "hostaway",
credentials: {
// Encrypted JSONB field
encrypted_api_key: "...",
encrypted_account_id: "...",
encrypted_webhook_secret: "..."
}
}

API Request Authentication

Header Format:

const headers = {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'X-Hostaway-Account-Id': accountId
};

Webhook Signature Validation

HMAC Validation:

interface WebhookRequest {
headers: {
'x-hostaway-signature': string;
'x-hostaway-timestamp': string;
};
body: object;
}

function validateWebhookSignature(
payload: string,
signature: string,
timestamp: string,
secret: string
): boolean {
// Prevent replay attacks (reject if timestamp > 5 minutes old)
const currentTime = Date.now();
const webhookTime = parseInt(timestamp, 10) * 1000;
if (currentTime - webhookTime > 300000) {
throw new Error('Webhook timestamp too old');
}

// Compute HMAC-SHA256
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');

// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}

Usage:

// In webhook handler
app.post('/webhooks/hostaway', async (req, res) => {
const signature = req.headers['x-hostaway-signature'];
const timestamp = req.headers['x-hostaway-timestamp'];
const payload = JSON.stringify(req.body);

const channel = await getHostawayChannel(req.body.accountId);
const secret = await decryptSecret(channel.credentials.encrypted_webhook_secret);

try {
const isValid = validateWebhookSignature(payload, signature, timestamp, secret);
if (!isValid) {
logger.warn('Invalid webhook signature', { accountId: req.body.accountId });
return res.status(401).json({ error: 'Invalid signature' });
}

// Process webhook...
await processWebhook(req.body);
res.status(200).json({ received: true });

} catch (error) {
logger.error('Webhook validation failed', { error });
res.status(400).json({ error: 'Validation failed' });
}
});

Webhook Handling

Webhook Events

Hostaway sends webhooks for the following events:

Event TypeDescriptionPriorityProcessing Time
reservation.createdNew booking confirmedCritical< 5s
reservation.updatedBooking modified (dates, guests)High< 10s
reservation.cancelledBooking cancelledCritical< 5s
listing.updatedListing details changedMedium< 30s
calendar.updatedAvailability changedHigh< 10s

Webhook Payload Structure

reservation.created

{
"event": "reservation.created",
"timestamp": "2025-10-24T10:00:00Z",
"accountId": "hostaway_account_123",
"data": {
"id": "hostaway_res_456",
"listingId": "hostaway_lst_789",
"channelId": "airbnb",
"channelName": "Airbnb",
"confirmationCode": "ABC123DEF",
"status": "confirmed",
"checkIn": "2025-11-15",
"checkOut": "2025-11-20",
"nights": 5,
"guestName": "John Doe",
"guestEmail": "john@example.com",
"guestPhone": "+1234567890",
"numberOfGuests": 4,
"adults": 3,
"children": 1,
"totalPrice": 1500.00,
"currency": "USD",
"isPaid": false,
"isManuallyChecked": false,
"createdAt": "2025-10-24T09:55:00Z"
}
}

Webhook Handler Implementation

interface WebhookContext {
webhookId: string;
receivedAt: Date;
processedAt?: Date;
status: 'received' | 'processing' | 'processed' | 'failed';
error?: string;
}

class HostawayWebhookHandler {
private readonly logger: Logger;
private readonly webhookLogRepo: WebhookLogRepository;
private readonly connectorService: ConnectorService;

async handleWebhook(req: Request, res: Response): Promise<void> {
const webhookId = generateUUID();
const context: WebhookContext = {
webhookId,
receivedAt: new Date(),
status: 'received'
};

try {
// Step 1: Validate signature
await this.validateSignature(req);

// Step 2: Log webhook immediately
await this.logWebhook(webhookId, req.body, 'received');

// Step 3: Return 200 OK quickly (acknowledge receipt)
res.status(200).json({
received: true,
webhookId
});

// Step 4: Process asynchronously (do not block response)
setImmediate(async () => {
try {
context.status = 'processing';
await this.processWebhook(req.body, context);

context.status = 'processed';
context.processedAt = new Date();
await this.updateWebhookLog(webhookId, context);

} catch (error) {
context.status = 'failed';
context.error = error.message;
await this.updateWebhookLog(webhookId, context);

this.logger.error('Webhook processing failed', {
webhookId,
event: req.body.event,
error
});
}
});

} catch (error) {
// Signature validation or logging failed
this.logger.error('Webhook handling failed', { webhookId, error });
res.status(400).json({ error: 'Invalid webhook' });
}
}

private async processWebhook(
payload: any,
context: WebhookContext
): Promise<void> {
const { event, data } = payload;

// Route to appropriate handler
switch (event) {
case 'reservation.created':
await this.handleReservationCreated(data, context);
break;

case 'reservation.cancelled':
await this.handleReservationCancelled(data, context);
break;

case 'reservation.updated':
await this.handleReservationUpdated(data, context);
break;

case 'listing.updated':
await this.handleListingUpdated(data, context);
break;

case 'calendar.updated':
await this.handleCalendarUpdated(data, context);
break;

default:
this.logger.warn('Unknown webhook event', { event });
}
}

private async handleReservationCreated(
data: any,
context: WebhookContext
): Promise<void> {
// Step 1: Map Hostaway listing ID to TVL listing/unit
const listing = await this.connectorService.findListingByExternalId(
data.listingId,
'hostaway'
);

if (!listing) {
throw new Error(`Listing not found for Hostaway ID: ${data.listingId}`);
}

// Step 2: Check for duplicate (idempotency)
const existingBooking = await this.bookingService.findByExternalId(
data.id,
'hostaway'
);

if (existingBooking) {
this.logger.info('Duplicate webhook, booking already exists', {
bookingId: existingBooking.id,
webhookId: context.webhookId
});
return; // Idempotent - return success
}

// Step 3: Map fields
const bookingData = await this.connectorService.mapBookingFromHostaway(data);

// Step 4: Create booking
const booking = await this.bookingService.createBookingFromChannel({
unitId: listing.unit_id,
externalId: data.id,
source: 'hostaway',
channelName: data.channelName,
confirmationCode: data.confirmationCode,
checkInDate: data.checkIn,
checkOutDate: data.checkOut,
guestName: data.guestName,
guestEmail: data.guestEmail,
guestPhone: data.guestPhone,
numberOfGuests: data.numberOfGuests,
totalAmount: Math.round(data.totalPrice * 100), // Convert to cents
currency: data.currency,
status: 'confirmed'
});

this.logger.info('Booking created from webhook', {
bookingId: booking.id,
webhookId: context.webhookId,
externalId: data.id
});

// Step 5: Emit event for other services
await this.eventBus.publish('booking.created', {
bookingId: booking.id,
source: 'hostaway',
webhookId: context.webhookId
});
}
}

Idempotency Handling

Strategy: Use external_id to detect duplicates

interface Booking {
id: string;
external_id: string; // Hostaway reservation ID
external_source: string; // "hostaway"
// ... other fields
}

// Check before creating
const existing = await db.query(
'SELECT id FROM bookings WHERE external_id = $1 AND external_source = $2',
[externalId, 'hostaway']
);

if (existing) {
return existing; // Already processed
}

API Client Implementation

Client Architecture

interface HostawayAPIClientConfig {
baseUrl: string;
apiKey: string;
accountId: string;
timeout: number; // Default: 30000ms
maxRetries: number; // Default: 3
retryDelay: number; // Default: 1000ms
}

class HostawayAPIClient {
private readonly config: HostawayAPIClientConfig;
private readonly httpClient: AxiosInstance;
private readonly rateLimiter: RateLimiter;
private readonly circuitBreaker: CircuitBreaker;
private readonly logger: Logger;

constructor(config: HostawayAPIClientConfig) {
this.config = config;
this.httpClient = this.createHttpClient();
this.rateLimiter = new RateLimiter({
maxRequests: 100,
perMilliseconds: 60000 // 100 req/min
});
this.circuitBreaker = new CircuitBreaker({
failureThreshold: 10,
resetTimeout: 60000
});
}

private createHttpClient(): AxiosInstance {
return axios.create({
baseURL: this.config.baseUrl,
timeout: this.config.timeout,
headers: {
'Authorization': `Bearer ${this.config.apiKey}`,
'Content-Type': 'application/json',
'X-Hostaway-Account-Id': this.config.accountId
}
});
}

async request<T>(
method: string,
path: string,
data?: any,
options?: RequestOptions
): Promise<T> {
// Check circuit breaker
if (this.circuitBreaker.isOpen()) {
throw new Error('Circuit breaker is open');
}

// Apply rate limiting
await this.rateLimiter.acquire();

try {
const response = await this.httpClient.request<T>({
method,
url: path,
data,
...options
});

this.circuitBreaker.recordSuccess();
return response.data;

} catch (error) {
this.circuitBreaker.recordFailure();
throw this.handleError(error);
}
}

// Listings API
async getListings(params?: { page?: number; limit?: number }): Promise<Listing[]> {
return this.request<Listing[]>('GET', '/listings', null, { params });
}

async getListing(listingId: string): Promise<Listing> {
return this.request<Listing>('GET', `/listings/${listingId}`);
}

async createListing(data: CreateListingRequest): Promise<Listing> {
return this.request<Listing>('POST', '/listings', data);
}

async updateListing(listingId: string, data: UpdateListingRequest): Promise<Listing> {
return this.request<Listing>('PUT', `/listings/${listingId}`, data);
}

// Availability API
async updateAvailability(
listingId: string,
availability: AvailabilityUpdate[]
): Promise<void> {
return this.request('POST', `/listings/${listingId}/calendar`, {
availability
});
}

// Pricing API
async updatePricing(
listingId: string,
pricing: PricingUpdate[]
): Promise<void> {
return this.request('POST', `/listings/${listingId}/rates`, {
rates: pricing
});
}

// Reservations API
async getReservation(reservationId: string): Promise<Reservation> {
return this.request<Reservation>('GET', `/reservations/${reservationId}`);
}
}

Request/Response Types

// Listings
interface Listing {
id: string;
name: string;
address: {
street: string;
city: string;
state: string;
country: string;
postalCode: string;
};
accommodates: number;
bedrooms: number;
bathrooms: number;
propertyType: string;
roomType: string;
status: 'active' | 'inactive';
}

interface CreateListingRequest {
name: string;
address: Address;
accommodates: number;
bedrooms: number;
bathrooms: number;
propertyType: string;
}

// Availability
interface AvailabilityUpdate {
date: string; // YYYY-MM-DD
status: 'available' | 'unavailable';
minStay?: number;
maxStay?: number;
}

// Pricing
interface PricingUpdate {
date: string; // YYYY-MM-DD
price: number; // Base price
currency: string; // USD
}

// Reservations
interface Reservation {
id: string;
listingId: string;
checkIn: string;
checkOut: string;
guestName: string;
guestEmail: string;
totalPrice: number;
status: 'confirmed' | 'cancelled';
}

Retry & Backoff Strategies

Exponential Backoff with Full Jitter

Algorithm:

function calculateBackoff(
attempt: number,
baseDelay: number = 1000,
maxDelay: number = 32000
): number {
// Exponential backoff: 2^attempt * baseDelay
const exponentialDelay = Math.min(
baseDelay * Math.pow(2, attempt),
maxDelay
);

// Full jitter: random delay between 0 and exponentialDelay
const jitter = Math.random() * exponentialDelay;

return Math.floor(jitter);
}

// Example delays:
// Attempt 1: 0-1000ms (avg 500ms)
// Attempt 2: 0-2000ms (avg 1000ms)
// Attempt 3: 0-4000ms (avg 2000ms)
// Attempt 4: 0-8000ms (avg 4000ms)
// Attempt 5: 0-16000ms (avg 8000ms)

Implementation:

interface RetryConfig {
maxAttempts: number; // Default: 5
baseDelay: number; // Default: 1000ms
maxDelay: number; // Default: 32000ms
retryableErrors: string[]; // HTTP status codes to retry
}

class RetryableAPIClient {
private readonly config: RetryConfig = {
maxAttempts: 5,
baseDelay: 1000,
maxDelay: 32000,
retryableErrors: ['429', '500', '502', '503', '504', 'ETIMEDOUT', 'ECONNRESET']
};

async requestWithRetry<T>(
fn: () => Promise<T>,
context: string
): Promise<T> {
let lastError: Error;

for (let attempt = 0; attempt < this.config.maxAttempts; attempt++) {
try {
const result = await fn();

if (attempt > 0) {
this.logger.info('Request succeeded after retry', {
context,
attempt: attempt + 1
});
}

return result;

} catch (error) {
lastError = error;

// Check if error is retryable
if (!this.isRetryable(error)) {
this.logger.error('Non-retryable error, failing immediately', {
context,
error: error.message
});
throw error;
}

// Check if we should retry
if (attempt < this.config.maxAttempts - 1) {
const delay = calculateBackoff(
attempt,
this.config.baseDelay,
this.config.maxDelay
);

this.logger.warn('Request failed, retrying', {
context,
attempt: attempt + 1,
maxAttempts: this.config.maxAttempts,
delayMs: delay,
error: error.message
});

await sleep(delay);
}
}
}

// All retries exhausted
this.logger.error('All retries exhausted', {
context,
attempts: this.config.maxAttempts,
error: lastError.message
});

throw new Error(
`Request failed after ${this.config.maxAttempts} attempts: ${lastError.message}`
);
}

private isRetryable(error: any): boolean {
// Network errors
if (error.code && this.config.retryableErrors.includes(error.code)) {
return true;
}

// HTTP status codes
if (error.response?.status) {
const status = error.response.status.toString();
return this.config.retryableErrors.includes(status);
}

return false;
}
}

BullMQ Job Retry Configuration

// In queue configuration
const syncQueue = new Queue('sync-jobs', {
connection: redis,
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 1000
},
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: false // Keep failed jobs for debugging
}
});

// Worker with retry handling
const worker = new Worker('sync-jobs', async (job) => {
const { listingId, syncType, data } = job.data;

try {
await syncListing(listingId, syncType, data);

} catch (error) {
// Log attempt
logger.error('Sync job failed', {
jobId: job.id,
attempt: job.attemptsMade,
maxAttempts: job.opts.attempts,
error: error.message
});

// If last attempt, move to DLQ
if (job.attemptsMade >= job.opts.attempts) {
await moveToDeadLetterQueue(job, error);
}

throw error; // Re-throw to trigger BullMQ retry
}
}, {
connection: redis,
concurrency: 10
});

Circuit Breaker Pattern

Implementation

interface CircuitBreakerConfig {
failureThreshold: number; // Open after N failures
successThreshold: number; // Close after N successes in half-open
resetTimeout: number; // Time before half-open (ms)
monitoringPeriod: number; // Time window for failure count (ms)
}

enum CircuitState {
CLOSED = 'closed', // Normal operation
OPEN = 'open', // Blocking requests
HALF_OPEN = 'half_open' // Testing if service recovered
}

class CircuitBreaker {
private state: CircuitState = CircuitState.CLOSED;
private failureCount: number = 0;
private successCount: number = 0;
private lastFailureTime: number = 0;
private readonly config: CircuitBreakerConfig;

constructor(config: CircuitBreakerConfig) {
this.config = config;
}

isOpen(): boolean {
// Check if should transition to half-open
if (this.state === CircuitState.OPEN) {
const timeSinceLastFailure = Date.now() - this.lastFailureTime;
if (timeSinceLastFailure >= this.config.resetTimeout) {
this.transitionToHalfOpen();
return false;
}
return true;
}

return false;
}

recordSuccess(): void {
this.failureCount = 0;

if (this.state === CircuitState.HALF_OPEN) {
this.successCount++;

if (this.successCount >= this.config.successThreshold) {
this.transitionToClosed();
}
}
}

recordFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();

if (this.state === CircuitState.HALF_OPEN) {
this.transitionToOpen();
} else if (this.failureCount >= this.config.failureThreshold) {
this.transitionToOpen();
}
}

private transitionToOpen(): void {
this.state = CircuitState.OPEN;
this.successCount = 0;

logger.error('Circuit breaker opened', {
failureCount: this.failureCount,
threshold: this.config.failureThreshold
});

// Emit metric
metrics.increment('circuit_breaker.opened', {
service: 'hostaway'
});
}

private transitionToHalfOpen(): void {
this.state = CircuitState.HALF_OPEN;
this.failureCount = 0;
this.successCount = 0;

logger.info('Circuit breaker half-open, testing service', {
resetTimeout: this.config.resetTimeout
});
}

private transitionToClosed(): void {
this.state = CircuitState.CLOSED;
this.failureCount = 0;
this.successCount = 0;

logger.info('Circuit breaker closed, service recovered', {
successCount: this.config.successThreshold
});

metrics.increment('circuit_breaker.closed', {
service: 'hostaway'
});
}

getState(): CircuitState {
return this.state;
}
}

Usage Example

class HostawayConnector {
private circuitBreaker: CircuitBreaker;

constructor() {
this.circuitBreaker = new CircuitBreaker({
failureThreshold: 10,
successThreshold: 3,
resetTimeout: 60000, // 1 minute
monitoringPeriod: 120000 // 2 minutes
});
}

async syncListing(listingId: string): Promise<void> {
// Check circuit breaker
if (this.circuitBreaker.isOpen()) {
throw new Error('Circuit breaker is open, service unavailable');
}

try {
// Make API call
await this.apiClient.updateListing(listingId, data);

// Record success
this.circuitBreaker.recordSuccess();

} catch (error) {
// Record failure
this.circuitBreaker.recordFailure();
throw error;
}
}
}

Error Handling

Error Classification

enum ErrorType {
// Retryable errors
RATE_LIMIT = 'rate_limit', // 429
SERVER_ERROR = 'server_error', // 500, 502, 503, 504
NETWORK_ERROR = 'network_error', // ETIMEDOUT, ECONNRESET

// Non-retryable errors
AUTH_ERROR = 'auth_error', // 401, 403
VALIDATION_ERROR = 'validation_error', // 400
NOT_FOUND = 'not_found', // 404
CONFLICT = 'conflict', // 409

// Application errors
MAPPING_ERROR = 'mapping_error',
BUSINESS_RULE_ERROR = 'business_rule_error'
}

interface APIError extends Error {
type: ErrorType;
statusCode?: number;
retryable: boolean;
context: Record<string, any>;
}

Error Handler Implementation

class ErrorHandler {
handleAPIError(error: any, context: Record<string, any>): APIError {
let errorType: ErrorType;
let retryable: boolean;
let statusCode: number | undefined;

// Network errors
if (error.code === 'ETIMEDOUT' || error.code === 'ECONNRESET') {
errorType = ErrorType.NETWORK_ERROR;
retryable = true;
}
// HTTP errors
else if (error.response) {
statusCode = error.response.status;

switch (statusCode) {
case 429:
errorType = ErrorType.RATE_LIMIT;
retryable = true;
break;
case 401:
case 403:
errorType = ErrorType.AUTH_ERROR;
retryable = false;
break;
case 400:
errorType = ErrorType.VALIDATION_ERROR;
retryable = false;
break;
case 404:
errorType = ErrorType.NOT_FOUND;
retryable = false;
break;
case 409:
errorType = ErrorType.CONFLICT;
retryable = false;
break;
case 500:
case 502:
case 503:
case 504:
errorType = ErrorType.SERVER_ERROR;
retryable = true;
break;
default:
errorType = ErrorType.SERVER_ERROR;
retryable = false;
}
}
// Unknown errors
else {
errorType = ErrorType.SERVER_ERROR;
retryable = false;
}

// Create structured error
const apiError: APIError = {
name: 'APIError',
message: error.message || 'Unknown error',
type: errorType,
statusCode,
retryable,
context
};

// Log error
this.logError(apiError);

return apiError;
}

private logError(error: APIError): void {
const logLevel = error.retryable ? 'warn' : 'error';

logger[logLevel]('API error occurred', {
type: error.type,
statusCode: error.statusCode,
retryable: error.retryable,
message: error.message,
context: error.context
});

// Emit metric
metrics.increment('api.errors', {
type: error.type,
retryable: error.retryable.toString()
});
}
}

Dead Letter Queue (DLQ)

// Failed jobs after max retries
async function moveToDeadLetterQueue(job: Job, error: Error): Promise<void> {
const dlqEntry = {
jobId: job.id,
jobName: job.name,
data: job.data,
failedAt: new Date(),
attempts: job.attemptsMade,
error: {
message: error.message,
stack: error.stack
}
};

// Store in DLQ table
await db.query(
`INSERT INTO dead_letter_queue (job_id, job_name, data, failed_at, attempts, error)
VALUES ($1, $2, $3, $4, $5, $6)`,
[
dlqEntry.jobId,
dlqEntry.jobName,
JSON.stringify(dlqEntry.data),
dlqEntry.failedAt,
dlqEntry.attempts,
JSON.stringify(dlqEntry.error)
]
);

// Alert ops team
await alerting.send({
severity: 'warning',
title: 'Job moved to DLQ',
message: `Job ${job.name} (${job.id}) failed after ${job.attemptsMade} attempts`,
context: dlqEntry
});

logger.error('Job moved to DLQ', dlqEntry);
}

Monitoring & Observability

Metrics (Prometheus)

// Counter metrics
metrics.counter('hostaway.webhooks.received', {
event_type: string
});

metrics.counter('hostaway.webhooks.processed', {
event_type: string,
status: 'success' | 'failure'
});

metrics.counter('hostaway.api.requests', {
method: string,
endpoint: string,
status_code: number
});

metrics.counter('hostaway.sync.jobs', {
sync_type: string,
status: 'success' | 'failure'
});

// Histogram metrics
metrics.histogram('hostaway.webhooks.processing_duration', {
event_type: string
});

metrics.histogram('hostaway.api.request_duration', {
method: string,
endpoint: string
});

metrics.histogram('hostaway.sync.job_duration', {
sync_type: string
});

// Gauge metrics
metrics.gauge('hostaway.circuit_breaker.state', {
state: 'open' | 'closed' | 'half_open'
});

metrics.gauge('hostaway.rate_limiter.available_tokens');

metrics.gauge('hostaway.sync.queue_length', {
sync_type: string
});

Structured Logging

// Webhook received
logger.info('Webhook received', {
webhookId: string,
event: string,
accountId: string,
timestamp: string
});

// Webhook processed
logger.info('Webhook processed', {
webhookId: string,
event: string,
processingTime: number,
bookingId: string
});

// API request
logger.debug('API request', {
method: string,
url: string,
headers: object,
body: object
});

// API response
logger.debug('API response', {
method: string,
url: string,
statusCode: number,
duration: number
});

// Sync job started
logger.info('Sync job started', {
jobId: string,
listingId: string,
syncType: string
});

// Sync job completed
logger.info('Sync job completed', {
jobId: string,
listingId: string,
syncType: string,
duration: number
});

// Circuit breaker state change
logger.warn('Circuit breaker opened', {
service: 'hostaway',
failureCount: number,
threshold: number
});

OpenTelemetry Tracing

import { trace } from '@opentelemetry/api';

const tracer = trace.getTracer('hostaway-connector');

// Webhook flow trace
async function processWebhook(payload: any): Promise<void> {
const span = tracer.startSpan('hostaway.webhook.process', {
attributes: {
'webhook.event': payload.event,
'webhook.id': payload.id
}
});

try {
// Child span for mapping
const mapSpan = tracer.startSpan('hostaway.webhook.map_fields', {
parent: span
});
const mappedData = await mapFields(payload.data);
mapSpan.end();

// Child span for booking creation
const createSpan = tracer.startSpan('hostaway.webhook.create_booking', {
parent: span
});
const booking = await createBooking(mappedData);
createSpan.end();

span.setStatus({ code: SpanStatusCode.OK });

} catch (error) {
span.setStatus({
code: SpanStatusCode.ERROR,
message: error.message
});
throw error;
} finally {
span.end();
}
}

Alerts

alerts:
# Critical alerts (page on-call)
- name: HostawayWebhookFailureRate
condition: rate(hostaway_webhooks_processed{status="failure"}[5m]) > 0.2
duration: 10m
severity: critical
message: "Hostaway webhook failure rate > 20% for 10 minutes"

- name: HostawayCircuitBreakerOpen
condition: hostaway_circuit_breaker_state{state="open"} == 1
duration: 5m
severity: critical
message: "Hostaway circuit breaker is open"

- name: HostawaySyncJobBacklog
condition: hostaway_sync_queue_length > 1000
duration: 15m
severity: critical
message: "Hostaway sync queue backlog > 1000 jobs"

# Warning alerts (Slack notification)
- name: HostawayAPIErrorRate
condition: rate(hostaway_api_requests{status_code=~"5.."}[5m]) > 0.1
duration: 10m
severity: warning
message: "Hostaway API error rate > 10%"

- name: HostawayWebhookProcessingSlow
condition: histogram_quantile(0.95, hostaway_webhooks_processing_duration) > 30
duration: 10m
severity: warning
message: "Hostaway webhook p95 processing time > 30s"

Configuration Management

Channel Configuration

interface HostawayChannelConfig {
// Authentication
credentials: {
apiKey: string;
accountId: string;
webhookSecret: string;
};

// API settings
api: {
baseUrl: string; // Default: https://api.hostaway.com/v1
timeout: number; // Default: 30000ms
maxRetries: number; // Default: 3
};

// Rate limiting
rateLimit: {
maxRequestsPerMinute: number; // Default: 100
burstCapacity: number; // Default: 120
};

// Sync settings
sync: {
enabledTypes: string[]; // ['availability', 'pricing', 'listing_details']
schedules: {
availability: string; // Cron: '*/15 * * * *' (every 15 min)
pricing: string; // Cron: '0 * * * *' (hourly)
listings: string; // Cron: '0 0 * * *' (daily)
};
};

// Webhook settings
webhook: {
enabled: boolean; // Default: true
endpoint: string; // /webhooks/hostaway
retryPolicy: {
maxAttempts: number; // Default: 5
backoffMs: number; // Default: 1000
};
};

// Circuit breaker
circuitBreaker: {
enabled: boolean; // Default: true
failureThreshold: number; // Default: 10
resetTimeoutMs: number; // Default: 60000
};
}

Environment Variables

# Hostaway API
HOSTAWAY_API_URL=https://api.hostaway.com/v1
HOSTAWAY_API_KEY=secret_key_here
HOSTAWAY_ACCOUNT_ID=account_123
HOSTAWAY_WEBHOOK_SECRET=webhook_secret_here

# Rate limiting
HOSTAWAY_RATE_LIMIT_RPM=100
HOSTAWAY_RATE_LIMIT_BURST=120

# Timeouts
HOSTAWAY_API_TIMEOUT_MS=30000
HOSTAWAY_WEBHOOK_TIMEOUT_MS=5000

# Circuit breaker
HOSTAWAY_CIRCUIT_BREAKER_ENABLED=true
HOSTAWAY_CIRCUIT_BREAKER_THRESHOLD=10
HOSTAWAY_CIRCUIT_BREAKER_RESET_MS=60000

# Retry
HOSTAWAY_MAX_RETRIES=5
HOSTAWAY_RETRY_DELAY_MS=1000

Validation & Alternatives

Architectural Decisions

Decision: Webhook-Driven Inbound Sync

Rationale:

  • Real-time booking notifications (< 1 minute latency)
  • Reduces polling overhead and API costs
  • Hostaway supports reliable webhook delivery with retries

Alternatives Considered:

  1. Polling-based sync: Query Hostaway API every N minutes
    • Pro: Simpler implementation, no webhook endpoint needed
    • Con: Higher latency, more API calls, delayed booking notifications
  2. Hybrid: Webhooks + periodic polling for reconciliation
    • Pro: Best of both worlds
    • Con: More complexity
    • Decision: Use as backup strategy if webhook issues arise

Decision: Queue-Based Outbound Sync

Rationale:

  • Async processing prevents blocking user requests
  • Built-in retry logic via BullMQ
  • Prioritization for critical syncs (availability > pricing > listing)

Alternatives Considered:

  1. Synchronous sync: Block until API call completes
    • Pro: Immediate feedback
    • Con: Poor UX, no retry mechanism
  2. Background threads: Use Node.js workers without queue
    • Pro: Simpler
    • Con: No persistence, loses jobs on restart

Decision: Circuit Breaker Pattern

Rationale:

  • Prevents cascading failures when Hostaway API is down
  • Reduces load on failing service
  • Enables graceful degradation

Alternatives Considered:

  1. No circuit breaker: Retry indefinitely
    • Pro: Simpler
    • Con: Wastes resources, delays recovery
  2. Manual intervention: Disable sync manually when API down
    • Pro: Full control
    • Con: Requires human monitoring

Manual Additions

(Reserved for human notes and decisions)