On this page and tools
Hosted sites
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.
Available403 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.
Set up
- Make a sign-in clientA manager of the practice's Space opens /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.
- Sign people inOn a Bynefit-hosted site, add the
authblock toconfig.json. The kit signs people in, stores the tokens and refreshes them. - Call the portalSend 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 https://bynefit.com/api/portal/engagements \
-H "Authorization: Bearer ACCESS_TOKEN"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();$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";
}{
"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. |
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 -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."}'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.createdwebhook 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 draws both the client and the staff screens from these calls, or you can build your own.