Skip to main content
Price tables in the admin

Catalog > Price Tables

Definition

The Analogy: A Price Table is a rate card. It bundles all the prices for one selling mode — booking, sale or subscription — into a single, editable object that can be attached to one Listing or shared across many.
Special in TWICE: A Listing never stores a price field directly. All pricing lives on Price Tables. A Listing can have one default Price Table plus any number of date-bound tables for promotions, seasonal windows or campaign pricing.A table’s availabilityRange controls when its rows are candidates — it does not give the table priority. At calculation time, rows from every table active for the booking are considered together (see Price resolution).

Where do I use it?

  • Rental rate cards — Define 1 day @ €40, 3 days @ €100, 1 week @ €180, etc.
  • Sales pricing — Set the outright sale price for a Listing, optionally with a compare-at price
  • Subscription plans — Configure commitment period, payment cycle and auto-renewal cadence
  • Promotional / seasonal pricing — Attach an extra dated Price Table whose rows are considered inside its window (a cheaper promotional row is selected over the default — see Price resolution)
  • Per-variant overrides — Adjust each pricing row by a rateMultiplier for specific variant values
  • Bulk re-pricing — Maintain one shared table and link it to many Listings in a category

Price Table model

A shared default Price Table returned by the API looks like this:
catalogItemId is null on shared tables not bound to one Listing, and availabilityRange is null on a default table that is always valid. A single Price Table holds three orthogonal sets of rows — booking, sales and subscription — gated by bookingsEnabled, salesEnabled and subscriptionsEnabled. Disable the modes you don’t sell to keep the editing surface focused.

Shared vs. standalone

Linking is managed via dedicated endpoints (link-catalog-item, unlink-catalog-items) so a Listing keeps a clean record of which shared tables apply.

Pricing row types

A Price Table contains three row types, each driving a different purchase mode.

Booking pricing rows

Used when the order is a rental booking. For rate-based pricing the engine picks the rows that best cover the booking duration (see Price resolution). Hourly, daily and weekly rows let a single Price Table cover any duration with a sensible rate.

Sales pricing rows

Used for outright sale orders.

Subscription pricing rows

Used for subscription-mode orders. See Subscriptions for the full billing lifecycle.

Per-variant pricing

If a Listing has variants, a Price Table can carry an optional variants array of CatalogItemVariantPricingTable entries. Each entry binds a variantName plus one or more values to a rateMultiplier and per-row multipliers for booking, sales and subscription rows. A CatalogItemVariantPricingTable entry has the following fields: Each per-row override references the base pricing row it adjusts (basePricingRowId) and applies a rateMultiplier (and optional additionalRateMultiplier) on top of that base row’s price. A multiplier of 1.0 matches base; 1.2 adds 20 %; 0.8 discounts by 20 %. The admin marks any combination with a CatalogItemVariantPricingTable entry as “Variant pricing” instead of “Base”.

Starting at pricing

Every booking pricing row has a timeBasis field that controls how duration is counted for billing. Two modes exist:

How “Starting at” billing works

The pricing engine identifies each calendar period the booking touches on the row’s own grid. A day unit is always midnight to midnight, an hour unit is clock-hour to clock-hour, and a week unit starts on the tenant’s configured first day of the week. Each touched period is billed as one unit — unless a finer rate covers the booked portion of that period more cheaply. The engine picks the cheapest valid composition recursively. Example — day and week rates: A 10-day booking starting Thursday, with a day rate of €50 and a week rate of €250:
  • Thursday through Sunday: 4 started days = €200
  • Monday through the following Sunday: 1 started week = €250
  • Total: €450
The week is calendar-aligned (Monday–Sunday with a Monday week start), not anchored at the pickup. Example — day and hour rates: Day rate €100, hour rate €20. A booking from Monday 09:00 to Tuesday 09:00:
  • Monday: 1 started day = €100
  • Tuesday: 1 started day = €100
  • Total: €200 (2 calendar days)
If the hour rate were €5 instead, the engine would bill 24 started hours = €120, because the finer composition is cheaper.

”Starting at” for fixed-duration packages

Fixed-duration booking rows also support timeBasis. A capped fixed package anchors the return date to the calendar period rather than to the exact pickup time. A “2 days” fixed package picked up on Monday at 14:00:
  • Elapsed (rolling): return Wednesday at 14:00 — exactly 48 hours later
  • Started (capped): return on Tuesday by the last available return time — the booking touches Monday and Tuesday, so two calendar days are covered
If the return day falls on a day the location is closed, the option is not offered for that pickup date.

Constraints

  • Sub-hour units always bill rolling. Rows using seconds or minutes are not affected by the timeBasis setting. They coexist freely in a “Starting at” table.
  • All rate-based rows on a table share one timeBasis. The admin enforces this with a single “Starting at pricing” checkbox that applies to all eligible rate-based rows. Fixed-duration rows each have their own independent timeBasis setting.
  • Week rates use the tenant’s week start day. A Saturday–Sunday booking counts as one week with a Monday start (both days are in the same Mon–Sun week), but as two weeks with a Sunday start.
Booking price section showing the Starting at pricing checkbox

Rate-based booking pricing with the Starting at pricing checkbox

Fixed-row duration options with Started and Elapsed variants

Duration options on a fixed pricing row

Price resolution

availabilityRange controls which tables are candidates for a booking — it does not give a table priority. There is no per-table priority or override field. When a price is calculated: All pricing calculations run in the Location’s timezone. Day boundaries, week boundaries, weekdays constraints, and timeOfDayRange windows are evaluated against the location’s local clock — not UTC or the customer’s timezone. A booking near local midnight is matched to the correct local day, and DST transitions are handled by calendar arithmetic in the location zone.
  1. Collect the active tables. A table is active when it has no availabilityRange, or its range overlaps the booking dates. The default table (no range) is therefore always active alongside any dated table whose window the booking falls in.
  2. Pool the rows. Every enabled row from every active table is gathered into a single candidate pool. The engine does not first pick one “winning” table — rows from different tables compete directly.
  3. Select the row(s).
    • Rate-based (dynamic) bookings — how the pool is resolved depends on the time basis of the rows in it. See Rate-based pricing for the walk in full, and Longest-fits-first vs cheapest composition below — the two bases use genuinely different algorithms, and the difference decides whether an over-priced long row can be avoided. Under both, a row’s weekdays, timeOfDayRange and availabilityRange constraints must pass before it is eligible for a segment, and any remainder falls to shorter rows and the additional-time rules.
    • Fixed-price bookings — the shopper selects a specific rate row (for example, 1 day @ €40) and that row’s price is used directly.
  4. Apply variant multipliers. If a CatalogItemVariantPricingTable entry matches the selected variant value combination, multiply the row’s contribution by the row-level multiplier (and the table-level rateMultiplier).
  5. Return totals. The endpoints …/rate-based-price and …/fixed-price return a priceBreakdown plus totalPrice, with each contribution itemised for the storefront and order summary.
A dated table does not override the default by date — it competes in the same pool. For rate-based pricing, a dated row is used only when it is the row the engine selects.Rows of the same duration are separated by price alone, so a cheaper promotional row wins inside its window, and a higher dated price (for example a holiday surcharge) is not applied while the default still carries a row of the same duration at a lower price.Example. Default table: 1 day @ €100 (no date range). Holiday table: 1 day @ €150 (24–31 Dec). A 24-hour rental on 25 Dec is priced at €100 — both rows cover the day equally, so the cheaper one wins. To charge the €150 holiday rate, remove or disable the competing €100 row for that window (or set its availabilityRange to exclude the holiday dates) so only the €150 row covers it.
There is no per-customer-tag pricing in the Price Table model today — customer-segment pricing is not configurable on a Price Table. Promotions for specific customers are handled via discount codes, not Price Tables.

Longest-fits-first vs cheapest composition

Rate-based resolution is not one algorithm. Which one runs is decided by the time basis of the rate-based rows in the pool. Elapsed duration (rolling, the default) — longest-fits-first. The engine walks the booking from its start. At each step it takes the longest row that still fits the remaining time, advances by that row’s duration, and repeats. Price is only a tiebreak between rows of equal duration. The engine never compares a long row against a composition of shorter ones, so:
  • A week row wins a seven-day booking whatever it costs — even priced above seven day-rows.
  • Two 1-day rows at €100 and €150 resolve to €100, because they are the same duration.
  • A 1-day row at €150 and a 12-hour row at €10 resolve to the day row, because it is longer.
Starting at (capped) — cheapest composition. The engine works on calendar periods instead. For each period the booking touches, it bills either one unit of that period or the cheaper finer composition of the booked portion — whichever costs less — and it compares those options recursively across every granularity on the table. Here price does decide across durations: a week row priced above seven started days loses to the day rows.
A table’s rate-based rows all share one time basis, but rows from different tables are pooled. If any row in the pool uses Starting at, the whole booking is resolved by the calendar-period engine, with the elapsed rows competing against it on the un-snapped range.
The practical consequence: on the default elapsed basis, an over-priced long row is not “priced out” — it is preferred. Tiering only produces a discount because you price the longer block lower, not because the engine shops for the cheapest total.

Applying tables to Listings and Collections

A shared Price Table is linked to a Listing through dedicated endpoints:
  • POST /pricing-tables/pricing-table/:pricingTableId/link-catalog-item — link one Listing
  • DELETE /pricing-tables/pricing-table/:pricingTableId/unlink-catalog-items — bulk unlink
  • POST /pricing-tables/add-pricing-table-to-catalog-item — discriminated union for the four linking modes:
    • mode = 'link' — link to an existing shared table
    • mode = 'create' — create a new standalone table inline
    • mode = 'sharedToShared' — replace one shared link with another
    • mode = 'sharedToStandalone' — fork a shared table into a standalone one on the Listing
    • mode = 'standaloneToShared' / 'standaloneUpdate' — flip ownership or rename in place
Collections are not directly linkable to a Price Table. To re-price a Collection, iterate its listCatalogItems and link the table to each Listing.

Key Properties (Price Table)

Relationships

A shared Price Table can be linked from many Listings. A standalone Price Table exists on a single Listing.
Each table is a container for the three orthogonal pricing modes. Toggle modes on/off without rebuilding the table.
Per-variant pricing entries reference variant values on the Listing — see the Variant concept.
The storefront and order builder pick the correct row by purchase mode (booking / sale / subscription) and call the …/rate-based-price or …/fixed-price endpoint to compute the total.

Lifecycle

1

Create

POST /pricing-tables/ with label and (for shared tables) isDefault: true. New tables start with empty row arrays.
Use standalone when the pricing is unique to one Listing. Use shared when you want to re-use the same rate card across a category of Listings.
No. Only tables with availabilityRange = null can be isDefault = true (enforced by the admin UI; the model permits the flag but the link logic skips dated tables when resolving the default).
2

Add rows

Add booking / sales / subscription rows via the per-row endpoints (/pricing-table/:id/row, …/sales-row, …/subscription-row). Add per-variant overrides via /pricing-table/:id/variant-pricing-table.
3

Link to a Listing

Use add-pricing-table-to-catalog-item with the right mode. The Pricing tab on a Listing shows linked tables in a sidebar and lets you create new ones inline.
4

Override per variant

Add a CatalogItemVariantPricingTable block for the variant values that need different pricing.
5

Promote or replace

To roll out a price change without breaking active orders, add a new dated table for the window. Inside that window its rows compete with the default in the same pool — a cheaper row is selected automatically; to apply a higher rate, also remove or date-scope the competing default row (see Price resolution).Existing orders keep the price they captured at checkout.
6

Delete

DELETE /pricing-tables/delete-table/:pricingTableId. Removing a shared table unlinks it from every Listing.

Frequently asked questions

Yes. You can attach a default table plus any number of dated tables for promotions or seasonal windows. At order time, rows from every table active for the booking are pooled together and the engine selects from that pool — see Price resolution. availabilityRange decides which tables are candidates, not which one wins.
From the booking pricing rows that cover the requested duration. On the default Elapsed duration basis the engine takes the longest row that fits the remaining time, then applies additional-time rules for the remainder. On Starting at it bills the calendar periods the booking touches. See Longest-fits-first vs cheapest composition.
Create a dated Price Table with availabilityRange covering the weekend, with rows priced 20 % below the default, and link it to the affected Listings. Because the promotional rows are cheaper and cover the same durations, the engine selects them inside the window and falls back to the default afterwards.This works precisely because the promo is cheaper — see the next question for higher/surcharge pricing.
Because the engine pools all active tables and does not prefer dated ones. Where two rows have the same duration, price is the only thing separating them, so the default’s cheaper row wins and the surcharge never gets selected.To apply a surcharge, remove or disable the competing default row for that window, or set the default row’s availabilityRange to exclude those dates, so only the higher row covers them.
Rolling (default) counts elapsed time from pickup. A “day” is 24 hours. A booking from Monday 14:00 to Wednesday 14:00 bills exactly 2 days.Starting at (capped) counts every calendar period the booking touches. A “day” is midnight to midnight. The same Monday 14:00 to Wednesday 14:00 booking touches Monday, Tuesday, and Wednesday — 3 started days.Use “Starting at” when you charge per calendar day (like parking garages or hotel nights). Use rolling when you charge for exact elapsed time (like hourly equipment rental).
Not via Price Tables. The Price Table model has no customer-segment dimension today. Use discount codes for per-customer pricing.
The Location’s timezone. Day and week boundaries, weekday restrictions, and time-of-day windows are resolved in the location’s local clock. If no location timezone is set, the tenant-level timezone is used, with UTC as a last resort.The timezone is resolved from the selected pickup location (or, when no single location is selected, from the listing’s primary linked location).
No. Orders capture the price contributions at checkout. Editing a Price Table affects future order totals only.
priceContribution is what one row added to the total; totalPrice is the sum across all rows the engine selected (base row + additional-time rows + variant multipliers).

Developer Reference

Price tables are exposed as pricing-tables in the API.

API: Pricing Tables

Open the endpoint in the API reference.

Listings

What a Price Table is attached to

Variants

Per-variant pricing overrides

Collections

Group Listings before bulk-applying a shared table

Order lifecycle

Where Price Table totals are captured at checkout