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

# Build a Booking Integration

> List experiences, pick a session, take payment, and create a booking - the complete server-to-server checkout flow

This guide walks through a complete booking integration against the Way API: from showing a brand's experiences to charging a card and confirming the booking. By the end you will have executed every call in this sequence:

1. [Get brand settings](/api-reference/brand-configuration/get-brand-settings) → currency, payment methods, payment platform
2. [Get all listings](/api-reference/listings/get-listings) → what can be booked
3. [Get listing sessions](/api-reference/listings/get-listing-sessions) → when it can be booked, for how much
4. [Get custom questions](/api-reference/experiences/get-custom-questions) → what to ask the guest
5. [Create a payment intent](/api-reference/carts-and-checkout/create-payment-intent) → Stripe client secret
6. [Create a booking](/api-reference/carts-and-checkout/create-booking) → the booking (pending payment)
7. Confirm the payment with Stripe → Way finalizes the booking

<Info>
  **Way never touches the card.** Card data goes directly to Stripe from your checkout; Way only ever sees a Stripe *client secret* and reconciles the result server-side. As long as you use Stripe Elements (recommended below), your integration stays out of PCI-DSS SAQ-D scope.
</Info>

All examples run against **staging** (`https://api.staging.letsway.com`) with these placeholder credentials - substitute your own from the [dashboard](/quickstart#create-an-api-key):

```bash theme={null}
export WAY_API="https://api.staging.letsway.com"
export WAY_BRAND_ID="1b6e4a2d-8c3f-4e9a-b5d7-2f8c6a4e0d19"
export WAY_API_KEY="<your staging secret key, way_sk_test_...>"
```

<Steps>
  <Step title="Authenticate and read the brand configuration">
    Start with [Get brand settings](/api-reference/brand-configuration/get-brand-settings) - it doubles as an auth smoke test and tells you three things you need later: the brand's **currency**, its enabled **payment methods**, and its **payment platform** (US vs EU Stripe - this decides which Stripe publishable key you use in step 8).

    ```bash theme={null}
    curl "$WAY_API/v1/brands/$WAY_BRAND_ID/settings" \
      --header "Authorization: Bearer $WAY_API_KEY"
    ```

    ```json theme={null}
    {
      "data": {
        "currency": "USD",
        "paymentPlatform": "stripe-us",
        "paymentMethods": { "card": true, "apple_pay": true, "google_pay": true },
        "products": ["activate"]
      }
    }
    ```

    <Note>
      **You'll need a Stripe publishable key from Way** to initialize Stripe.js in step 8 - ask your Way representative during onboarding; there is no API call to fetch it. This is **Way's** key, not one from your own Stripe account: payments are created on Way's Stripe platform, so a key from your own account will not work. Keys are specific to the environment (staging vs production) and to your brand's `paymentPlatform` (`stripe-us` vs `stripe-eu`) - for this guide, ask for the **staging** key.
    </Note>
  </Step>

  <Step title="Pick a listing and detect its type">
    Fetch the brand's listings with [Get all listings](/api-reference/listings/get-listings) and show them to your user. When one is selected, load its full detail with [Get a listing](/api-reference/listings/get-listing).

    ```bash theme={null}
    curl "$WAY_API/v3/listings/1f4877f4-a082-4a05-9cbf-fa129b28f01c" \
      --header "Authorization: Bearer $WAY_API_KEY"
    ```

    The `kind` field decides how the rest of the flow branches. The **same endpoints serve both kinds** - only a few request fields differ, called out at each step below.

    |                     | Time-based experience                                    | Resource-group collection                                   |
    | ------------------- | -------------------------------------------------------- | ----------------------------------------------------------- |
    | `kind`              | `experience`                                             | `resource`                                                  |
    | Detect from listing | `resourceGroupCollectionId` null, `resourceGroups` empty | `resourceGroupCollectionId` set, `resourceGroups` populated |
    | Example             | A 90-minute sailing tour with guests                     | Booking 2 cabanas for a day                                 |

    From the listing detail, keep the `experienceId` and (for `resource` listings) the `resourceGroups[].id` values - you need them for availability and booking.

    <Note>
      For `resource` listings the listing-level `experienceId` is `null` - each session returned in the next step carries the `experienceId` to book with.
    </Note>
  </Step>

  <Step title="Choose a session and compute the price">
    Get availability for the listing with [Get listing sessions](/api-reference/listings/get-listing-sessions):

    <Tabs>
      <Tab title="experience (time-based)">
        ```bash theme={null}
        curl "$WAY_API/v1/listings/1f4877f4-a082-4a05-9cbf-fa129b28f01c/sessions?from=2026-08-15&to=2026-08-22" \
          --header "Authorization: Bearer $WAY_API_KEY"
        ```
      </Tab>

      <Tab title="resource (collection)">
        Query availability **per resource group** by adding `resourceGroupId`:

        ```bash theme={null}
        curl "$WAY_API/v1/listings/9c2e5f7a-3d1b-4a8c-b6e4-5a9d2c8f1e37/sessions?from=2026-08-15&to=2026-08-22&resourceGroupId=4a7d1e9b-2c5f-4b3a-8e6d-1f9a5c3b7e28" \
          --header "Authorization: Bearer $WAY_API_KEY"
        ```
      </Tab>
    </Tabs>

    The response is a `data.sessions` array:

    ```json theme={null}
    {
      "data": {
        "sessions": [
          {
            "startDateTime": "2026-08-15T17:00:00",
            "duration": 90,
            "capacity": 10,
            "participantCount": 0,
            "status": "OPEN",
            "experienceId": "e7a3b8d1-4c2f-4b6e-9a5d-8f1c3e7b2a64",
            "priceTiers": [
              { "id": "6c59db13-2c63-43b9-b91b-6267544f5fdb", "name": "Adult", "price": 89, "maxQuantity": 10 }
            ]
          }
        ]
      }
    }
    ```

    Record the guest's chosen `startDateTime` (this becomes `sessionTime` in the booking payload), `duration` (becomes `sessionDuration`), and a price tier **name** per participant - the booking payload references tiers by name.

    For richer date-picker UX (month views, first-available shortcuts), see the [Availability endpoints](/api-reference/availability/get-dates-with-sessions).
  </Step>

  <Step title="Collect required booking information">
    Experiences can define extra questions that must be answered at booking time. Fetch them with [Get custom questions](/api-reference/experiences/get-custom-questions):

    ```bash theme={null}
    curl "$WAY_API/v1/brands/$WAY_BRAND_ID/experiences/e7a3b8d1-4c2f-4b6e-9a5d-8f1c3e7b2a64/custom-questions" \
      --header "Authorization: Bearer $WAY_API_KEY"
    ```

    An empty `items` array means there is nothing extra to ask. Render any questions in your checkout form and collect the answers keyed by `questionId`.

    **Answers travel inside the booking call** (step 7) - there is no separate save endpoint. The [Create a booking](/api-reference/carts-and-checkout/create-booking) payload accepts them at three scopes, depending on who or what the question is about:

    | Field                                           | Scope                                                    |
    | ----------------------------------------------- | -------------------------------------------------------- |
    | `data[].participants[].customQuestionResponses` | Per participant (e.g. each guest's dietary restrictions) |
    | `data[].additionalCustomQuestionResponses`      | Per booked experience, not tied to one participant       |
    | `purchaser.customQuestionResponses`             | About the purchaser                                      |

    All three use the same shape:

    ```json theme={null}
    [
      {
        "questionId": "q-dietary-01",
        "answer": [{ "value": "vegetarian", "key": null }]
      },
      {
        "questionId": "q-pickup-point",
        "answer": [{ "value": "Hotel lobby", "key": "hotel-lobby" }]
      }
    ]
    ```

    `key` is the selected option's key for select/choice questions and `null` for free-text and checkbox answers (`value` is a string, or a boolean for checkboxes).

    <Warning>
      Answers are validated when you call book-bulk: required questions must be answered, and select answers must match one of the question's options - violations return `422`. Way stores a snapshot of each question with the answer, so later edits to a question don't distort past bookings.
    </Warning>

    If you display terms and conditions, fetch them with [Get terms and conditions](/api-reference/brand-configuration/get-terms-and-conditions).
  </Step>

  <Step title="Create your cart - locally">
    <Info>
      **There is no create-cart endpoint, by design.** You mint the cart ID yourself - any UUID (v4 or v7) - and Way accepts it lazily: the first time the platform hears about your `cartId` is when it appears in the payment-intent URL or the booking payload.
    </Info>

    ```javascript theme={null}
    import { randomUUID } from "crypto";
    const cartId = randomUUID(); // e.g. "c4a1b2c3-d4e5-4f6a-8b9c-0d1e2f3a4b5c"
    ```

    Rules that keep checkout reliable:

    * **Reuse the same `cartId`** across the payment intent → booking → payment confirmation of one checkout attempt. It's the thread that ties them together.
    * **Rotate to a fresh UUID on any failure** before retrying. A cart that already has a payment intent or booking attached can be rejected as non-reusable (`422`).
    * Nothing to clean up on success. On failure, optionally call [Discard cart](/api-reference/carts-and-checkout/discard-cart) and start over with a fresh ID.

    Assemble the cart items in your own state. This `data[]` item shape is used identically by the payment intent (step 6) and the booking (step 7). Every field traces back to something you fetched earlier:

    | Field                                | Comes from                                                                                                                                                                                                                |
    | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `experienceId`                       | The **chosen session's** `experienceId` (step 3)                                                                                                                                                                          |
    | `sessionTime`                        | The chosen session's `startDateTime`                                                                                                                                                                                      |
    | `sessionDuration`                    | The chosen session's `duration`                                                                                                                                                                                           |
    | `mode`                               | `"private"` if the chosen session is a private session or the listing's `bookingAvailabilityMode` is `private` (the guest books the session exclusively); otherwise `"shared"` (the guest joins alongside other bookings) |
    | `participants[].priceTierName`       | The `name` of one of the chosen session's `priceTiers[]` - one entry per guest                                                                                                                                            |
    | `resourceGroupId` (`resource` only)  | The listing detail's `resourceGroups[].id` (step 2)                                                                                                                                                                       |
    | `resourceQuantity` (`resource` only) | How many resource units to book - must equal `participants.length`                                                                                                                                                        |

    <Tabs>
      <Tab title="experience (time-based)">
        ```json theme={null}
        {
          "experienceId": "e7a3b8d1-4c2f-4b6e-9a5d-8f1c3e7b2a64",
          "sessionTime": "2026-08-15T17:00:00",
          "sessionDuration": 90,
          "mode": "shared",
          "participants": [
            { "firstName": "Alice", "lastName": "Rivera", "priceTierName": "Adult" },
            { "firstName": "Ben", "lastName": "Rivera", "priceTierName": "Adult" }
          ]
        }
        ```

        Omit `resourceGroupId` and `resourceQuantity` entirely.
      </Tab>

      <Tab title="resource (collection)">
        ```json theme={null}
        {
          "experienceId": "f2c8d4a6-9b1e-4c7a-8d3f-6e2a9c5b1d48",
          "resourceGroupId": "4a7d1e9b-2c5f-4b3a-8e6d-1f9a5c3b7e28",
          "resourceQuantity": 2,
          "sessionTime": "2026-08-15T00:00:00",
          "sessionDuration": 1440,
          "mode": "private",
          "participants": [
            { "firstName": "Alice", "lastName": "Rivera", "priceTierName": "Full-Day" },
            { "firstName": "Ben", "lastName": "Rivera", "priceTierName": "Full-Day" }
          ]
        }
        ```

        The `experienceId` comes from the session response in step 3 (not the listing, whose `experienceId` is null for collections).

        <Warning>
          `resourceGroupId` and `resourceQuantity` are **required** for collections, and `resourceQuantity` must equal `participants.length` - otherwise the API returns `422`.
        </Warning>
      </Tab>
    </Tabs>
  </Step>

  <Step title="Create the payment intent">
    For card payments with a total above zero, create a payment intent scoped to your cart with [Create a payment intent](/api-reference/carts-and-checkout/create-payment-intent). The body's `data[]` takes the cart items from step 5:

    ```bash theme={null}
    curl -X POST "$WAY_API/v1/brands/$WAY_BRAND_ID/carts/c4a1b2c3-d4e5-4f6a-8b9c-0d1e2f3a4b5c/payment_intents" \
      --header "Authorization: Bearer $WAY_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{ "data": [ { ...cart item from step 5... } ] }'
    ```

    ```json theme={null}
    {
      "data": {
        "totalNetAmount": 178,
        "paymentIntentSecret": "pi_3Nx..._secret_Yz4"
      }
    }
    ```

    Keep the `paymentIntentSecret` - Stripe.js uses it to **mount the payment form** in step 8. The final charge is confirmed against the `clientSecret` returned by the booking call in step 7, which is the authoritative one for the checkout.

    **Skip this step entirely** when the total is zero (fully discounted) or the guest pays with a non-card method (step 10).

    <Tip>
      To show a running total before checkout, call this endpoint with `"getOnlyTotalNetAmount": true` - it prices the cart without initializing a payment.
    </Tip>
  </Step>

  <Step title="Create the booking - before the card is charged">
    Ordering: **the booking is created first, then the card is charged.** [Create a booking](/api-reference/carts-and-checkout/create-booking) creates a booking in `processing` state that already holds the inventory, and returns the definitive Stripe `clientSecret` to charge against:

    ```bash theme={null}
    curl -X POST "$WAY_API/v1/brands/$WAY_BRAND_ID/book-bulk" \
      --header "Authorization: Bearer $WAY_API_KEY" \
      --header "Content-Type: application/json" \
      --data '{
        "cartId": "c4a1b2c3-d4e5-4f6a-8b9c-0d1e2f3a4b5c",
        "purchaser": {
          "firstName": "Alice",
          "lastName": "Rivera",
          "emailAddress": "alice@example.com",
          "phoneNumber": "+15550100"
        },
        "data": [
          {
            "experienceId": "e7a3b8d1-4c2f-4b6e-9a5d-8f1c3e7b2a64",
            "sessionTime": "2026-08-15T17:00:00",
            "sessionDuration": 90,
            "mode": "shared",
            "participants": [
              {
                "firstName": "Alice",
                "lastName": "Rivera",
                "priceTierName": "Adult",
                "customQuestionResponses": [
                  { "questionId": "q-dietary-01", "answer": [{ "value": "vegetarian", "key": null }] }
                ]
              },
              { "firstName": "Ben", "lastName": "Rivera", "priceTierName": "Adult" }
            ]
          }
        ],
        "paymentMethod": "credit-card",
        "siteLanguage": "en",
        "deviceType": "pc"
      }'
    ```

    The `data[]` items are the same cart items from step 5, now carrying the custom-question answers collected in step 4 (per participant here; experience-level answers go in `data[].additionalCustomQuestionResponses`, purchaser-level ones in `purchaser.customQuestionResponses`).

    ```json theme={null}
    {
      "data": {
        "subjectId": "c4a1b2c3-d4e5-4f6a-8b9c-0d1e2f3a4b5c",
        "subjectType": "cart",
        "confirmationCode": "55xNJqU3Q",
        "amount": 17800,
        "currency": "USD",
        "clientSecret": "pi_3Nx..._secret_Yz4"
      }
    }
    ```

    Three things to note here:

    * The link between booking and payment is the **`cartId`** - there is no payment-intent ID field in this payload. Way correlates them server-side.
    * The `confirmationCode` in this response is the **cart's** confirmation code (`subjectType: "cart"`). Each booking created from the cart gets its own confirmation code - you retrieve it in step 9 using the `cartConfirmationCode` filter.
    * **`amount` here is in Stripe's minor units for card payments** (`17800` = \$178.00) because it echoes the payment intent - unlike every other amount in the API, which uses major units. See [Amounts and currency](/api-reference/overview#amounts-and-currency).
  </Step>

  <Step title="Charge the card with Stripe">
    Use **Stripe Elements** with the `clientSecret` from the booking response. The card number lives only inside Stripe's iframe; your code and the Way API never see it.

    ```javascript theme={null}
    import { loadStripe } from "@stripe/stripe-js";

    // The publishable key for your brand's payment platform (step 1 note)
    const stripe = await loadStripe(STRIPE_PUBLISHABLE_KEY);

    const elements = stripe.elements({ clientSecret });
    const paymentElement = elements.create("payment");
    paymentElement.mount("#payment"); // Stripe's iframe collects the card

    // On submit:
    await elements.submit();
    const { paymentIntent, error } = await stripe.confirmPayment({
      elements,
      clientSecret,
      redirect: "if_required", // handles 3D Secure automatically
      confirmParams: { return_url: "https://yoursite.example/return?cartId=..." },
    });
    ```

    `redirect: "if_required"` triggers the 3D Secure challenge only when the issuer demands it. On redirect flows, Stripe returns to your `return_url` with `payment_intent_client_secret` in the query - re-check the intent status on return. Brands on `stripe-eu` will hit SCA challenges far more often than US brands.

    <Accordion title="No browser? Confirming server-side via Stripe's API">
      A fully headless integrator can confirm the intent against **Stripe's** API instead (this is a Stripe call, not a Way call):

      ```
      POST https://api.stripe.com/v1/payment_intents/{id}/confirm
        payment_method=pm_card_visa
      ```

      The `{id}` is the part of the client secret before `_secret_`. Authenticate the call the same way Stripe.js does: pass the brand's **publishable key** as the bearer token together with the `client_secret` in the form body. Use Stripe **test tokens** like `pm_card_visa` on staging.

      <Warning>
        Sending raw card numbers to Stripe's REST API puts you in PCI-DSS SAQ-D scope. Unless you are already a certified PCI processor, use Stripe Elements.
      </Warning>
    </Accordion>

    <Note>
      Way exposes **no confirm or status endpoint** for payment intents - confirmation and status checks are entirely Stripe-side (`stripe.confirmPayment`, `stripe.retrievePaymentIntent`). Way learns the payment settled through its own Stripe webhook and finalizes the booking server-side; there is no "payment done" call for you to make.
    </Note>
  </Step>

  <Step title="Verify success - and handle failure">
    After `confirmPayment`, check `paymentIntent.status === "succeeded"`. That's your UX signal; the authoritative reconciliation is Way's Stripe webhook, which flips the booking from `processing` to `confirmed` and sends the guest their confirmation email.

    Verify from your server with [Get bookings](/api-reference/bookings/get-bookings), filtering by `cartConfirmationCode` (from step 7) or `cartId`:

    ```bash theme={null}
    curl "$WAY_API/v1/bookings?cartConfirmationCode=55xNJqU3Q" \
      --header "Authorization: Bearer $WAY_API_KEY"
    ```

    ```json theme={null}
    {
      "items": [
        {
          "id": "019f5ca1-4dc1-775d-a40d-833f44bfccf4",
          "confirmationCode": "U7dSqlzCA",
          "cartId": "c4a1b2c3-d4e5-4f6a-8b9c-0d1e2f3a4b5c",
          "cartConfirmationCode": "55xNJqU3Q",
          "amount": 178,
          "currency": "USD",
          "amountRefunded": 0,
          "status": "confirmed",
          "paymentMethod": "credit-card",
          "purchaser": { "firstName": "Alice", "lastName": "Rivera", "emailAddress": "alice@example.com" },
          "participants": [
            { "firstName": "Alice", "lastName": "Rivera", "priceTierName": "Adult", "cancelledAt": null },
            { "firstName": "Ben", "lastName": "Rivera", "priceTierName": "Adult", "cancelledAt": null }
          ],
          "event": { "timezone": "America/Chicago", "startDateTime": "2026-08-15T17:00:00" },
          "experience": { "id": "e7a3b8d1-4c2f-4b6e-9a5d-8f1c3e7b2a64", "title": "Sunset Sailing Tour" }
        }
      ]
    }
    ```

    Each item carries the booking's own `confirmationCode` (the code the guest sees) and its `id` - the **booking ID** you'll need for [Get booking](/api-reference/bookings/get-booking), [cancellation](/api-reference/bookings/cancel-booking), and [rescheduling](/api-reference/bookings/reschedule-booking) later. Subscribe to the [`booking.completed` webhook](/webhooks/events/booking-completed) to be notified without polling.

    **On payment failure:**

    1. Call [Discard cart](/api-reference/carts-and-checkout/discard-cart) to release the cart.
    2. **Rotate to a fresh `cartId`** and restart from step 6 - the old cart is not reusable.

    | Booking `status`                    | Meaning                                                      |
    | ----------------------------------- | ------------------------------------------------------------ |
    | `processing`                        | Created, awaiting payment confirmation. **Holds inventory.** |
    | `confirmed`                         | Payment settled (or non-card method) - guest notified        |
    | `failed` / `cancelled` / `refunded` | Terminal states                                              |

    <Warning>
      A card booking that never gets a confirmed payment stays `processing` and keeps holding capacity until platform cleanup runs. Fine for testing - but in production, always discard the cart on failure rather than abandoning it.
    </Warning>
  </Step>

  <Step title="Non-card payment methods">
    `paymentMethod` in the booking payload also accepts `cash` and `room-charge`. These **skip Stripe entirely** - no payment intent, no confirmation - and the booking is `confirmed` synchronously in the book-bulk response. The same applies to any booking with a zero total.

    For `room-charge` (posting the cost to the guest's hotel folio), validate the guest's reservation first with [Validate room charge](/api-reference/integrations/validate-room-charge), then pass the returned reservation fields into book-bulk under `paymentDetail`. The full flow - validation, payload fields, and per-PMS behavior for OPERA, StayNTouch, Infor, and Mews - is covered in the [Room Charge Integration](/guides/room-charge) guide.
  </Step>
</Steps>

## Common errors

| Error                                            | Cause                                                                                         | Fix                                                                                                                          |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `422` on payment intent or book-bulk             | Cart already used (payment intent or booking attached)                                        | Rotate to a fresh `cartId` and retry                                                                                         |
| `422` mentioning quantity                        | `resourceQuantity !== participants.length` on a `resource` item                               | Send one participant per resource unit                                                                                       |
| `422` at booking time                            | Session sold out between selection and booking                                                | Re-fetch sessions, offer alternatives, or point to [Waitlists](/api-reference/waitlists/join-waitlist)                       |
| Payment succeeded but booking still `processing` | Way's Stripe webhook hasn't landed yet                                                        | Wait a few seconds; verify via [Get bookings](/api-reference/bookings/get-bookings) or the `booking.completed` webhook       |
| `422` on custom questions                        | Required question unanswered, or a select answer's `key` doesn't match the question's options | Re-fetch [custom questions](/api-reference/experiences/get-custom-questions) and validate answers client-side before booking |
