Skip to main content

Onboarding an Add-on

The other pages in this section describe the model. This one is the task: you have a new meal, service or cancellation plan and you want a guest to be able to buy it.

Listing rows are usually seeded, not written by hand. The normal path is tag-driven — you enable the item at channel level against a pricing tag, and every listing carrying that tag picks it up when it is onboarded. You only write a listing_channel_* row directly when one property needs to differ from the rest.

Which shape am I onboarding?

Six things onboard through this section, in three shapes. Knowing which one you have tells you where the money lives and how much work the tag does.

ShapeApplies toWhere the price/terms liveSeeded by tag?
PricedMeals, VASThe listing row — it is copied down and then owned thereYes
DistributedPromotions, bank offers, couponsThe catalog offer only; channel and listing rows carry nothing but isEnabledYes
AttachedCancellation plansThe plan, with per-listing overrides availableNo — attach explicitly

The practical difference: for a priced item, editing the catalog cost is not enough on its own and the listing row is the thing to check. For a distributed offer there is only ever one set of numbers, so editing the promotion changes it everywhere at once and the mapping rows are purely a question of who sees it.

Meals and VAS

The short version

#Do thisWhere
1Create the catalog itemPOST /api/v1/pms/meals · POST /api/v1/pms/vas
2Give it a cost row, against a pricing tagPOST /api/v1/pms/meal-costs · POST /api/v1/pms/vas-costs
3Enable it on the channel, against the same tagPOST /api/v1/pms/channel-mappings/meals · .../vas
4Make sure the listing carries that taglisting_tag
5Create or re-onboard the listingPOST /api/v1/pms/listings
6Check what was seededGET /api/v1/pms/listing-channel-mappings/meals/by-listing/{listingId}

Steps 3 and 4 are the pair that actually does the work. ListingOnboardingService seeds a listing row only where an enabled channel row's tag_name appears in that listing's tag set. A tag typo in either place produces no error and no row — just an add-on that never shows up.

1. Catalog item

A meal is a flat identifier; a VAS additionally declares its kind (SINGLE, BUNDLE, VARIANT_PARENT) and category. Neither carries a price. See Meals and VAS for the full field lists.

2. Cost row

One row per (meal_id, tag_name), or (vas_id, variant_id?, tag_name). The tag is what ties this price to a set of listings — see Pricing Tags for how to choose one.

For a whole rate card there is POST .../meal-costs/bulk and a CSV path (upload-csv, with sample and export alongside).

3. Channel row

POST /api/v1/pms/channel-mappings/meals
{
"channelId": "chnl_B6QBF6ArzneqjX",
"mealId": "BREAKFAST",
"tagName": "goa-peak",
"adultCost": 750.00,
"childCost": 375.00,
"isEnabled": true,
"stayStartDate": "2026-10-01",
"stayEndDate": "2027-03-31"
}

isEnabled must be true for onboarding to seed anything from this row.

The two date windows set here become the fallback for the listing rows — each of the four values falls back independently, so a listing row that sets only stayEndDate still inherits the channel's other three. null means no limit, not "closed".

4. Listing tags

The listing needs a listing_tag row whose tag_name matches the channel row. A listing with no tags is skipped by onboarding entirely — the service logs No tags found for listing: {id} and returns.

5. Onboard

POST /api/v1/pms/listings creates the listing, indexes it, onboards it onto every channel matching its tags, and warms the listing-card cache. The bulk variant does the same per id.

Onboarding seeds meals, VAS, promotions, bank offers and coupons. It is additive — it creates rows that do not exist yet and leaves existing ones alone.

6. Verify

GET /api/v1/pms/listing-channel-mappings/meals/by-listing/{listingId}
GET /api/v1/pms/listing-channel-mappings/vas/by-listing/{listingId}

Then price a real stay and confirm the add-on appears at the figure you expect. by-channel/{channelId} gives the same view across a channel.

Overriding one listing

To make a single property differ, write its row directly:

POST /api/v1/pms/listing-channel-mappings/meals
POST /api/v1/pms/listing-channel-mappings/vas

The price the guest is charged comes from this row — not from the cost row and not from the channel row. Keep mealCostId / vasCostId pointing at the cost row the figures came from: that pointer is what propagation follows on the next cost edit, and clearing it quietly opts the listing out of future price changes.

Promotions, bank offers and coupons

These three behave identically to each other. All the commercial terms — discount, validity, usage caps, blackout dates, minimum nights — live on the catalog offer. The channel row (channel_promotion, channel_bank_offer, channel_coupon) is channelId + offerId + tagName + isEnabled and nothing else; the listing row is listingId + channelId + offerId + isEnabled. Neither carries a price, so there is no cost layer and nothing to propagate.

1. Create the offer

POST /api/v1/pms/promotions
POST /api/v1/pms/bank-offers
POST /api/v1/pms/coupons

All three share a common core: code, title, startDateTime / endDateTime, discountMethod, discountType, discountAmount or discountPercentage, maximumDiscountAllowed, minimumNights / maximumNights, minimumBookingAmount, maximumUsageLimit, blackoutDates, checkinBlackoutDates, stayStartDate / stayEndDate, status, rank and termsAndConditions.

Each then adds its own:

  • Promotionstype, nonRefundable, weekendOnly / weekdayOnly, clubbed, earlyBookerValueInDays, lastMinuteUnit / lastMinuteValue.
  • Bank offersbankName, instrumentCategory, cardNetwork, cardType, eligibleBins, pgOfferId / pgOfferIds, howItWorks, validOnLocations.
  • Couponsprovider, isBankOfferApplicable, isVisible.

Coupons and bank offers can have redemption codes generated in bulk under /api/v1/pms/offer-codes/{coupons|bank-offers}/{id}/generate.

2. Put it on listings

Either attach across a channel in one call —

POST /api/v1/pms/listing-channel-offers/promotions/apply-to-channel/{channelId}
POST /api/v1/pms/listing-channel-offers/bank-offers/apply-to-channel/{channelId}
POST /api/v1/pms/listing-channel-offers/coupons/apply-to-channel/{channelId}

— or one listing at a time with POST .../promotions, .../bank-offers, .../coupons. There are bulk-upsert, bulk-delete, export and sample routes for each, and the matching DELETE .../by-channel/{channelId}/{offerId} takes an offer off a whole channel.

The bank-offer channel call is a reconcile, not a plain apply: it takes the set of listings the offer should be live on and settles the channel to match, returning what it added and removed.

3. Verify

GET /api/v1/pms/listing-channel-offers/promotions/by-listing/{listingId}
GET /api/v1/pms/listing-channel-offers/bank-offers/by-channel/{channelId}/summary
Channel-level offer rows have no admin endpoint

ListingOnboardingService seeds these three from enabled channel_* rows whose tag matches the listing — the same mechanism as meals and VAS. But the admin API exposes only the listing-level mappings; there is no route that writes channel_promotion, channel_bank_offer or channel_coupon. Until one exists, tag-driven seeding for offers depends on those rows being present by another route, and apply-to-channel is the supported way to put an existing offer onto a channel's listings.

Cancellation plans

Plans differ from meals and VAS in three ways: they carry their own commercial terms rather than a separate cost row, they map under listing-channel-offers rather than listing-channel-mappings, and they are not seeded by listing onboarding — a newly created listing has no plans until you attach them.

1. Create the plan and its refund ladder

POST /api/v1/pms/cancellation-plans
{
"id": "NONREF_MONSOON",
"name": "Non-refundable",
"planType": "NON_REFUNDABLE",
"discountMethod": "PERCENTAGE",
"discountPercentage": 10,
"status": "ACTIVE",
"tiers": [
{ "daysBeforeCheckIn": 15, "refundPercentage": 100, "label": "Full refund" },
{ "daysBeforeCheckIn": 7, "refundPercentage": 50, "label": "Half refund" },
{ "daysBeforeCheckIn": 0, "refundPercentage": 0, "label": "No refund" }
]
}

planType is one of FLEXIBLE, MODERATE, STRICT, NON_REFUNDABLE. status is ACTIVE, INACTIVE or EXPIRED.

The tiers are the refund ladder — how much comes back, the further ahead the guest cancels. Each tier may carry its own stayStartDate / stayEndDate to apply only in a season, and the plan itself may be limited the same way.

The discount fields (discountMethod, discountPercentage, discountAmount, freeNights, maximumDiscountAllowed) are what the guest gets off the stay for accepting the policy — a stricter plan normally buys a lower rate.

2. Attach it

Per listing:

POST /api/v1/pms/listing-channel-offers/cancellation-plans
{
"listingId": "lst_qedo82",
"channelId": "chnl_B6QBF6ArzneqjX",
"cancellationPlanId": "NONREF_MONSOON",
"isEnabled": true
}

Or across a channel in one call, which returns the number of rows written:

POST /api/v1/pms/listing-channel-offers/cancellation-plans/apply-to-channel/{channelId}
DELETE /api/v1/pms/listing-channel-offers/cancellation-plans/by-channel/{channelId}/{cancellationPlanId}

3. Override for one listing

The mapping row can re-cut the terms without touching the shared plan: discountMethodOverride, discountPercentageOverride, discountAmountOverride, freeNightsOverride, maximumDiscountOverride, tierOverrides and termsOverride. Set isEnabled to false to withdraw a plan without deleting the row.

4. Verify

GET /api/v1/pms/listing-channel-offers/cancellation-plans/by-listing/{listingId}

When nothing shows up

SymptomCauseFix
Catalog and cost exist, listing has no rowTag on the channel row does not match any tag on the listingAlign channel_*.tag_name with the listing's listing_tag, then re-onboard
Nothing seeded for any listingChannel row is isEnabled = falseEnable it; onboarding skips disabled rows
Listing skipped entirelyListing has no tagsAdd a listing_tag row
Channel row edited, listings unchangedChannel-layer edits do not cascadeRe-onboard, or edit the listing rows directly
Cost edited, one listing kept the old priceThat row's meal_cost_id / vas_cost_id is null or points elsewhereRepoint it; see Propagation
Add-on shows on some dates onlyStay window excludes those nightsWiden stayStartDate / stayEndDate, or null them
Add-on disappeared for every dateBooking window has closedCheck bookingEndDateTime on both the listing and channel rows
Price differs from the cost rowThe listing row carries its own figures and winsUpdate the listing row, not just the cost row
A meal will not switch offchannel_meal.is_enabled gates onboarding, not display — CartOptionsService.resolveMeals filters on the date windows only, while resolveVas does check isEnabled on its listing rowClose the booking window or delete the listing row. Worth confirming whether this asymmetry is intended
New listing has no cancellation plansPlans are not tag-seededAttach them explicitly, or use apply-to-channel
Promotion edited, but a listing still shows the old discountNothing is copied down for offers — check you edited the catalog offer, not a stale cached readThe mapping rows carry no terms; there is nothing to re-propagate
Offer live on the channel but missing on one listingIts listing_channel_* row is absent or isEnabled = falseapply-to-channel, or upsert that one listing's row
Bank offer removed from some listings unexpectedlybank-offers/apply-to-channel reconciles — listings outside the set you sent are removedSend the complete set of listings the offer should be live on

What cascades later, and what does not

EditCascades to listings?
meal_cost / vas_cost price changeYes — rows pointing at that cost id are updated in the same transaction
Channel row toggled or re-pricedNo — future onboardings only
Catalog item renamedNo pricing effect; display fields are read at request time
Promotion / bank offer / coupon editedNothing to cascade — the mapping rows never held the terms, so the change is live everywhere the offer is mapped
Cancellation plan editedLive everywhere it is attached, except listings holding an override
New listing createdSeeds meals, VAS, promotions, bank offers, coupons — not cancellation plans

Every route above also answers under /api/v1/admin/… — same controller, same behaviour.