> ## Documentation Index
> Fetch the complete documentation index at: https://docs.conduit.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Portals

> Build portal verification rules, offers, and templates over MCP.

<Warning>
  Portal tools require the **Portals** add-on. Without it every portal tool returns a `forbidden` error. Contact [support@conduit.ai](mailto:support@conduit.ai) to enable it.
</Warning>

## Overview

A portal is the page someone gets a link to before their stay. What they see there comes from three pieces of workspace configuration, each with its own set of MCP tools:

<CardGroup cols={3}>
  <Card title="Verification rules" icon="shield-check">
    What has to be collected or signed before check-in. Deposits, agreements, arrival details, questions.
  </Card>

  <Card title="Offers" icon="tag">
    The things that can be bought or requested. Late checkout, mid-stay clean, airport pickup.
  </Card>

  <Card title="Templates" icon="layout">
    The page itself: branding, layout blocks, and which offers appear where.
  </Card>
</CardGroup>

These are independent. You can ship verification rules without ever building a template, and offers exist on their own until a template's layout references them.

### Before you start

| Requirement | Detail                                                                                   |
| ----------- | ---------------------------------------------------------------------------------------- |
| Token       | Read/write for any create, patch, delete, or reorder. Read-only tokens can list and get. |
| Permission  | `View Upsells` to read, `Manage Upsells` to write.                                       |
| Argument    | Every tool takes `workspace_id`.                                                         |
| Concurrency | Every write takes `expected_updated_at` (or `expected_version` on templates).            |

<Note>
  All timestamps are epoch milliseconds. All money is whole cents (`25000` is \$250.00) paired with a lowercase currency code.
</Note>

## Verification rules

A verification rule says "for stays that look like this, collect this before check-in." Each rule has a **step type** (what to collect), a **config** (how to present it), and optional **targeting** (which stays it applies to).

### Step types

| `step_type`            | Slot            | Collects                                    |
| ---------------------- | --------------- | ------------------------------------------- |
| `id_check`             | `identity`      | An identity document.                       |
| `agreement`            | `agreement`     | A signature on a document. Never priced.    |
| `damage_waiver`        | `signature`     | A signature on a waiver, optionally priced. |
| `security_deposit`     | `deposit`       | A refundable hold.                          |
| `mandatory_fee`        | `fee`           | A non-refundable charge.                    |
| `guest_details`        | `guest_details` | Email, phone, arrival and departure times.  |
| `additional_questions` | `questions`     | Free-form questions you define.             |
| `other_guests`         | `other_guests`  | Details for everyone else on the booking.   |

### The rule that governs everything: one rule per slot

Rules are evaluated in `order`. For each slot, **the first enabled rule whose targeting matches the stay wins, and every later rule for that slot is skipped.**

This is what makes ordering meaningful. A specific rule has to sit above the general one, or it never fires:

```
order 1  Deposit, stays of 7+ nights, $500     <- matches a 10-night stay, claims "deposit"
order 2  Deposit, every stay, $250             <- skipped for that stay, applies to the rest
```

Flip those two and the \$500 rule becomes dead configuration: the catch-all claims the slot first, every time. Nothing errors, the specific rule just never applies.

Two rules with **different** step types that share a slot compete the same way. `damage_waiver` and `agreement` do not, they sit in different slots and both apply.

### Creating a rule

Start with the simplest useful one, arrival details on every stay. Omitting `targeting` means "every stay":

```json theme={null}
{
  "name": "Arrival details",
  "workspace_id": "ws_123",
  "step_type": "guest_details",
  "config": {
    "title": "When are you arriving?",
    "collectEmail": true,
    "collectPhone": true,
    "collectEta": true,
    "collectEtd": false,
    "requiredFields": ["phone", "eta"]
  }
}
```

`requiredFields` must be a subset of what you collect. Requiring `eta` while `collectEta` is `false` is rejected.

Now a targeted, priced one. This is where the ordering rule starts to matter:

```json theme={null}
{
  "name": "Deposit on long stays",
  "workspace_id": "ws_123",
  "step_type": "security_deposit",
  "config": {
    "title": "Security deposit",
    "instructions": "Released 7 days after checkout.",
    "amountInCents": 50000,
    "currency": "usd",
    "releaseDelayDays": 7
  },
  "targeting": {
    "minNights": 7,
    "bookingChannelMode": "exclude",
    "bookingChannels": ["airbnb"]
  }
}
```

New rules are appended last and enabled by default. Since this one is more specific than a catch-all deposit, move it up (see [Reordering](#reordering)).

<Accordion title="The other five step types">
  **Agreement.** A document to sign, with no charge attached. Pricing an agreement is rejected; use `damage_waiver` instead.

  ```json theme={null}
  {
    "step_type": "agreement",
    "config": {
      "title": "House rules",
      "body": "Quiet hours are 10 PM to 8 AM. No smoking anywhere on the property."
    }
  }
  ```

  **Damage waiver.** A signature that can carry a price. `model` is `flat` or `per_night`; `capInCents` bounds a per-night total and must be at least `amountInCents`.

  ```json theme={null}
  {
    "step_type": "damage_waiver",
    "config": {
      "title": "Damage waiver",
      "body": "Covers accidental damage up to $1,000.",
      "pricing": {
        "model": "per_night",
        "amountInCents": 1200,
        "currency": "usd",
        "capInCents": 8400
      }
    }
  }
  ```

  **Mandatory fee.** Same shape as a deposit but non-refundable, so no `releaseDelayDays`.

  ```json theme={null}
  {
    "step_type": "mandatory_fee",
    "config": {
      "title": "Resort fee",
      "instructions": "Covers pool and gym access.",
      "amountInCents": 3500,
      "currency": "usd"
    }
  }
  ```

  **Additional questions.** Question `id`s must be unique within the rule. `single_select` and `multi_select` need at least two options; `text`, `long_text`, and `checkbox` take none.

  ```json theme={null}
  {
    "step_type": "additional_questions",
    "config": {
      "title": "A few questions",
      "questions": [
        {
          "id": "purpose",
          "label": "What brings you here?",
          "type": "single_select",
          "required": true,
          "options": ["Holiday", "Work", "Family"]
        },
        {
          "id": "plate",
          "label": "Vehicle plate, if you are driving",
          "type": "text",
          "required": false
        }
      ]
    }
  }
  ```

  **Other guests.** Details for everyone else on the booking. Set `minGuests`, `maxGuests`, or both. `maxGuests` caps at 50 and `fields` caps at 20 entries, each with a unique `id`.

  ```json theme={null}
  {
    "step_type": "other_guests",
    "config": {
      "title": "Who else is staying?",
      "maxGuests": 6,
      "fields": [
        { "id": "full_name", "label": "Full name", "type": "text", "required": true },
        { "id": "photo_id", "label": "Photo ID", "type": "image", "required": false }
      ]
    }
  }
  ```

  **ID check.** Collects an identity document. Takes `title` and `instructions`, plus optional `requireContactPhoto` and `autoApprove`.
</Accordion>

### Targeting

Every criterion you set is ANDed. An empty or omitted `targeting` matches every stay.

| Field                                    | Effect                                                                                     |
| ---------------------------------------- | ------------------------------------------------------------------------------------------ |
| `minNights` / `maxNights`                | Stay length bounds. `minNights` cannot exceed `maxNights`.                                 |
| `bookingChannels` + `bookingChannelMode` | `include` or `exclude` the listed channels. Setting a mode requires at least one channel.  |
| `maxBookingLeadDays`                     | Only bookings made within this many days of arrival.                                       |
| `matchUnknownBookingLeadTime`            | Also match stays with no known booking date. Requires `maxBookingLeadDays`.                |
| `listingIds` / `listingGroupIds`         | One location filter: the stay passes if its listing is listed **or** one of its groups is. |

`exclude_returning_guests` sits alongside `targeting` and drops the rule for anyone who has stayed before. Useful for ID checks you only want to run once per person.

### Reordering

`reorder_verification_rules` takes **every** rule in the workspace, in the order you want, each with its `expected_updated_at`. Partial lists are rejected:

```json theme={null}
{
  "workspace_id": "ws_123",
  "items": [
    { "id": "vr_long_stay_deposit", "expected_updated_at": 1730000000000 },
    { "id": "vr_standard_deposit", "expected_updated_at": 1730000000000 },
    { "id": "vr_arrival_details", "expected_updated_at": 1730000000000 }
  ]
}
```

Omitting a rule would silently renumber the ones you left out, which is exactly how a specific rule ends up buried under a catch-all. Send the whole list.

### Editing and deleting

`patch_verification_rule` cannot change `step_type`. A `config` you send has to match the type the rule already has, or you get `invalid_input`. An `agreement` or `damage_waiver` rule whose document was uploaded in the app rejects a `config` for the same reason: edit its PDF in the app. `delete_verification_rule` is permanent: stays already collecting that step keep what they have gathered, new stays stop being asked.

<Note>
  Priced rules (`security_deposit`, `mandatory_fee`, and `damage_waiver` with a non-zero price) need Stripe connected before they can be enabled, but only once the workspace has an **active** template. Until then you can build them freely.
</Note>

## Portal offers

An offer is one purchasable or requestable thing. Offers are workspace-level and reusable: create one, reference it from as many template layouts as you like.

`kind` decides the behaviour: `purchase` takes payment, `request` asks without charging, `ad` just links out via `link_url`.

### Flat pricing

```json theme={null}
{
  "workspace_id": "ws_123",
  "name": "Late checkout",
  "description": "Keep the room until 2 PM.",
  "price_in_cents": 3500,
  "kind": "purchase",
  "highlights": ["Two extra hours", "Subject to availability"]
}
```

### Priced by option

When one offer has variants, use `by_option` pricing. `fieldLabel` is what the chooser is called, and each option carries its own price:

```json theme={null}
{
  "workspace_id": "ws_123",
  "name": "Airport pickup",
  "description": "Meet and greet at arrivals.",
  "price_in_cents": 6000,
  "kind": "purchase",
  "pricing": {
    "model": "by_option",
    "fieldLabel": "Vehicle",
    "options": [
      { "title": "Sedan, up to 3 bags", "priceInCents": 6000 },
      { "title": "Van, up to 7 bags", "priceInCents": 9500 }
    ]
  }
}
```

`by_option` requires both `fieldLabel` and `options`. Omitting either is rejected.

### Pricing off the booking total

`percent_price` charges a percentage of a booking figure instead of a fixed amount:

```json theme={null}
{
  "percent_price": { "percent": 10, "of": "totalPrice" }
}
```

`of` accepts `totalPrice`, `grossRent`, `netRent`, `subtotal`, `totalFees`, or `totalTaxes`.

### Targeting and availability

Offer `targeting` takes `listingIds`, `listingGroupIds`, `inboxTypeIds` (with `inboxMode`), night bounds, vacancy windows (`minVacantNightsBeforeArrival`, `minVacantNightsAfterDeparture`), and `customAttributeConditions` combined with `all` or `any`. Early checkin, for instance, only makes sense when the night before is free:

```json theme={null}
{
  "targeting": { "minVacantNightsBeforeArrival": 1 }
}
```

`availability` bounds when the offer is visible and bookable using `visibleFrom`, `visibleUntil`, `bookableFrom`, and `bookableUntil`.

### Archiving

`archive_portal_offer` deactivates an offer **and removes it from every template layout that referenced it**. `restore_portal_offer` reactivates it but does not put it back into those layouts; re-add the blocks yourself.

## Portal templates

A template is the page. It carries branding, a header, an ordered `layout`, and checkout behaviour.

### Status and versioning

Templates are `draft`, `active`, or `paused` (plus `archived`). A new template starts as `draft` unless you say otherwise. Templates use `expected_version`, an integer, rather than a timestamp.

<Warning>
  Editing a template does **not** change portals already sent. The config is snapshotted onto each portal when it is created, so someone mid-checkout keeps the page they started on. The same is true of guides and the brand kit.
</Warning>

### Layout blocks

`layout` is an ordered array. Every block needs a unique `id` you choose.

| `type`               | Purpose                        | Key fields                                     |
| -------------------- | ------------------------------ | ---------------------------------------------- |
| `header`             | Renders the configured header. | —                                              |
| `offerHero`          | One offer, full width.         | `offerId`, `badgeLabel`, `headline`, `tagline` |
| `offer`              | One offer, standard card.      | `offerId`, `badge`                             |
| `offerGrid`          | Several offers in a grid.      | `offerIds`, `columns` (`1` or `2`)             |
| `bundle`             | Offers sold together.          | `offerIds`, `label`, `bundlePriceInCents`      |
| `text`               | Copy.                          | `body`, `variant` (`section` or `body`)        |
| `trustStrip`         | Reassurance row.               | `items`                                        |
| `spacer`             | Vertical space.                | `size` (`sm`, `md`, `lg`)                      |
| `image`              | A picture.                     | `url`, `alt`, `aspectRatio`                    |
| `formInput`          | Collect a value.               | `name`, `label`, `required`, `inputType`       |
| `paymentPlaceholder` | Where checkout renders.        | `label`                                        |

`trustStrip` items are `secure_checkout`, `free_cancellation`, `verified_business`, and `powered_by_stripe`.

Every `offerId` and `offerIds` entry has to be a real offer, so **create offers first**.

### Building one

```json theme={null}
{
  "workspace_id": "ws_123",
  "name": "Arrival portal",
  "status": "draft",
  "branding": {
    "brand_color": "#1F6F5C",
    "accent_color": "#E8B44A",
    "theme": "light"
  },
  "header": {
    "business_name": "Seabright Rentals",
    "context_line": "Your stay starts soon",
    "hero_source": "listing",
    "show_trust_badge": true
  },
  "layout": [
    { "id": "b1", "type": "header" },
    {
      "id": "b2",
      "type": "offerHero",
      "offerId": "off_late_checkout",
      "headline": "Stay a little longer",
      "tagline": "Check out at 2 PM instead of 10 AM."
    },
    { "id": "b3", "type": "text", "variant": "section", "body": "Add to your stay" },
    {
      "id": "b4",
      "type": "offerGrid",
      "offerIds": ["off_airport_pickup", "off_midstay_clean"],
      "columns": 2
    },
    { "id": "b5", "type": "paymentPlaceholder" },
    {
      "id": "b6",
      "type": "trustStrip",
      "items": ["secure_checkout", "powered_by_stripe"]
    }
  ],
  "checkout": {
    "allow_cart": true,
    "payment_methods": ["card", "apple_pay"],
    "cta_label": "Confirm and pay"
  }
}
```

Colours are six-character hex, with or without the leading `#`.

### The rest of the config

`listing_data` toggles property facts onto the page (`address`, `wifi`, `parking`, `doorCode`, `checkInInstructions`, `houseRules`, and friends), each an independent boolean under `fields` with an `enabled` master switch. `after_payment` sets what happens post-purchase (`mode` of `stay` or `full_screen`, plus `message` and `show_receipt`). `confirmation_message` controls the follow-up message. `smart_lock` enables lock integration.

### Duplicating, archiving, deleting

`duplicate_portal_template` copies to a new draft, which is the safe way to revise an active portal. `archive_portal_template` and `restore_portal_template` are reversible. `delete_portal_template` is not: it requires `confirm_name` to match the template's name exactly, only works when the template has no recorded sales, and deletes the portals created from it.

## End to end

Building a working portal with a deposit on long stays:

<Steps>
  <Step title="Create the offers">
    `create_portal_offer` for each thing being sold. Keep the returned `id` values.
  </Step>

  <Step title="Create the verification rules">
    `create_verification_rule` for the deposit and the arrival details. Both land at the end of the list, enabled.
  </Step>

  <Step title="Fix the order">
    `reorder_verification_rules` with every rule, specific above general, so the long-stay deposit sits above any catch-all.
  </Step>

  <Step title="Build the template">
    `create_portal_template` with a `layout` referencing the offer IDs from step 1.
  </Step>

  <Step title="Activate">
    `patch_portal_template` with `status: "active"` and the template's current `expected_version`.
  </Step>
</Steps>

A prompt that drives the whole thing:

```text theme={null}
In workspace ws_123, create a late checkout offer at $35 and an airport pickup
offer with sedan and van options. Then add a $500 security deposit rule for
stays of 7 nights or more, and an arrival details rule asking for phone and ETA
on every stay. Put the deposit above any existing catch-all deposit. Finally
build a draft template called "Arrival portal" with the late checkout as the
hero and the pickup in a grid below it.
```

## Tool reference

| Tool                                                                                                                                                           | Permission            |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
| `list_verification_rules`, `get_verification_rule`                                                                                                             | View Upsells          |
| `create_verification_rule`, `patch_verification_rule`, `delete_verification_rule`, `reorder_verification_rules`                                                | Manage Upsells        |
| `list_portal_offer_catalog`, `get_portal_offer`                                                                                                                | View Upsells          |
| `create_portal_offer`, `patch_portal_offer`, `archive_portal_offer`, `restore_portal_offer`                                                                    | Manage Upsells        |
| `list_portal_template_catalog`, `get_portal_template`                                                                                                          | View Upsells          |
| `create_portal_template`, `patch_portal_template`, `duplicate_portal_template`, `archive_portal_template`, `restore_portal_template`, `delete_portal_template` | Manage Upsells        |
| `list_portal_guides`, `get_portal_guide`                                                                                                                       | View Upsells          |
| `create_portal_guide`, `patch_portal_guide`, `duplicate_portal_guide`, `archive_portal_guide`, `restore_portal_guide`, `reorder_portal_guides`                 | Manage Upsells        |
| `get_portal_brand_kit`, `patch_portal_brand_kit`                                                                                                               | View / Manage Upsells |

`get_portal`, `update_portal`, `list_portal_offers`, `get_customer_portals`, and `get_checkin_status` are runtime tools that serve a portal to a specific person, rather than configuration tools. They read and write one live portal, not the workspace setup described here.

## Errors

| Code            | Cause                                                                                           |
| --------------- | ----------------------------------------------------------------------------------------------- |
| `forbidden`     | Portals not enabled, or the token's user lacks the permission.                                  |
| `conflict`      | `expected_updated_at` or `expected_version` is stale. Re-read and retry.                        |
| `invalid_input` | Config does not fit the step type, a validation rule failed, or a referenced ID does not exist. |
| `not_found`     | The resource is not in this workspace.                                                          |

Read the current value back before retrying a `conflict`. Both locks are checked against the stored record, so a blind retry with the same value fails the same way.
