Skip to main content

Hostaway Field Mapping Contracts

Summary

This document defines the complete field mapping specifications between TVL's internal data models and Hostaway's API schemas. Field mappings enable bi-directional data synchronization, ensuring that property listings, bookings, availability, and pricing data are correctly transformed when flowing between TVL and Hostaway.

The document includes:

  • Field-level mappings for all entity types
  • Transformation functions for data conversion
  • Validation rules for data integrity
  • Concrete examples with before/after data

Table of Contents


Mapping Architecture

Mapping Strategy

TVL uses a configuration-driven mapping approach where field mappings are stored in the database and can be customized per channel or listing. This allows flexibility without code changes.

interface FieldMapping {
id: string;
channelId: string; // Hostaway channel ID
entityType: EntityType; // 'property', 'booking', 'availability', 'pricing'
direction: Direction; // 'inbound', 'outbound', 'bidirectional'
sourceField: string; // TVL field path (e.g., 'property.name')
targetField: string; // Hostaway field path (e.g., 'listing.name')
transformation?: string; // Optional transform function
defaultValue?: any; // Fallback if source is null
isRequired: boolean; // Required by target system
validationRule?: string; // Custom validation
}

enum EntityType {
PROPERTY = 'property',
UNIT = 'unit',
BOOKING = 'booking',
AVAILABILITY = 'availability',
PRICING = 'pricing'
}

enum Direction {
INBOUND = 'inbound', // Hostaway → TVL
OUTBOUND = 'outbound', // TVL → Hostaway
BIDIRECTIONAL = 'bidirectional'
}

Mapper Service

class FieldMapperService {
async mapToHostaway<T>(
entityType: EntityType,
tvlData: any,
channelId: string
): Promise<T> {
// Fetch mappings for channel and entity type
const mappings = await this.getMappings(channelId, entityType, 'outbound');

const hostawayData: any = {};

for (const mapping of mappings) {
const sourceValue = this.getNestedValue(tvlData, mapping.sourceField);

// Apply transformation if specified
let targetValue = mapping.transformation
? await this.transform(sourceValue, mapping.transformation)
: sourceValue;

// Use default if null and default specified
if (targetValue == null && mapping.defaultValue != null) {
targetValue = mapping.defaultValue;
}

// Validate required fields
if (mapping.isRequired && targetValue == null) {
throw new MappingError(
`Required field missing: ${mapping.targetField}`,
{ mapping, tvlData }
);
}

// Set value in target object
this.setNestedValue(hostawayData, mapping.targetField, targetValue);
}

return hostawayData as T;
}

async mapFromHostaway<T>(
entityType: EntityType,
hostawayData: any,
channelId: string
): Promise<T> {
const mappings = await this.getMappings(channelId, entityType, 'inbound');

const tvlData: any = {};

for (const mapping of mappings) {
const sourceValue = this.getNestedValue(hostawayData, mapping.targetField);

let targetValue = mapping.transformation
? await this.transform(sourceValue, mapping.transformation)
: sourceValue;

if (targetValue == null && mapping.defaultValue != null) {
targetValue = mapping.defaultValue;
}

this.setNestedValue(tvlData, mapping.sourceField, targetValue);
}

return tvlData as T;
}

private getNestedValue(obj: any, path: string): any {
return path.split('.').reduce((acc, part) => acc?.[part], obj);
}

private setNestedValue(obj: any, path: string, value: any): void {
const parts = path.split('.');
const last = parts.pop()!;
const target = parts.reduce((acc, part) => {
if (!acc[part]) acc[part] = {};
return acc[part];
}, obj);
target[last] = value;
}

private async transform(value: any, transformName: string): Promise<any> {
const transformFn = transformations[transformName];
if (!transformFn) {
throw new Error(`Unknown transformation: ${transformName}`);
}
return transformFn(value);
}
}

Property & Unit Mappings

TVL Property/Unit Schema

interface Property {
id: string;
organizationId: string;
name: string;
description: string;
propertyType: PropertyType;
address: {
street: string;
city: string;
state: string;
postalCode: string;
country: string;
latitude?: number;
longitude?: number;
};
amenities: string[]; // ['wifi', 'parking', 'pool', ...]
photos: {
url: string;
caption?: string;
order: number;
}[];
}

interface Unit {
id: string;
propertyId: string;
name: string;
unitType: UnitType; // 'entire_place', 'private_room', 'shared_room'
maxGuests: number;
bedrooms: number;
beds: number;
bathrooms: number;
bathroomType: 'private' | 'shared';
squareFeet?: number;
checkInTime: string; // '15:00'
checkOutTime: string; // '11:00'
minStay: number; // Minimum nights
maxStay?: number; // Maximum nights
}

enum PropertyType {
HOUSE = 'house',
APARTMENT = 'apartment',
CONDO = 'condo',
VILLA = 'villa',
CABIN = 'cabin',
COTTAGE = 'cottage'
}

enum UnitType {
ENTIRE_PLACE = 'entire_place',
PRIVATE_ROOM = 'private_room',
SHARED_ROOM = 'shared_room'
}

Hostaway Listing Schema

{
"id": "hostaway_lst_123",
"name": "Beautiful Beachfront Villa",
"address": {
"street": "123 Ocean Drive",
"city": "Miami Beach",
"state": "FL",
"zipcode": "33139",
"country": "US",
"lat": 25.7617,
"lng": -80.1918
},
"propertyTypeId": 1,
"roomTypeId": 1,
"accommodates": 8,
"bedrooms": 4,
"beds": 5,
"bathrooms": 3.5,
"timezone": "America/New_York",
"checkInTime": "15:00",
"checkOutTime": "11:00",
"currency": "USD",
"amenities": [1, 5, 12, 23],
"pictures": [
{
"url": "https://cdn.example.com/photo1.jpg",
"caption": "Living room view",
"sortOrder": 1
}
],
"status": "active"
}

Field Mappings (Outbound: TVL → Hostaway)

TVL FieldHostaway FieldTransformRequiredDefaultValidation
property.namename-Yes-Max 100 chars
property.descriptiondescription-No-Max 5000 chars
property.propertyTypepropertyTypeIdmapPropertyTypeYes1Valid type ID
unit.unitTyperoomTypeIdmapRoomTypeYes1Valid room ID
property.address.streetaddress.street-Yes-Max 255 chars
property.address.cityaddress.city-Yes--
property.address.stateaddress.state-Yes-2-letter code
property.address.postalCodeaddress.zipcode-Yes--
property.address.countryaddress.country-Yes-2-letter ISO
property.address.latitudeaddress.lat-No--90 to 90
property.address.longitudeaddress.lng-No--180 to 180
unit.maxGuestsaccommodatesroundUpYes-Min 1
unit.bedroomsbedroomsroundUpYes1Min 0
unit.bedsbedsroundUpYes1Min 1
unit.bathroomsbathrooms-Yes1Min 0
unit.checkInTimecheckInTimeformatTimeNo'15:00'HH:MM
unit.checkOutTimecheckOutTimeformatTimeNo'11:00'HH:MM
property.amenitiesamenitiesmapAmenitiesNo[]Array of IDs
property.photospicturesmapPhotosNo[]Max 50

Field Mappings (Inbound: Hostaway → TVL)

Hostaway FieldTVL FieldTransformRequiredDefault
idlisting.externalId-Yes-
nameproperty.namesanitizeYes-
address.streetproperty.address.street-Yes-
address.cityproperty.address.city-Yes-
address.stateproperty.address.state-Yes-
address.zipcodeproperty.address.postalCode-Yes-
address.countryproperty.address.country-Yes-
address.latproperty.address.latitude-Nonull
address.lngproperty.address.longitude-Nonull
propertyTypeIdproperty.propertyTypemapPropertyTypeFromIdYes'house'
roomTypeIdunit.unitTypemapRoomTypeFromIdYes'entire_place'
accommodatesunit.maxGuests-Yes-
bedroomsunit.bedrooms-Yes1
bedsunit.beds-Yes1
bathroomsunit.bathrooms-Yes1
amenitiesproperty.amenitiesmapAmenitiesFromIdsNo[]
picturesproperty.photosmapPhotosFromHostawayNo[]

Booking Mappings

TVL Booking Schema

interface Booking {
id: string;
unitId: string;
externalId?: string; // Hostaway reservation ID
externalSource?: string; // 'hostaway'
channelName?: string; // 'Airbnb', 'VRBO'
confirmationCode: string;
status: BookingStatus;
checkInDate: string; // YYYY-MM-DD
checkOutDate: string; // YYYY-MM-DD
numberOfNights: number;
guest: {
name: string;
email: string;
phone?: string;
};
guests: {
adults: number;
children: number;
infants: number;
};
pricing: {
baseAmount: number; // Cents
cleaningFee: number; // Cents
taxAmount: number; // Cents
totalAmount: number; // Cents
currency: string; // 'USD'
};
isPaid: boolean;
createdAt: string;
}

enum BookingStatus {
PENDING = 'pending',
CONFIRMED = 'confirmed',
CANCELLED = 'cancelled',
COMPLETED = 'completed'
}

Hostaway Reservation Schema

{
"id": "hostaway_res_123",
"listingId": "hostaway_lst_456",
"channelId": "airbnb",
"channelName": "Airbnb",
"confirmationCode": "ABC123XYZ",
"status": "confirmed",
"arrivalDate": "2025-11-15",
"departureDate": "2025-11-20",
"nights": 5,
"guestName": "John Doe",
"guestEmail": "john@example.com",
"guestPhone": "+1-555-0123",
"numberOfGuests": 4,
"adults": 3,
"children": 1,
"infants": 0,
"totalPrice": 1500.00,
"cleaningFee": 150.00,
"taxAmount": 120.00,
"currency": "USD",
"isPaid": false,
"isManuallyChecked": false,
"insertedOn": "2025-10-24T10:00:00Z"
}

Field Mappings (Inbound: Hostaway → TVL)

Hostaway FieldTVL FieldTransformRequiredDefaultNotes
idexternalId-Yes-Idempotency key
listingIdunitIdmapListingToUnitYes-Via listing lookup
channelNamechannelName-No'Unknown'Display name
confirmationCodeconfirmationCode-Yes--
statusstatusmapBookingStatusYes'confirmed'-
arrivalDatecheckInDate-Yes-YYYY-MM-DD
departureDatecheckOutDate-Yes-YYYY-MM-DD
nightsnumberOfNights-Yes-Calculated field
guestNameguest.namesanitizeYes--
guestEmailguest.emailtoLowerCaseYes--
guestPhoneguest.phoneformatPhoneNonull-
adultsguests.adults-Yes1Min 1
childrenguests.children-Yes0Min 0
infantsguests.infants-Yes0Min 0
totalPricepricing.totalAmountdollarsToCentsYes-USD cents
cleaningFeepricing.cleaningFeedollarsToCentsNo0USD cents
taxAmountpricing.taxAmountdollarsToCentsNo0USD cents
currencypricing.currency-Yes'USD'ISO code
isPaidisPaid-Nofalse-
insertedOncreatedAt-Yes-ISO timestamp

Field Mappings (Outbound: TVL → Hostaway)

Bookings typically flow inbound only (Hostaway → TVL), as guests book on OTAs and Hostaway notifies TVL. However, if TVL creates a booking (direct booking), it may need to push to Hostaway:

TVL FieldHostaway FieldTransformRequiredNotes
unitIdlistingIdmapUnitToListingYesVia listing lookup
confirmationCodeconfirmationCode-Yes-
statusstatusmapBookingStatusToHostawayYes-
checkInDatearrivalDate-YesYYYY-MM-DD
checkOutDatedepartureDate-YesYYYY-MM-DD
guest.nameguestName-Yes-
guest.emailguestEmail-Yes-
guest.phoneguestPhone-No-
guests.adultsadults-YesMin 1
guests.childrenchildren-NoDefault 0
pricing.totalAmounttotalPricecentsToDollarsYesUSD dollars
pricing.cleaningFeecleaningFeecentsToDollarsNoUSD dollars

Availability Mappings

TVL Availability Schema

interface AvailabilityBlock {
id: string;
unitId: string;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
status: BlockStatus;
reason?: string; // 'booking', 'maintenance', 'owner_block'
minStay?: number;
maxStay?: number;
}

enum BlockStatus {
AVAILABLE = 'available',
BLOCKED = 'blocked',
BOOKED = 'booked'
}

Hostaway Calendar Schema

{
"listingId": "hostaway_lst_456",
"calendar": [
{
"date": "2025-11-15",
"status": "unavailable",
"reason": "booked",
"minStay": 2,
"maxStay": null
},
{
"date": "2025-11-16",
"status": "available",
"minStay": 3,
"maxStay": 14
}
]
}

Field Mappings (Outbound: TVL → Hostaway)

TVL FieldHostaway FieldTransformRequiredDefaultNotes
unitIdlistingIdmapUnitToListingYes-Via listing lookup
startDatedate-Yes-Each date in range
statusstatusmapAvailabilityStatusYes-'available' or 'unavailable'
reasonreason-NonullDisplay only
minStayminStay-NonullMinimum nights
maxStaymaxStay-NonullMaximum nights

Availability Status Mapping:

function mapAvailabilityStatus(tvlStatus: BlockStatus): string {
switch (tvlStatus) {
case BlockStatus.AVAILABLE:
return 'available';
case BlockStatus.BLOCKED:
case BlockStatus.BOOKED:
return 'unavailable';
default:
return 'unavailable';
}
}

Field Mappings (Inbound: Hostaway → TVL)

Hostaway FieldTVL FieldTransformRequiredNotes
listingIdunitIdmapListingToUnitYesVia listing lookup
datestartDate & endDatesingleDateYesSame date
statusstatusmapAvailabilityStatusFromHostawayYes-
reasonreason-No-

Pricing Mappings

TVL Pricing Schema

interface PriceRule {
id: string;
unitId: string;
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
basePrice: number; // Cents per night
cleaningFee: number; // Cents
currency: string; // 'USD'
minStay?: number;
extraGuestFee?: number; // Cents per guest over base occupancy
}

Hostaway Pricing Schema

{
"listingId": "hostaway_lst_456",
"rates": [
{
"date": "2025-11-15",
"price": 250.00,
"currency": "USD",
"minStay": 3
},
{
"date": "2025-11-16",
"price": 275.00,
"currency": "USD"
}
]
}

Field Mappings (Outbound: TVL → Hostaway)

TVL FieldHostaway FieldTransformRequiredDefaultNotes
unitIdlistingIdmapUnitToListingYes-Via listing lookup
startDatedate-Yes-Each date in range
basePricepricecentsToDollarsYes-USD dollars
currencycurrency-Yes'USD'ISO code
minStayminStay-NonullMinimum nights

Note: Hostaway does not support cleaningFee or extraGuestFee in daily rates. These must be configured at the listing level.

Field Mappings (Inbound: Hostaway → TVL)

Pricing typically flows outbound only (TVL → Hostaway), as TVL is the source of truth for pricing. However, if Hostaway prices are updated externally, they can be imported:

Hostaway FieldTVL FieldTransformRequiredNotes
listingIdunitIdmapListingToUnitYesVia listing lookup
datestartDate & endDatesingleDateYesSame date
pricebasePricedollarsToCentsYesUSD cents
currencycurrency-YesISO code

Transformation Functions

String Transformations

const transformations = {
// Sanitize HTML and trim whitespace
sanitize: (value: string): string => {
if (!value) return value;
return value
.replace(/<[^>]*>/g, '') // Strip HTML tags
.trim();
},

// Convert to lowercase
toLowerCase: (value: string): string => {
return value?.toLowerCase() || value;
},

// Format phone number
formatPhone: (value: string): string => {
if (!value) return value;
// Remove non-digits
const digits = value.replace(/\D/g, '');
// Format as +1-XXX-XXX-XXXX (US format)
if (digits.length === 10) {
return `+1-${digits.slice(0,3)}-${digits.slice(3,6)}-${digits.slice(6)}`;
}
return value; // Return as-is if not 10 digits
},

// Format time to HH:MM
formatTime: (value: string): string => {
if (!value) return value;
const match = value.match(/^(\d{1,2}):(\d{2})$/);
if (!match) return value;
const [, hours, minutes] = match;
return `${hours.padStart(2, '0')}:${minutes}`;
}
};

Numeric Transformations

const transformations = {
// Round up to nearest integer
roundUp: (value: number): number => {
return Math.ceil(value);
},

// Convert dollars to cents
dollarsToCents: (value: number): number => {
return Math.round(value * 100);
},

// Convert cents to dollars
centsToDollars: (value: number): number => {
return value / 100;
},

// Clamp value between min and max
clamp: (value: number, min: number, max: number): number => {
return Math.max(min, Math.min(max, value));
}
};

Enum Mappings

const transformations = {
// Property Type: TVL → Hostaway
mapPropertyType: (tvlType: PropertyType): number => {
const mapping: Record<PropertyType, number> = {
[PropertyType.HOUSE]: 1,
[PropertyType.APARTMENT]: 2,
[PropertyType.CONDO]: 3,
[PropertyType.VILLA]: 4,
[PropertyType.CABIN]: 5,
[PropertyType.COTTAGE]: 6
};
return mapping[tvlType] || 1; // Default to house
},

// Property Type: Hostaway → TVL
mapPropertyTypeFromId: (hostawayId: number): PropertyType => {
const mapping: Record<number, PropertyType> = {
1: PropertyType.HOUSE,
2: PropertyType.APARTMENT,
3: PropertyType.CONDO,
4: PropertyType.VILLA,
5: PropertyType.CABIN,
6: PropertyType.COTTAGE
};
return mapping[hostawayId] || PropertyType.HOUSE;
},

// Room Type: TVL → Hostaway
mapRoomType: (tvlType: UnitType): number => {
const mapping: Record<UnitType, number> = {
[UnitType.ENTIRE_PLACE]: 1,
[UnitType.PRIVATE_ROOM]: 2,
[UnitType.SHARED_ROOM]: 3
};
return mapping[tvlType] || 1;
},

// Room Type: Hostaway → TVL
mapRoomTypeFromId: (hostawayId: number): UnitType => {
const mapping: Record<number, UnitType> = {
1: UnitType.ENTIRE_PLACE,
2: UnitType.PRIVATE_ROOM,
3: UnitType.SHARED_ROOM
};
return mapping[hostawayId] || UnitType.ENTIRE_PLACE;
},

// Booking Status: Hostaway → TVL
mapBookingStatus: (hostawayStatus: string): BookingStatus => {
const mapping: Record<string, BookingStatus> = {
'confirmed': BookingStatus.CONFIRMED,
'pending': BookingStatus.PENDING,
'cancelled': BookingStatus.CANCELLED,
'completed': BookingStatus.COMPLETED
};
return mapping[hostawayStatus] || BookingStatus.CONFIRMED;
},

// Booking Status: TVL → Hostaway
mapBookingStatusToHostaway: (tvlStatus: BookingStatus): string => {
const mapping: Record<BookingStatus, string> = {
[BookingStatus.CONFIRMED]: 'confirmed',
[BookingStatus.PENDING]: 'pending',
[BookingStatus.CANCELLED]: 'cancelled',
[BookingStatus.COMPLETED]: 'completed'
};
return mapping[tvlStatus] || 'confirmed';
}
};

Array Transformations

const transformations = {
// Map amenity names to Hostaway IDs
mapAmenities: async (tvlAmenities: string[]): Promise<number[]> => {
const mapping = await getAmenityMapping(); // Fetch from DB
return tvlAmenities
.map(name => mapping[name])
.filter(id => id != null);
},

// Map Hostaway amenity IDs to TVL names
mapAmenitiesFromIds: async (hostawayIds: number[]): Promise<string[]> => {
const mapping = await getAmenityMapping();
const reverseMapping = Object.fromEntries(
Object.entries(mapping).map(([k, v]) => [v, k])
);
return hostawayIds
.map(id => reverseMapping[id])
.filter(name => name != null);
},

// Map photos
mapPhotos: (tvlPhotos: Property['photos']): any[] => {
return tvlPhotos.map(photo => ({
url: photo.url,
caption: photo.caption || '',
sortOrder: photo.order
}));
},

// Map photos from Hostaway
mapPhotosFromHostaway: (hostawayPictures: any[]): Property['photos'] => {
return hostawayPictures.map(pic => ({
url: pic.url,
caption: pic.caption,
order: pic.sortOrder
}));
}
};

Lookup Transformations

const transformations = {
// Map Hostaway listing ID to TVL unit ID
mapListingToUnit: async (listingId: string): Promise<string> => {
const listing = await db.query(
'SELECT unit_id FROM listings WHERE external_id = $1 AND channel_type = $2',
[listingId, 'hostaway']
);

if (!listing) {
throw new MappingError(`Listing not found: ${listingId}`);
}

return listing.unit_id;
},

// Map TVL unit ID to Hostaway listing ID
mapUnitToListing: async (unitId: string): Promise<string> => {
const listing = await db.query(
'SELECT external_id FROM listings WHERE unit_id = $1 AND channel_type = $2 AND status = $3',
[unitId, 'hostaway', 'active']
);

if (!listing) {
throw new MappingError(`Active listing not found for unit: ${unitId}`);
}

return listing.external_id;
}
};

Validation Rules

Field Validators

interface ValidationRule {
type: ValidationType;
params?: any;
message?: string;
}

enum ValidationType {
REQUIRED = 'required',
MIN_LENGTH = 'min_length',
MAX_LENGTH = 'max_length',
MIN_VALUE = 'min_value',
MAX_VALUE = 'max_value',
PATTERN = 'pattern',
ENUM = 'enum',
CUSTOM = 'custom'
}

class FieldValidator {
validate(value: any, rules: ValidationRule[]): ValidationResult {
const errors: string[] = [];

for (const rule of rules) {
const error = this.validateRule(value, rule);
if (error) {
errors.push(error);
}
}

return {
isValid: errors.length === 0,
errors
};
}

private validateRule(value: any, rule: ValidationRule): string | null {
switch (rule.type) {
case ValidationType.REQUIRED:
if (value == null || value === '') {
return rule.message || 'Field is required';
}
break;

case ValidationType.MIN_LENGTH:
if (typeof value === 'string' && value.length < rule.params) {
return rule.message || `Minimum length is ${rule.params}`;
}
break;

case ValidationType.MAX_LENGTH:
if (typeof value === 'string' && value.length > rule.params) {
return rule.message || `Maximum length is ${rule.params}`;
}
break;

case ValidationType.MIN_VALUE:
if (typeof value === 'number' && value < rule.params) {
return rule.message || `Minimum value is ${rule.params}`;
}
break;

case ValidationType.MAX_VALUE:
if (typeof value === 'number' && value > rule.params) {
return rule.message || `Maximum value is ${rule.params}`;
}
break;

case ValidationType.PATTERN:
if (typeof value === 'string' && !new RegExp(rule.params).test(value)) {
return rule.message || `Value does not match pattern`;
}
break;

case ValidationType.ENUM:
if (!rule.params.includes(value)) {
return rule.message || `Value must be one of: ${rule.params.join(', ')}`;
}
break;
}

return null;
}
}

Common Validation Rules

// Property name
const propertyNameRules: ValidationRule[] = [
{ type: ValidationType.REQUIRED },
{ type: ValidationType.MIN_LENGTH, params: 5, message: 'Name too short' },
{ type: ValidationType.MAX_LENGTH, params: 100, message: 'Name too long' }
];

// Email
const emailRules: ValidationRule[] = [
{ type: ValidationType.REQUIRED },
{ type: ValidationType.PATTERN, params: '^[^@]+@[^@]+\\.[^@]+$', message: 'Invalid email' }
];

// Price (cents)
const priceRules: ValidationRule[] = [
{ type: ValidationType.REQUIRED },
{ type: ValidationType.MIN_VALUE, params: 0, message: 'Price cannot be negative' }
];

// Max guests
const maxGuestsRules: ValidationRule[] = [
{ type: ValidationType.REQUIRED },
{ type: ValidationType.MIN_VALUE, params: 1, message: 'At least 1 guest required' },
{ type: ValidationType.MAX_VALUE, params: 50, message: 'Maximum 50 guests' }
];

// Date (YYYY-MM-DD)
const dateRules: ValidationRule[] = [
{ type: ValidationType.REQUIRED },
{ type: ValidationType.PATTERN, params: '^\\d{4}-\\d{2}-\\d{2}$', message: 'Invalid date format' }
];

Examples

Example 1: Property Mapping (Outbound)

TVL Data:

const tvlProperty: Property = {
id: 'prop_123',
organizationId: 'org_456',
name: 'Stunning Oceanfront Villa',
description: 'Luxury 4BR villa with private beach access...',
propertyType: PropertyType.VILLA,
address: {
street: '456 Seaside Lane',
city: 'Malibu',
state: 'CA',
postalCode: '90265',
country: 'US',
latitude: 34.0259,
longitude: -118.7798
},
amenities: ['wifi', 'parking', 'pool', 'beach_access'],
photos: [
{ url: 'https://cdn.example.com/photo1.jpg', caption: 'Ocean view', order: 1 },
{ url: 'https://cdn.example.com/photo2.jpg', caption: 'Kitchen', order: 2 }
]
};

const tvlUnit: Unit = {
id: 'unit_789',
propertyId: 'prop_123',
name: 'Main Villa',
unitType: UnitType.ENTIRE_PLACE,
maxGuests: 8,
bedrooms: 4,
beds: 5,
bathrooms: 3.5,
bathroomType: 'private',
checkInTime: '15:00',
checkOutTime: '11:00',
minStay: 3,
maxStay: 14
};

Mapped Hostaway Data:

{
"name": "Stunning Oceanfront Villa",
"description": "Luxury 4BR villa with private beach access...",
"propertyTypeId": 4,
"roomTypeId": 1,
"address": {
"street": "456 Seaside Lane",
"city": "Malibu",
"state": "CA",
"zipcode": "90265",
"country": "US",
"lat": 34.0259,
"lng": -118.7798
},
"accommodates": 8,
"bedrooms": 4,
"beds": 5,
"bathrooms": 3.5,
"checkInTime": "15:00",
"checkOutTime": "11:00",
"currency": "USD",
"amenities": [1, 5, 12, 23],
"pictures": [
{
"url": "https://cdn.example.com/photo1.jpg",
"caption": "Ocean view",
"sortOrder": 1
},
{
"url": "https://cdn.example.com/photo2.jpg",
"caption": "Kitchen",
"sortOrder": 2
}
]
}

Example 2: Booking Mapping (Inbound)

Hostaway Webhook Data:

{
"event": "reservation.created",
"data": {
"id": "hostaway_res_999",
"listingId": "hostaway_lst_789",
"channelId": "airbnb",
"channelName": "Airbnb",
"confirmationCode": "HMABCDE12345",
"status": "confirmed",
"arrivalDate": "2025-12-01",
"departureDate": "2025-12-08",
"nights": 7,
"guestName": "Jane Smith",
"guestEmail": "jane.smith@email.com",
"guestPhone": "+1-555-0199",
"numberOfGuests": 6,
"adults": 4,
"children": 2,
"infants": 0,
"totalPrice": 3500.00,
"cleaningFee": 250.00,
"taxAmount": 280.00,
"currency": "USD",
"isPaid": false,
"insertedOn": "2025-11-20T14:30:00Z"
}
}

Mapped TVL Booking:

const tvlBooking: Booking = {
id: 'bkg_auto_generated',
unitId: 'unit_789', // Looked up via listingId
externalId: 'hostaway_res_999',
externalSource: 'hostaway',
channelName: 'Airbnb',
confirmationCode: 'HMABCDE12345',
status: BookingStatus.CONFIRMED,
checkInDate: '2025-12-01',
checkOutDate: '2025-12-08',
numberOfNights: 7,
guest: {
name: 'Jane Smith',
email: 'jane.smith@email.com',
phone: '+1-555-0199'
},
guests: {
adults: 4,
children: 2,
infants: 0
},
pricing: {
baseAmount: 350000, // $3500 * 100
cleaningFee: 25000, // $250 * 100
taxAmount: 28000, // $280 * 100
totalAmount: 403000, // $4030 * 100
currency: 'USD'
},
isPaid: false,
createdAt: '2025-11-20T14:30:00Z'
};

Example 3: Availability Mapping (Outbound)

TVL Availability Blocks:

const tvlBlocks: AvailabilityBlock[] = [
{
id: 'block_1',
unitId: 'unit_789',
startDate: '2025-12-01',
endDate: '2025-12-08',
status: BlockStatus.BOOKED,
reason: 'booking',
minStay: 3
},
{
id: 'block_2',
unitId: 'unit_789',
startDate: '2025-12-15',
endDate: '2025-12-20',
status: BlockStatus.BLOCKED,
reason: 'maintenance',
minStay: null
}
];

Mapped Hostaway Calendar:

{
"listingId": "hostaway_lst_789",
"calendar": [
{ "date": "2025-12-01", "status": "unavailable", "reason": "booked", "minStay": 3 },
{ "date": "2025-12-02", "status": "unavailable", "reason": "booked", "minStay": 3 },
{ "date": "2025-12-03", "status": "unavailable", "reason": "booked", "minStay": 3 },
{ "date": "2025-12-04", "status": "unavailable", "reason": "booked", "minStay": 3 },
{ "date": "2025-12-05", "status": "unavailable", "reason": "booked", "minStay": 3 },
{ "date": "2025-12-06", "status": "unavailable", "reason": "booked", "minStay": 3 },
{ "date": "2025-12-07", "status": "unavailable", "reason": "booked", "minStay": 3 },
{ "date": "2025-12-15", "status": "unavailable", "reason": "maintenance" },
{ "date": "2025-12-16", "status": "unavailable", "reason": "maintenance" },
{ "date": "2025-12-17", "status": "unavailable", "reason": "maintenance" },
{ "date": "2025-12-18", "status": "unavailable", "reason": "maintenance" },
{ "date": "2025-12-19", "status": "unavailable", "reason": "maintenance" }
]
}

Validation & Alternatives

Architectural Decisions

Decision: Database-Driven Field Mappings

Rationale:

  • Allows per-tenant customization without code changes
  • Supports A/B testing of different mapping strategies
  • Enables UI for property managers to adjust mappings

Alternatives Considered:

  1. Hardcoded mappings in code
    • Pro: Simpler, faster
    • Con: No flexibility, requires deploy for changes
  2. Config files (JSON/YAML)
    • Pro: Version controlled, code review
    • Con: Still requires deploy, no per-tenant customization

Decision: Eager Validation vs. Lazy Validation

Chosen: Eager validation (validate before API call)

Rationale:

  • Fail fast, avoid wasted API calls
  • Better error messages for property managers
  • Reduces external API load

Alternative: Lazy validation (let Hostaway API reject)

  • Pro: Simpler implementation
  • Con: Wastes API quota, poor UX

Decision: Transformation Functions as Named Strings

Rationale:

  • Stored in database as function names
  • Allows dynamic application without code generation
  • Supports custom transformations per tenant

Alternative: Lambda/code stored in DB

  • Pro: Maximum flexibility
  • Con: Security risk, hard to test

Manual Additions

(Reserved for human notes and decisions)