# Client portal kit

Put a working client portal and a staff pipeline on a hosted site with two lines of HTML and no JavaScript of your own. Clients see their requests, messages and documents and can start a new request. Staff move work through its stages and answer clients. Everything on screen comes from the Space's engagement types and forms, so a new kind of work needs no new code.

Available

> **What drives it.** The kit reads `GET /portal/schema` from the [client portal API](https://bynefit.com/docs/client-portal-api). That is the Space's active [engagement types](https://bynefit.com/docs/manage-engagement-types), their stages, and the linked form's fields. Change a type or a form on the Engagements page and the portal changes with it.

## Set up

1. **Turn on sign-in**Create a sign-in client for the Space at [/dash/sites/oauth](https://bynefit.com/dash/sites/oauth) and add its `auth` block to `config.json`. See [site config](https://bynefit.com/docs/help-site-config).
2. **Turn on the add-on**The Space needs the engagements add-on. Without it the portal says it is not switched on.
3. **Add a mount point**Put one element on a page. The kit loads itself, signs the person in and fills the element.

## Publishing pages that use the kit

A page carries the runtime version from the moment it was published. After the kit changes, re-publish each page that mounts it (an edit to the page or a Rebuild is enough) so it loads the current version. Pages you have not re-published keep working on the version they were published with.

## Mount points

**A client's page (HTML)**

```
<div data-bynli="portal" data-portal-heading="Your requests"></div>
```

**The staff page (HTML)**

```
<div data-bynli="portal-admin" data-portal-heading="Engagements"></div>
```

| Attribute | Where | What it does |
| --- | --- | --- |
| `data-portal-heading` | Both | The page heading. Plain text only. Defaults to "Your requests" or "Engagements". |
| `data-portal-intro` | Client | A line of plain text above the list of requests. |
| `data-portal-site` | Both | The key of the site this page belongs to, for example `doggie-o`. The server already works the site out from where the page is served, so this only confirms it: a key that names a different site is ignored. On the staff page it picks the site shown first. A site sees its own types, booking types and engagements plus the ones the whole Space shares. The staff page has a Site selector with All sites. |
| `data-portal-types` | Both | Comma-separated engagement type keys, for example `grooming,boarding`. The page lists, offers and shows only those types and their engagements. Keys the Space does not have are ignored, and the list can only narrow what the Space already offers. |
| `data-portal-booking-types` | Both | Comma-separated booking type ids (`bkt_` and 32 hex characters). The booking list, the "Book a time" chooser and the staff bookings screen show only those types. |

From a script, `await Bynli.use('portal')` loads the kit, and `Bynli.Portal.mount(el)` and `Bynli.Portal.mountAdmin(el)` fill an element you made yourself.

> **Keep the staff page off public navigation.** It works only for a signed-in manager or admin of the Space, but anything on the page can use that person's token. Put it on a page only your staff reach.

## What a client gets

- **A list of their requests** with the stage each is at, or its status when the type has no stage for it.
- **A request page** with progress through the stages the client may see, the messages and updates in order, a message box while the request is open, the documents (download, add, and remove their own), and how to pay when something is due.
- **A new request form** built from the schema. With one type it shows that type's form fields. With several it asks which kind first. A type with no form, or a Space with no types, gets a short "what do you need" form.
- **Booking** when the Space has booking open: choose a booking type, pick a free time (shown in the practice's own time zone, which the page names), add a note and send it. Their bookings are listed with a pay link while one is waiting for payment, and a cancel button for the ones that can still be cancelled.
- **The form's thank-you** after sending, if the linked form has a confirmation message.

## What staff get

- **A pipeline board.** By status, or by one type's stages, internal stages included. Cards stack on a phone.
- **One engagement.** The client's shared details, the full timeline with internal notes marked, and the documents.
- **Charges and payment.** On an engagement, edit its charges (description, quantity, price in dollars, and the kind, including discounts), send the client the pay link, open the payment page, and mark it paid when the Space collects the money itself. Settled engagements show their charges read-only.
- **Stages and contact.** Stage buttons for the engagement's type with the current one pressed, the request reference of the form that started it, and call and email buttons for the client.
- **Bookings.** A Bookings view next to the pipeline: approve or decline requests (with an optional reason sent to the client), cancel a booking, and retry a refund that did not go through.
- **Moves.** Change the status to one of the allowed next ones (with a reason, which is sent to the client when it asks them for something), move the stage, send a message to the client, or leave an internal note.

## The schema call

| Call | Who | Returns |
| --- | --- | --- |
| `GET /portal/schema` | A signed-in client | The Space's active types. Stages the client may not see are left out. |
| `GET /portal/admin/schema` | A manager or admin of the Space | Every type, inactive ones too, with every stage, default items, ids, and the allowed status moves. |

**200 (JSON)**

```json
{
  "schema": {
    "types": [
      {
        "key": "walks",
        "label": "Dog walk",
        "kind": "service",
        "stages": [
          {"key": "requested", "label": "Requested", "client_visible": true, "status_bucket": "submitted"},
          {"key": "walked", "label": "Walked", "client_visible": true, "status_bucket": "closed"}
        ],
        "bookingTypeId": "bkt_0123456789abcdef0123456789abcdef",
        "form": {
          "id": "frm_0123456789abcdef0123456789abcdef",
          "name": "Walk request",
          "message": "Thanks, we will be in touch.",
          "fields": [
            {"key": "field_12", "label": "Dog name", "type": "text", "required": true, "placeholder": null, "help": null, "options": []},
            {"key": "field_13", "label": "Size", "type": "dropdown", "required": true, "placeholder": null, "help": null, "options": ["Small", "Medium", "Large"]}
          ]
        }
      }
    ],
    "bookingTypes": [],
    "statuses": ["draft", "submitted", "awaiting_payment", "in_review", "info_needed", "filing", "filed", "issued", "closed", "cancelled"],
    "messageClosed": ["draft", "closed", "cancelled"],
    "kinds": ["formation", "service"],
    "limits": {"title": 160, "message": 4000, "answer": 2000}
  }
}
```

- **Forms.** `form` is `null` when the type has no form, or the form is paused or a registration form. Hidden fields are never listed. A `section_header` is a heading, not a question.
- **Booking types.** The Space's active booking types, only once it has opening hours. `bookingTypeId` is the booking type linked to the engagement type, or `null`.
- **Before the update.** A Space whose database has not got engagement types yet answers `200` with empty `types`, so the kit falls back to the short form.
- **Add-on.** Both calls answer `403` `addon_required` like the other engagement routes.

## Starting a request from a type

`POST /portal/engagements` also takes a type. Send `type` (the type's key) and `answers`, an object keyed by the form's `field_` keys. The request is created as a draft at the type's first stage with the type's default items, the answers become the first message (`Label: value`, one per line), and the client then sends it with `POST /portal/engagements/{id}/submit`.

**Start a request (JSON)**

```json
{"type": "walks", "answers": {"field_12": "Rex", "field_13": "Large"}}
```

| Status | When |
| --- | --- |
| `422` | The type is unknown, switched off or belongs to another Space; a required answer is missing; an answer is over 2000 characters; or a dropdown or radio answer is not one of the listed options. Unknown answer keys are ignored. |
| `201` | Created. The body is `{engagement}` with its `type` and `stage`. |

## Styling and accessibility

- **Neutral by default.** The kit inherits the page's font and colours, and uses the browser's light or dark scheme for its own surfaces. Style the classes that start with `bynli-portal-` to match the site.
- **Touch targets.** Every button, field, select and choice is at least 44 pixels tall. Nothing makes the page scroll sideways at 390 pixels.
- **Announced.** Errors use an alert, confirmations a status message, the list of stages marks the current step, and the heading takes focus when the view changes. Required fields are marked for assistive technology.

## Safety

- **No markup from data.** Titles, messages, names, file names and form labels are written as text, never as HTML. A request called `<img onerror=...>` shows exactly that.
- **Links.** A payment link appears only if it is an `https://` address, and opens in a new tab with no access back to the page.
- **The token stays in the sign-in kit.** The portal sends it as a bearer header on each call and asks for no cookies.
- **The server decides.** The kit only shows what the API returns. A client cannot see another client's request, a stage marked staff-only, or an internal note, whatever the page does.

---

Source: https://bynefit.com/docs/client-portal-kit
Checked against the live API on October 18, 2026.
