> ## 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, guides, and templates through the Conduit API.

<Warning>
  Portal API methods require the **Portals** add-on. Without it, each method returns `403 Forbidden`. Contact [support@conduit.ai](mailto:support@conduit.ai) to enable it.
</Warning>

## Overview

A portal is the page that a guest opens before a stay. Its content comes from workspace configuration that you manage through API methods:

<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>

  <Card title="Guides" icon="book-open">
    Published guest information that can appear in matching portals.
  </Card>

  <Card title="Brand kit" icon="palette">
    Workspace branding defaults for future portal sessions.
  </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.                                                                             |
| Path        | Every method uses `workspace_id`.                                                                                              |
| Concurrency | Update and lifecycle methods take `expected_updated_at` or `expected_version`. Reorder items include their current timestamps. |

<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

Call [Create Verification Rule](/api-reference/portals/verification-rules/create) to add arrival details to every stay. Omit `targeting` to match every stay:

```json theme={null}
{
  "name": "Arrival details",
  "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",
  "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](/api-reference/portals/verification-rules/reorder) takes every rule in the workspace. Put the rules in the required order and include each `expected_updated_at` value. The API rejects partial lists:

```json theme={null}
{
  "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 }
  ]
}
```

Partial lists are rejected because accepting one would renumber omitted rules. This can put a specific rule after a general rule. Send the whole list.

### Editing and deleting

[Update Verification Rule](/api-reference/portals/verification-rules/update) cannot change `step_type`. The supplied `config` must match the existing step type. Edit uploaded agreement and waiver documents in the app.

[Delete Verification Rule](/api-reference/portals/verification-rules/delete) is permanent. Existing stays keep collected data, but new stays do not receive the step.

<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` controls the behavior. `purchase` takes payment, `request` does not charge, and `ad` opens `link_url`.

### Flat pricing

Call [Create Portal Offer](/api-reference/portals/offers/create) to create a purchasable or requestable offer:

```json theme={null}
{
  "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}
{
  "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](/api-reference/portals/offers/archive) deactivates an offer. It also removes the offer from each template layout.

[Restore Portal Offer](/api-reference/portals/offers/restore) reactivates the offer. It does not restore the removed layout blocks.

## Portal templates

A template defines the portal page. It contains branding, a header, an ordered `layout`, and checkout behavior.

### 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 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

Call [Create Portal Template](/api-reference/portals/templates/create) with the portal configuration:

```json theme={null}
{
  "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"
  }
}
```

Colors are six-character hexadecimal values, with or without the leading `#`.

### The rest of the config

`listing_data` controls property data on the page. Each field has a Boolean value, and `enabled` controls all fields.

`after_payment` controls the post-purchase state. `confirmation_message` controls the follow-up message. `smart_lock` enables the lock integration.

### Duplicating, archiving, deleting

[Duplicate Portal Template](/api-reference/portals/templates/duplicate) creates a new draft. [Archive Portal Template](/api-reference/portals/templates/archive) and [Restore Portal Template](/api-reference/portals/templates/restore) are reversible.

[Delete Portal Template](/api-reference/portals/templates/delete) is permanent. It requires an exact `confirm_name` and a template with no recorded sales.

## Portal guides

Portal guides provide guest information in published portal sessions. Published guide changes reach matching customer portals immediately.

Use the [portal guide methods](/api-reference/portals/guides/list) to create, publish, duplicate, archive, restore, and reorder guides.

## Brand kit

The portal brand kit supplies the workspace defaults for new portal sessions. Existing sessions keep their saved branding snapshot.

Use [Get Portal Brand Kit](/api-reference/portals/brand-kit/get) and [Update Portal Brand Kit](/api-reference/portals/brand-kit/update) to manage these defaults.

## End to end

Building a working portal with a deposit on long stays:

<Steps>
  <Step title="Create the offers">
    Use [Create Portal Offer](/api-reference/portals/offers/create) for each item. Keep each returned `id`.
  </Step>

  <Step title="Create the verification rules">
    Use [Create Verification Rule](/api-reference/portals/verification-rules/create) for the deposit and arrival details. New rules are enabled and appended.
  </Step>

  <Step title="Fix the order">
    Use [Reorder Verification Rules](/api-reference/portals/verification-rules/reorder). Put specific rules above general rules.
  </Step>

  <Step title="Build the template">
    Use [Create Portal Template](/api-reference/portals/templates/create). Reference the offer IDs from step 1 in `layout`.
  </Step>

  <Step title="Activate">
    Use [Update Portal Template](/api-reference/portals/templates/update) with `status: "active"` and the current `expected_version`.
  </Step>
</Steps>

## API methods

| Resource           | Read methods                                                                                                 | Write methods                                                                                                                                                                                                                                                                                                              |
| ------------------ | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Verification rules | [List](/api-reference/portals/verification-rules/list), [Get](/api-reference/portals/verification-rules/get) | [Create](/api-reference/portals/verification-rules/create), [Update](/api-reference/portals/verification-rules/update), [Delete](/api-reference/portals/verification-rules/delete), [Reorder](/api-reference/portals/verification-rules/reorder)                                                                           |
| Offers             | [List](/api-reference/portals/offers/list), [Get](/api-reference/portals/offers/get)                         | [Create](/api-reference/portals/offers/create), [Update](/api-reference/portals/offers/update), [Archive](/api-reference/portals/offers/archive), [Restore](/api-reference/portals/offers/restore)                                                                                                                         |
| Templates          | [List](/api-reference/portals/templates/list), [Get](/api-reference/portals/templates/get)                   | [Create](/api-reference/portals/templates/create), [Update](/api-reference/portals/templates/update), [Duplicate](/api-reference/portals/templates/duplicate), [Archive](/api-reference/portals/templates/archive), [Restore](/api-reference/portals/templates/restore), [Delete](/api-reference/portals/templates/delete) |
| Guides             | [List](/api-reference/portals/guides/list), [Get](/api-reference/portals/guides/get)                         | [Create](/api-reference/portals/guides/create), [Update](/api-reference/portals/guides/update), [Duplicate](/api-reference/portals/guides/duplicate), [Archive](/api-reference/portals/guides/archive), [Restore](/api-reference/portals/guides/restore), [Reorder](/api-reference/portals/guides/reorder)                 |
| Brand kit          | [Get](/api-reference/portals/brand-kit/get)                                                                  | [Update](/api-reference/portals/brand-kit/update)                                                                                                                                                                                                                                                                          |

## Errors

| Status | Cause                                                        |
| ------ | ------------------------------------------------------------ |
| `400`  | The request or configuration is invalid.                     |
| `401`  | The API token is missing or invalid.                         |
| `403`  | Portals are not enabled, or the token user lacks permission. |
| `404`  | The resource is not in this workspace.                       |
| `409`  | `expected_updated_at` or `expected_version` is stale.        |
| `429`  | The request exceeded a rate limit.                           |

Read the current value before you retry a `409` response. A retry with the same lock value fails again.
