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.
| Operation | Request | Permission |
|---|---|---|
listUpsells | GET /upsells | view_properties |
getUpsell | GET /upsells/{id} | view_properties |
createUpsell | POST /upsells | modify_properties and modify_guides |
updateUpsell | PATCH /upsells/{id} | modify_properties, plus modify_guides to change copy |
reorderUpsell | POST /upsells/{id}/reorder | modify_properties |
listUpsellsByProperty | GET /properties/{propertyId}/upsells | view_properties |
listUpsellVisibilityRules | GET /upsells/{upsellId}/visibility-rules | view_properties |
listPaymentAccounts | GET /payment-accounts | view_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.
Listing upsells
Section titled “Listing upsells”GET /upsells is paginated (default limit 50, maximum 200). Rows come back in the merchandising order operators set: sortOrder ascending, then newest first.
curl "https://api-us.suiteop.com/api/v1/upsells?search=check-out&limit=50" \ -H "Authorization: Bearer sk_live_your_key_here"searchmatches the internalnicknameonly, case-insensitively. Guest-facing titles aren’t searched and aren’t in the response.archivedOnlyis an either/or switch, not an include toggle. Omitted, you get active upsells only;truereturns 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.
Reading one upsell
Section titled “Reading one upsell”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.
Creating an upsell
Section titled “Creating an upsell”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.
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.idField notes:
- A new upsell reaches no property. It shows on no portal until you scope it; see Choosing which properties see it.
isActivedefaults tofalse. Sendtrueto 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
titleTextIdbut no price fields, no option fields, no delivery timing and no description, CTA or entry-consent IDs; callgetUpsellandlistUpsellsto read those back. ItsrequiresSchedulingis deprecated: it’strueonly whenscheduleBasisisguest_selects. The title, description and CTA are translated into the organization’s other languages after the create. typeispurchase,request,postcardoradvertisement. ArequestneedsnotificationMethod(email,slackorsms); apostcardneedspostcardTemplateId.categoryisadd_ons,visits_and_tours,experiences,upgrade_your_stay,services,shop_your_stay,partnersorother.- Cover:
iconNameorimageUrl, not both. TakeiconNamefromsearchCoverIcons; an unlisted name is saved without error and then drawn blank. cta(the button label, up to 64 characters) can be set only here.descriptiontakes up to 6000 characters.- Entry consent:
requiresPermissionToEnteronly works together withasksPermissionToEnter: true. Both are forced off forpostcardandadvertisement. - Delivery timing is set with
scheduleBasis,scheduleMinNoticeHoursandscheduleWindows; see Delivery timing. - Also accepted:
isFeatured,requiresNotes,hasCustomQuantifier,quantifierLabel,advertisementExternalLink,emailNotificationText,notificationText,notificationSlackChannelId,minQuantity,maxQuantity,maxPriceCentsandfields.
Delivery timing
Section titled “Delivery timing”scheduleBasis says when a purchased upsell is delivered:
scheduleBasis | Delivery |
|---|---|
none | No date. The default on create |
check_in | The reservation’s check-in date |
check_out | The reservation’s check-out date |
guest_selects | The guest picks a slot, limited by scheduleWindows |
scheduleWindowsonly applies toguest_selects. Each window hasdaysOfWeek(0 is Sunday, 6 is Saturday),startTimeandendTimein property-local 24-hourHH:MM;endTimemust be at least 30 minutes afterstartTime. 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,nullremoves 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.
Pricing and payment
Section titled “Pricing and payment”Prices are integers in minor units of the payment account’s currency: 4500 means 45.00.
chargeType | Price |
|---|---|
free | No charge; no payment account needed |
fixed_price | basePriceCents once |
price_per_quantity | basePriceCents per unit, within minQuantity–maxQuantity |
price_per_night | basePriceCents per night |
price_per_guest | basePriceCents per guest on the reservation |
price_per_guest_per_night | basePriceCents per guest per night |
price_by_option | Set by the chosen options in fields; basePriceCents is optional |
Rules the API enforces:
purchaseandrequestneed achargeType. Any paidchargeTypeother thanprice_by_optionneedsbasePriceCentsabove zero, and so does a paidpostcard.price_by_optionneedsfields. Each field is a question with at least one choice, and each choice’spriceDeltaCentsis added tobasePriceCents; deltas may be zero or negative. With no base price, at least one choice must carry a price.fieldTypetext,numberortime(24-hourHH:MM) only validates the choice labels.maxPriceCentscaps the computed total and can’t be belowbasePriceCents.minQuantityandmaxQuantitydefault to 1 and 10. They’re checked as a pair against those defaults, sominQuantity: 20alone is rejected.
Payment accounts
Section titled “Payment accounts”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
paymentAccountIdto 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
missingPaymentAccountinlistUpsells. It’struefor 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. currencyCodeinlistUpsellscomes from the upsell’s own pricing currency, else the pinned account, else the default account, elseusd.
Choosing which properties see it
Section titled “Choosing which properties see it”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":
| Operation | Request | Effect |
|---|---|---|
listEntityScopes | GET /entity-scopes | Read the current propertyIds, propertyGroupIds, tagIds and portalSettingsIds |
updateEntityScopes | POST /entity-scopes | Replace all four sets. An omitted array means empty, so it clears that route |
addEntityScopes | POST /entity-scopes/add | Add to the existing sets |
removeEntityScopes | POST /entity-scopes/remove | Remove from the existing sets |
The write operations need modify_properties.
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.
Property-scoped callers
Section titled “Property-scoped callers”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,
listUpsellsleaves them out, andlistUpsellsByPropertyfor 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.
updateEntityScopesfrom a scoped caller replaces only what their access covers; scopes they can’t see are kept.
Updating an upsell
Section titled “Updating an upsell”PATCH /upsells/{id} takes the fields to change inside a data object. Fields you leave out keep their value.
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.
Guest copy
Section titled “Guest copy”titleanddescriptionare plain English. They overwrite the English source of the translation and are then re-translated into the organization’s other languages. Writing either needsmodify_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.
ctacan’t be changed after create.titleTextId,descriptionTextId,ctaTextIdandpermissionToEnterTextIdrelink the upsell to another existing translation. They never write text. A relink to a different ID needsmodify_guides; resending the current ID doesn’t.- For other languages, use the Translations operations on the text IDs.
Other field notes
Section titled “Other field notes”- Changing
chargeTypecan clear prices. Moving toprice_by_optionwithout sendingbasePriceCentsclears the base price. Moving toprice_by_optionorfixed_pricewithout sendingmaxPriceCentsclears the ceiling. - The pricing rules from create still apply, checked against the merged result, so switching to a paid
chargeTypemeans sendingbasePriceCentsin the same request. minQuantityandmaxQuantityrejectnull. There’s no way back to the default once set.- Setting
iconNameclearsimageUrl, and the other way round. Sending both is rejected. fieldsis the whole set, matched by ID. Sendingfieldsmakes it the upsell’s complete list; omitting it leaves the stored fields alone. A field or choice sent with itsidfromgetUpsellis edited in place; one without anidis 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 backisRequiredandsortOrder, or they reset tofalseand the item’s position in the array. On older upsellsgetUpsellcan return a field’stitleorfieldType, or a choice’stitleorpriceDeltaCents, asnull; the update requires them, so fill them in before sending the item back.isActive: falseswitches the upsell off;boostersEnabledis also only settable here.- Guard against overwriting someone else’s edit with
expectedUpdatedAt. Send it besidedata, set to theupdatedAtyou last read fromgetUpsellorlistUpsells, or that your last write returned, exactly as returned, milliseconds included. If anyone changed the upsell since, the update answers409and 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:
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 } ] } ] } }'Reordering
Section titled “Reordering”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": "…"}}.
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.
Visibility rules
Section titled “Visibility rules”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.