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
- Authentication & Authorization
- Webhook Handling
- API Client Implementation
- Retry & Backoff Strategies
- Circuit Breaker Pattern
- Error Handling
- Monitoring & Observability
- Configuration Management
- Validation & Alternatives
Architecture Overview
Component Diagram
Data Flow
Inbound Flow (OTA → TVL)
- Guest books on Airbnb
- Airbnb notifies Hostaway
- Hostaway sends webhook to TVL
- Webhook Handler validates signature
- Connector Service parses payload
- Field Mapper transforms data
- Booking Service creates reservation
- Availability Service creates block
Outbound Flow (TVL → OTA)
- Manager updates availability in TVL
- Availability Service emits event
- Connector Service receives event
- Sync job queued for each listing
- API Client fetches listing data
- Field Mapper transforms data
- API Client calls Hostaway API
- 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 Type | Description | Priority | Processing Time | 
|---|---|---|---|
| reservation.created | New booking confirmed | Critical | < 5s | 
| reservation.updated | Booking modified (dates, guests) | High | < 10s | 
| reservation.cancelled | Booking cancelled | Critical | < 5s | 
| listing.updated | Listing details changed | Medium | < 30s | 
| calendar.updated | Availability changed | High | < 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:
- 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
 
- 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:
- Synchronous sync: Block until API call completes
- Pro: Immediate feedback
- Con: Poor UX, no retry mechanism
 
- 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:
- No circuit breaker: Retry indefinitely
- Pro: Simpler
- Con: Wastes resources, delays recovery
 
- Manual intervention: Disable sync manually when API down
- Pro: Full control
- Con: Requires human monitoring
 
Manual Additions
(Reserved for human notes and decisions)