Skip to content
Dashboard

Upsells

An upsell is an offer in a property’s guest portal store: something a guest can buy (a late check-out), request (an early check-in), send (a postcard) or simply be shown (an advertisement). This guide covers the upsell operations, how pricing and payment work, and how an upsell is attached to properties. Every field is listed on the operation’s page in the API Reference.

OperationRequestPermission
listUpsellsGET /upsellsview_properties
getUpsellGET /upsells/{id}view_properties
createUpsellPOST /upsellsmodify_properties and modify_guides
updateUpsellPATCH /upsells/{id}modify_properties, plus modify_guides to change copy
reorderUpsellPOST /upsells/{id}/reordermodify_properties
listUpsellsByPropertyGET /properties/{propertyId}/upsellsview_properties
listUpsellVisibilityRulesGET /upsells/{upsellId}/visibility-rulesview_properties
listPaymentAccountsGET /payment-accountsview_payments

Paths are relative to your region’s base URL, for example https://api-us.suiteop.com/api/v1. See Authentication for permissions.

There is no delete or archive operation. Archiving an upsell is done in the SuiteOp app; the API can list archived upsells but not archive, restore or delete one.

GET /upsells is paginated (default limit 50, maximum 200). Rows come back in the merchandising order operators set: sortOrder ascending, then newest first.

Terminal window
curl "https://api-us.suiteop.com/api/v1/upsells?search=check-out&limit=50" \
-H "Authorization: Bearer sk_live_your_key_here"
  • search matches the internal nickname only, case-insensitively. Guest-facing titles aren’t searched and aren’t in the response.
  • archivedOnly is an either/or switch, not an include toggle. Omitted, you get active upsells only; true returns only archived ones. No single call returns both.

Each row carries id, nickname, type, category, isActive, isFeatured, sortOrder, archivedAt, scheduleBasis (see Delivery timing) and the pricing fields: chargeType, basePriceCents, minOptionPriceCents, currencyCode, paymentAccountId and missingPaymentAccount (see Pricing and payment).

To see what one property’s guests are offered, use GET /properties/{propertyId}/upsells. It resolves every way an upsell can reach the property (directly, through its property group, its guest portal or one of its tags), keeps one row per upsell and says which route won in assignmentLevel, with priority property, then property group, then portal, then tag. assignmentTagTitle is filled only on tag rows. Archived upsells are left out. The list isn’t paginated and carries no titles or prices.

GET /upsells/{id} returns the full record: type, category, flags, notification settings, cover, paymentAccountId, the delivery timing (scheduleBasis, scheduleMinNoticeHours, scheduleWindows) and the translated copy as titleTextId, descriptionTextId, ctaTextId and permissionToEnterTextId. Those are translation IDs, not text. Pass them to getTranslationEntries to read what a guest sees; see Translations.

It also returns the option fields, each with its id, title, fieldType, isRequired, sortOrder and choices; every choice has its own id, title, priceDeltaCents and sortOrder. Those IDs are what updateUpsell needs to edit a field or choice in place; see Other field notes.

Base pricing isn’t in this response. basePriceCents, chargeType and the other base price fields come only from listUpsells, which in turn has no guest copy. Archived upsells can still be read by ID. An ID from another organization, or one you can’t reach, is reported as not found.

nickname (the internal label, up to 200 characters), type, category and title (the guest headline, up to 200 characters) are required. Because title is guest copy, creating an upsell needs modify_guides as well as modify_properties.

Terminal window
curl -X POST https://api-us.suiteop.com/api/v1/upsells \
-H "Authorization: Bearer sk_live_your_key_here" \
-H "Idempotency-Key: 3f2b9c4e-8d1a-4b7e-9f0c-2a6d5e8b1c47" \
-H "Content-Type: application/json" \
-d '{
"nickname": "Harbour Cottage late check-out",
"type": "purchase",
"category": "upgrade_your_stay",
"title": "Late check-out until 2pm",
"description": "Take your time on departure day and leave at 2pm instead of 10am.",
"chargeType": "fixed_price",
"basePriceCents": 4500,
"paymentAccountId": "3f2b7a10-5c4d-4e8f-9a1b-6c2d3e4f5a60",
"iconName": "coffee",
"isActive": true
}'

The response is 201 with the new upsell under data:

{
"data": {
"id": "3f2b1d9e-7a4c-4b2e-8f61-0c9d2e5a7b18",
"nickname": "Harbour Cottage late check-out",
"type": "purchase",
"category": "upgrade_your_stay",
"isActive": true,
"isFeatured": false,
"requiresNotes": false,
"requiresScheduling": false,
"asksPermissionToEnter": false,
"requiresPermissionToEnter": false,
"notificationMethod": null,
"notificationSlackChannelId": null,
"postcardTemplateId": null,
"paymentAccountId": "3f2b7a10-5c4d-4e8f-9a1b-6c2d3e4f5a60",
"titleTextId": "3f2b8e21-4d6a-4c9b-a1e3-7f5d2c8b9a04",
"advertisementExternalLink": null,
"iconName": "coffee",
"imageUrl": null,
"createdAt": "2026-09-26T09:14:02.118Z",
"updatedAt": "2026-09-26T09:14:02.118Z"
},
"meta": { "requestId": "3f1c9a52-…" }
}
const res = await fetch('https://api-us.suiteop.com/api/v1/upsells', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SUITEOP_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
nickname: 'Harbour Cottage late check-out',
type: 'purchase',
category: 'upgrade_your_stay',
title: 'Late check-out until 2pm',
chargeType: 'fixed_price',
basePriceCents: 4500,
}),
})
const { data } = await res.json()
const upsellId = data.id

Field notes:

  • A new upsell reaches no property. It shows on no portal until you scope it; see Choosing which properties see it.
  • isActive defaults to false. Send true to switch it on at creation.
  • It lands first. A new upsell takes the top position and the others shift down one.
  • The response is the stored row only. It has titleTextId but no price fields, no option fields, no delivery timing and no description, CTA or entry-consent IDs; call getUpsell and listUpsells to read those back. Its requiresScheduling is deprecated: it’s true only when scheduleBasis is guest_selects. The title, description and CTA are translated into the organization’s other languages after the create.
  • type is purchase, request, postcard or advertisement. A request needs notificationMethod (email, slack or sms); a postcard needs postcardTemplateId.
  • category is add_ons, visits_and_tours, experiences, upgrade_your_stay, services, shop_your_stay, partners or other.
  • Cover: iconName or imageUrl, not both. Take iconName from searchCoverIcons; an unlisted name is saved without error and then drawn blank.
  • cta (the button label, up to 64 characters) can be set only here. description takes up to 6000 characters.
  • Entry consent: requiresPermissionToEnter only works together with asksPermissionToEnter: true. Both are forced off for postcard and advertisement.
  • Delivery timing is set with scheduleBasis, scheduleMinNoticeHours and scheduleWindows; see Delivery timing.
  • Also accepted: isFeatured, requiresNotes, hasCustomQuantifier, quantifierLabel, advertisementExternalLink, emailNotificationText, notificationText, notificationSlackChannelId, minQuantity, maxQuantity, maxPriceCents and fields.

scheduleBasis says when a purchased upsell is delivered:

scheduleBasisDelivery
noneNo date. The default on create
check_inThe reservation’s check-in date
check_outThe reservation’s check-out date
guest_selectsThe guest picks a slot, limited by scheduleWindows
  • scheduleWindows only applies to guest_selects. Each window has daysOfWeek (0 is Sunday, 6 is Saturday), startTime and endTime in property-local 24-hour HH:MM; endTime must be at least 30 minutes after startTime. Up to 20 windows. With no windows the guest picks a date only, with no time. Sending the array replaces the whole set; send [] to clear it, or omit it to keep the stored windows.
  • scheduleMinNoticeHours (1 to 720) is the minimum notice before a guest-picked slot. On update, null removes the limit.
{
"scheduleBasis": "guest_selects",
"scheduleMinNoticeHours": 24,
"scheduleWindows": [{ "daysOfWeek": [1, 2, 3, 4, 5], "startTime": "09:00", "endTime": "17:00" }]
}

requiresScheduling is still accepted but deprecated. On create, true means guest_selects and false means none. On update, true moves the upsell to guest_selects and false turns guest_selects off to none, leaving check_in or check_out as they are. Moving to guest_selects this way with no windows stored or sent adds an every-day, all-day window. A scheduleBasis in the same request wins.

Prices are integers in minor units of the payment account’s currency: 4500 means 45.00.

chargeTypePrice
freeNo charge; no payment account needed
fixed_pricebasePriceCents once
price_per_quantitybasePriceCents per unit, within minQuantity–maxQuantity
price_per_nightbasePriceCents per night
price_per_guestbasePriceCents per guest on the reservation
price_per_guest_per_nightbasePriceCents per guest per night
price_by_optionSet by the chosen options in fields; basePriceCents is optional

Rules the API enforces:

  • purchase and request need a chargeType. Any paid chargeType other than price_by_option needs basePriceCents above zero, and so does a paid postcard.
  • price_by_option needs fields. Each field is a question with at least one choice, and each choice’s priceDeltaCents is added to basePriceCents; deltas may be zero or negative. With no base price, at least one choice must carry a price. fieldType text, number or time (24-hour HH:MM) only validates the choice labels.
  • maxPriceCents caps the computed total and can’t be below basePriceCents.
  • minQuantity and maxQuantity default to 1 and 10. They’re checked as a pair against those defaults, so minQuantity: 20 alone is rejected.

paymentAccountId names the processor account that collects the money. Get it from GET /payment-accounts (needs view_payments), which returns each account’s id, nickname, provider, currencyCode and isActive. Accounts are connected in the SuiteOp app, not through the API.

  • Omit paymentAccountId to use the organization’s default account.
  • Pick an active account whose currency matches your price. A paid upsell pinned to an inactive account is saved successfully but never shown to guests. It doesn’t fall back to the default account.
  • Check missingPaymentAccount in listUpsells. It’s true for a paid upsell that can’t take money: its pinned account is inactive, or it has no pin and the organization has no active default.
  • currencyCode in listUpsells comes from the upsell’s own pricing currency, else the pinned account, else the default account, else usd.

An upsell reaches properties through four routes: individual properties, property groups, tags and guest portals. createUpsell and updateUpsell don’t set them. Use the entity scope operations with entityType: "upsell":

OperationRequestEffect
listEntityScopesGET /entity-scopesRead the current propertyIds, propertyGroupIds, tagIds and portalSettingsIds
updateEntityScopesPOST /entity-scopesReplace all four sets. An omitted array means empty, so it clears that route
addEntityScopesPOST /entity-scopes/addAdd to the existing sets
removeEntityScopesPOST /entity-scopes/removeRemove from the existing sets

The write operations need modify_properties.

Terminal window
curl -X POST https://api-us.suiteop.com/api/v1/entity-scopes/add \
-H "Authorization: Bearer sk_live_your_key_here" \
-H "Idempotency-Key: 3f2b0a7c-1e5d-4f3a-8b2c-9d6e4a1f7c35" \
-H "Content-Type: application/json" \
-d '{
"entityType": "upsell",
"entityId": "3f2b1d9e-7a4c-4b2e-8f61-0c9d2e5a7b18",
"propertyGroupIds": ["3f2b5c8d-2a1e-4d7b-9c3f-8e1a6b4d2f90"]
}'

IDs from another organization are rejected with a validation error.

An sk_ API key acts across the whole organization. An OAuth token carries the property access of the member who authorized it, so a member limited to some properties is a scoped caller:

  • Reading: a scoped caller sees an upsell that reaches at least one of their properties, and any upsell not yet assigned anywhere. Others are reported as not found, listUpsells leaves them out, and listUpsellsByProperty for a property outside their access returns an empty list.
  • Writing scopes: a scoped caller can only assign routes they wholly hold. A property group or tag counts only if they have every property in it, and a portal only if they have every property it serves, the organization default portal included. Sending any other group, tag, portal or property is refused with 404, as if it didn’t exist.
  • A replace leaves out-of-reach scopes alone. updateEntityScopes from a scoped caller replaces only what their access covers; scopes they can’t see are kept.

PATCH /upsells/{id} takes the fields to change inside a data object. Fields you leave out keep their value.

Terminal window
curl -X PATCH https://api-us.suiteop.com/api/v1/upsells/3f2b1d9e-7a4c-4b2e-8f61-0c9d2e5a7b18 \
-H "Authorization: Bearer sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{"data": {"basePriceCents": 5500, "title": "Late check-out until 3pm", "isActive": true}}'

The response is the updated row, in the same shape as the create response.

  • title and description are plain English. They overwrite the English source of the translation and are then re-translated into the organization’s other languages. Writing either needs modify_guides.
  • Re-translation replaces hand-edited translations of that field. Only fields whose text actually changed are re-translated, so resending the stored text leaves the other languages alone.
  • cta can’t be changed after create.
  • titleTextId, descriptionTextId, ctaTextId and permissionToEnterTextId relink the upsell to another existing translation. They never write text. A relink to a different ID needs modify_guides; resending the current ID doesn’t.
  • For other languages, use the Translations operations on the text IDs.
  • Changing chargeType can clear prices. Moving to price_by_option without sending basePriceCents clears the base price. Moving to price_by_option or fixed_price without sending maxPriceCents clears the ceiling.
  • The pricing rules from create still apply, checked against the merged result, so switching to a paid chargeType means sending basePriceCents in the same request.
  • minQuantity and maxQuantity reject null. There’s no way back to the default once set.
  • Setting iconName clears imageUrl, and the other way round. Sending both is rejected.
  • fields is the whole set, matched by ID. Sending fields makes it the upsell’s complete list; omitting it leaves the stored fields alone. A field or choice sent with its id from getUpsell is edited in place; one without an id is added as new; a stored field or choice you leave out is deleted, a field’s choices with it. Send back every ID you mean to keep, because past purchases point at them. An ID that isn’t on this upsell (or, for a choice, on that field) is rejected as not found and nothing is saved. Every field or choice you send is rewritten in full: send back isRequired and sortOrder, or they reset to false and the item’s position in the array. On older upsells getUpsell can return a field’s title or fieldType, or a choice’s title or priceDeltaCents, as null; the update requires them, so fill them in before sending the item back.
  • isActive: false switches the upsell off; boostersEnabled is also only settable here.
  • Guard against overwriting someone else’s edit with expectedUpdatedAt. Send it beside data, set to the updatedAt you last read from getUpsell or listUpsells, or that your last write returned, exactly as returned, milliseconds included. If anyone changed the upsell since, the update answers 409 and nothing is saved. Leave it out and the last write wins.

To change one option’s price on a price_by_option upsell, read it with GET /upsells/{id}, then send every field and choice back with its id and all its values, changing only what you mean to. This keeps both choices and raises the second one’s delta to 20.00:

Terminal window
curl -X PATCH https://api-us.suiteop.com/api/v1/upsells/3f2b1d9e-7a4c-4b2e-8f61-0c9d2e5a7b18 \
-H "Authorization: Bearer sk_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"data": {
"fields": [
{
"id": "3f2c4a81-6b2d-4e9f-8a13-5c7d9e0b2f46",
"title": "Check-out time",
"fieldType": "time",
"isRequired": true,
"sortOrder": 0,
"choices": [
{ "id": "3f2c5b92-7c3e-4fa0-9b24-6d8e0f1c3a57", "title": "12:00", "priceDeltaCents": 0, "sortOrder": 0 },
{ "id": "3f2c6ca3-8d4f-40b1-8c35-7e9f1a2d4b68", "title": "14:00", "priceDeltaCents": 2000, "sortOrder": 1 }
]
}
]
}
}'

POST /upsells/{id}/reorder moves an upsell to sit right after afterId, or to the front when afterId is null. It returns {"data": {"success": true}, "meta": {"requestId": "…"}}.

Terminal window
curl -X POST https://api-us.suiteop.com/api/v1/upsells/3f2b1d9e-7a4c-4b2e-8f61-0c9d2e5a7b18/reorder \
-H "Authorization: Bearer sk_live_your_key_here" \
-H "Idempotency-Key: 3f2b6e1a-9c4d-4a2b-8e7f-1d3c5b9a0e62" \
-H "Content-Type: application/json" \
-d '{"afterId": null}'

The order is organization-wide and includes archived upsells. Both the moved upsell and afterId must be visible to you, and afterId can’t be the upsell itself.

GET /upsells/{upsellId}/visibility-rules lists the rules that decide when an upsell is offered, one row per rule. ruleCategory is seasonal, length_of_stay, turn_day, gap_night, booking_window, booking_source or relative_date, and each row fills the fields its category uses, such as seasonalWindows, lengthOfStayFromDays, hideOnCheckInTurnDay or includedBookingSources.

Rules are read-only on the API. They’re authored in the SuiteOp app and there is no write operation. An upsell with no rules returns an empty array, and so does one you can’t reach.