# Client portal API

How a practice's hosted site shows each client their own engagements, documents, messages and bookings, and how the practice's own staff run the same work from its own admin page. Every call uses the person's Sign in with Bynefit token.

Available

> **What an engagement is.** A request or order a practice runs through Bynefit for a client: a company formation, a service, a booking. It has a status, a price, a timeline, documents and a message thread. The practice needs the *engagements* add-on on. Without it the engagement, document and message calls answer `403` with `addon_required`.

## Who can see what

| Caller | Sees | Routes |
| --- | --- | --- |
| A signed-in client of the practice | Only their own engagements, documents, messages and bookings at that practice. | `/portal/...` |
| A manager or admin of the practice, signed in | Every client's engagements, the pipeline, payments, the Space's members, events, tasks and bookings of that practice. | `/portal/admin/...` |
| A Space API key | Nothing. API keys cannot read engagements, and there is no engagement webhook event. | Not available |
| Another practice's site | Nothing. The practice is taken from the token's client, never from the request. | Not available |

A visitor's token never works on the admin routes: they need an active manager or admin role in the practice's Space. Bynefit staff have no special access either, unless the practice gave them a role.

> **Keep the admin surface off public pages.** Anything running on the practice's site can use the token of whoever is signed in. Build the admin screens on a page only your staff reach, ideally your own verified domain.

## Set up

1. **Make a sign-in client**A manager of the practice's Space opens [/dash/sites/oauth](https://bynefit.com/dash/sites/oauth). The client belongs to that Space, which is how every portal call knows whose engagements to look in. See [OAuth 2.0 / OpenID Connect](https://bynefit.com/docs/oauth).
2. **Sign people in**On a Bynefit-hosted site, add the `auth` block to `config.json`. The kit signs people in, stores the tokens and refreshes them.
3. **Call the portal**Send the access token as `Authorization: Bearer`. `Bynli.auth.accessToken()` returns a fresh one.

The portal answers browsers on `bynefit.com`, on `*.site.bynefit.com`, and on a custom domain once it is verified on a live site. Anything else gets no cross-origin permission. There are no cookies: only the bearer token. Access tokens last an hour and refresh tokens 60 days; a revoked or expired token gets `401` `invalid_token`.

## Your first call

**cURL**

```bash
curl https://bynefit.com/api/portal/engagements \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

**JavaScript**

```javascript
await Bynli.use('auth');
const token = await Bynli.auth.accessToken();

const res = await fetch("https://bynefit.com/api/portal/engagements", {
  headers: { Authorization: "Bearer " + token }
});
if (res.status === 401) { Bynli.auth.signIn(); }
const { engagements } = await res.json();
```

**PHP**

```php
$ch = curl_init("https://bynefit.com/api/portal/engagements");
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer " . $accessToken],
]);
$data = json_decode(curl_exec($ch), true);
foreach ($data['engagements'] as $e) {
    echo $e['id'], ' ', $e['status'], ' ', $e['title'], "\n";
}
```

**200 (JSON)**

```json
{
  "engagements": [
    {
      "id": "eng_0123456789abcdef0123456789abcdef",
      "kind": "service",
      "serviceKey": null,
      "entityType": null,
      "jurisdiction": null,
      "title": "Bookkeeping cleanup",
      "status": "info_needed",
      "paymentState": "awaiting",
      "totalCents": 45000,
      "items": [],
      "submittedAt": "2026-10-02 15:10:00",
      "closedAt": null,
      "updatedAt": "2026-10-09 11:42:18"
    }
  ]
}
```

The list is the client's own engagements at this practice, newest first, up to 50, drafts included. Items appear only on the detail call.

### The engagement

| Field | Values |
| --- | --- |
| `id` | `eng_` and 32 hex characters. Never a number. |
| `kind` | `formation` or `service` |
| `entityType` | `llc`, `corp`, `nonprofit` or `null` |
| `jurisdiction` | A two-letter state code or `null` |
| `status` | `draft`, `submitted`, `awaiting_payment`, `in_review`, `info_needed`, `filing`, `filed`, `issued`, `closed`, `cancelled` |
| `paymentState` | `unpriced`, `awaiting`, `paid`, `refunded` |
| `totalCents` | The all-in total in cents, or `null` while unpriced |
| `items` | Price lines `{label, kind, qty, unitCents}`, detail call only |

## Client routes

Bodies are JSON. Ids that are not the caller's own answer `404` `{"error":"Not found."}`, the same as ids that do not exist.

| Call | What it does |
| --- | --- |
| `GET /portal/me` | The client's name and, only if they share them with this site, email, phone and business. Includes `manageUrl`, where the person changes what is shared. Not behind the add-on. |
| `PATCH /portal/me` | Changes the name only. Email, phone and business are changed by the person on Bynefit, and a body that names them is refused with `422`. |
| Every portal read | The caller's site is taken from the request `Origin` and the Space's registered sites. An optional `site` query parameter (a site key) is accepted only when it agrees. Staff routes also accept `site=all` or another site of the Space. |
| `GET /portal/booking` and `GET /portal/admin/bookings` | Accept the same `booking_types` comma list of booking type ids; the staff list, its counts and its limit apply it in the query. |
| `GET /portal/schema` | The Space's active engagement types (optional `types` and `booking_types` comma lists narrow the answer, never widen it) with the stages a client may see, each type's form fields, and booking types. Behind the add-on. See the [portal kit](https://bynefit.com/docs/client-portal-kit). |
| `GET /portal/engagements` | The list above. |
| `POST /portal/engagements` | Starts a draft. `title` is required (160 characters). Optional: `kind`, `serviceKey`, `entityType`, `jurisdiction` (two letters), `details` (4000 characters, saved as the first message). Or send `type` (an engagement type's key) and `answers` keyed by the form's field keys: the draft starts at the type's first stage and the title defaults to the type's label. `201` with `{engagement}`. |
| `GET /portal/engagements/{id}` | `{engagement, timeline, documents, payment}`. The timeline shows only what the client may see, oldest first. `payment` says how it is collected (`method` is `bynefit` or `external`), whether it is `payable`, and a `payUrl` or the practice's own `url` and `instructions`. |
| `PATCH /portal/engagements/{id}` | Edits a draft (`title`, `entityType`, `jurisdiction`, `serviceKey`). `409` once it is submitted. |
| `POST /portal/engagements/{id}/submit` | Sends a draft to the practice. A formation needs an entity type and a state first (`422`). The practice is alerted. |
| `POST /portal/engagements/{id}/reply` | Answers a request for information. Only while the status is `info_needed` (`409` otherwise). `{"body": "..."}`, up to 4000 characters. Moves it to `in_review`. |
| `GET /portal/engagements/{id}/documents` | `{documents}`: `{id, label, direction, mime, sizeBytes, createdAt, originalName, canDelete, heldForSignature}`. |
| `POST /portal/engagements/{id}/documents` | Uploads a file as multipart: `file` and an optional `label`. PDF, JPEG, PNG, HEIC, HEIF, WebP or TIFF, up to 25 MB, up to 40 per engagement. `201` with `{document}`. |
| `GET /portal/documents/{id}/download` | The file, as a download. Fetch it with the bearer header, since a plain link cannot send one. |
| `DELETE /portal/documents/{id}` | Withdraws a file the client uploaded, unless it is part of a signature. |
| `GET /portal/advisor` | Who the client deals with: `{advisor: {name, isTeam, practice}}`. No email address. |
| `GET /portal/messages` | One thread per submitted engagement, newest message first. |
| `GET /portal/messages/{id}` and `POST` | Reads or sends in a thread, up to 4000 characters. `409` on a closed engagement. Replying to an `info_needed` engagement counts as the reply. |
| `GET /portal/booking`, `/booking/slots`, `GET and POST /portal/bookings`, `POST /portal/bookings/{id}/cancel` | Booking types, free times, and the client's own bookings. Not behind the add-on. |

**cURL**

```bash
curl -X POST https://bynefit.com/api/portal/engagements/eng_0123456789abcdef0123456789abcdef/reply \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"body":"Here is the address you asked for."}'
```

**JavaScript**

```javascript
const res = await fetch("https://bynefit.com/api/portal/engagements/eng_0123456789abcdef0123456789abcdef/reply", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + await Bynli.auth.accessToken(),
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ body: "Here is the address you asked for." })
});
const { engagement } = await res.json();
```

Self-serve company formation orders that a person places directly with Bynefit are not in these lists: only engagements created by a practice for its client are.

## Practice routes

For the practice's own manager or admin. Every route answers `403` `This account does not manage this practice.` to anyone else. Bodies must be JSON objects. Actions marked *admin* need the admin role, not just manager.

| Call | What it does |
| --- | --- |
| `GET /portal/admin/schema` | Every engagement type, inactive ones too, with all stages, default items, the linked form and booking type, and the allowed status moves. Behind the add-on. |
| `GET /portal/admin/engagements` | The pipeline, newest first, up to 100. Filter with `status`, or `archived=1`. Each row has the client's name and a stable `key` for grouping, the last message, and `paidAt`. Also returns `counts`. |
| `GET /portal/admin/engagements/{id}` | One engagement with the customer's shared details, the full timeline including internal notes, documents, the allowed next statuses and the payment view. |
| `POST .../stage` | Body `{"stage": "key"}`. Moves the engagement to a stage of its engagement type. The stage's status bucket may move the status along an allowed forward step, never backwards; otherwise it answers 409. Every engagement the portal returns carries `type` and `stage` (`{key, label}` or null), and clients only see stages marked client-visible. |
| `POST .../status`, `.../note`, `.../message` | Moves the status, adds an internal note, or sends a message the client sees and gets by email. |
| `POST .../items`, `.../payment` (admin), `.../send-pay-link`, `.../mark-paid` | Prices an engagement, sets how it is collected, sends the pay link, and marks an outside payment as received. |
| `POST .../documents` | Sends the client a file. |
| `POST .../archive`, `.../unarchive`, `DELETE` (admin) | Takes a closed engagement out of the pipeline, brings it back, or deletes it. A delete is refused with `409` when money moved or can move. |
| `GET /portal/admin/overview` | Collected this month, outstanding, open orders and the 12 newest events. Message text is never included. |
| `GET /portal/admin/clients` and `/clients/{id}` | The practice's clients, and one client's detail. Email, phone and business appear only when the client shares them with this site. |
| `POST /portal/admin/clients/{id}/suggestions` | Suggests a fix to a client's shared details. Nothing changes until the client approves it on Bynefit. |
| `GET /portal/admin/payments`, `/team`, `/events` | Payment totals and orders, the Space's members, and upcoming events. |
| `GET, POST, PATCH, DELETE /portal/admin/tasks` | The Space's follow-up tasks. |
| `GET /portal/admin/payment-settings`, `POST` (admin) | How the practice collects by default. |
| `/portal/admin/bookings`, `/portal/admin/booking/...` | Approve, decline, cancel and refund bookings; set booking hours, types and blocked times. |

## Errors

| Status | Body | Meaning |
| --- | --- | --- |
| `401` | `{"error":"invalid_token","error_description":"..."}` with `WWW-Authenticate` | No token, or an expired, revoked or wrong one. Refresh it, or sign the person in again. |
| `403` | `{"error":"...","error_code":"addon_required","addon":"..."}` | The practice has not turned on the engagements add-on. |
| `403` | `{"error":"This account does not manage this practice."}` | An admin route, called by someone who is not a manager or admin there. |
| `404` | `{"error":"Not found."}` | Not yours, or does not exist. |
| `409` | `{"error":"..."}` | The engagement is in the wrong state for that action. The message says why. |
| `422` | `{"error":"..."}` | A field is missing or invalid. The message names it. |
| `429` | `{"error":"...","retry_after":N}` and `Retry-After` | Slow down. Writes are limited per person: for example 20 new engagements and 30 messages an hour. |
| `500` | `{"error":"Server error","error_code":"server_error","trace_id":"..."}` | Quote the `trace_id` to support. |

## What is not available

- Listing other clients' engagements from a client token, or from any page without a practice manager signed in.
- Reading engagements with an API key, or with a webhook. The nearest signal is a `task.created` webhook when a request or reply opens a follow-up task, and it does not carry the engagement.
- A scope for engagements. The portal does not check sign-in scopes: it checks that the token belongs to a client of the practice, and that the person is a customer of it (or, for the admin routes, a manager).
- A page you must write yourself. The [portal kit](https://bynefit.com/docs/client-portal-kit) draws both the client and the staff screens from these calls, or you can build your own.

---

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