Skip to content
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.

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

CallerSeesRoutes
A signed-in client of the practiceOnly their own engagements, documents, messages and bookings at that practice./portal/...
A manager or admin of the practice, signed inEvery client's engagements, the pipeline, payments, the Space's members, events, tasks and bookings of that practice./portal/admin/...
A Space API keyNothing. API keys cannot read engagements, and there is no engagement webhook event.Not available
Another practice's siteNothing. 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 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.
  2. Sign people inOn 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 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"
200JSON
{
  "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

FieldValues
ideng_ and 32 hex characters. Never a number.
kindformation or service
entityTypellc, corp, nonprofit or null
jurisdictionA two-letter state code or null
statusdraft, submitted, awaiting_payment, in_review, info_needed, filing, filed, issued, closed, cancelled
paymentStateunpriced, awaiting, paid, refunded
totalCentsThe all-in total in cents, or null while unpriced
itemsPrice 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.

CallWhat it does
GET /portal/meThe 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/meChanges 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 readThe 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/bookingsAccept 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/schemaThe 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/engagementsThe list above.
POST /portal/engagementsStarts 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}/submitSends a draft to the practice. A formation needs an entity type and a state first (422). The practice is alerted.
POST /portal/engagements/{id}/replyAnswers 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}/documentsUploads 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}/downloadThe 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/advisorWho the client deals with: {advisor: {name, isTeam, practice}}. No email address.
GET /portal/messagesOne thread per submitted engagement, newest message first.
GET /portal/messages/{id} and POSTReads 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}/cancelBooking 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."}'

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.

CallWhat it does
GET /portal/admin/schemaEvery 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/engagementsThe 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 .../stageBody {"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, .../messageMoves 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-paidPrices an engagement, sets how it is collected, sends the pay link, and marks an outside payment as received.
POST .../documentsSends 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/overviewCollected 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}/suggestionsSuggests a fix to a client's shared details. Nothing changes until the client approves it on Bynefit.
GET /portal/admin/payments, /team, /eventsPayment totals and orders, the Space's members, and upcoming events.
GET, POST, PATCH, DELETE /portal/admin/tasksThe 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

StatusBodyMeaning
401{"error":"invalid_token","error_description":"..."} with WWW-AuthenticateNo 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-AfterSlow 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