Skip to main content
This page is the low-level reference behind the SDK components and clients. Most apps never call these endpoints directly — the SDK does — but you’ll need them when minting tokens or debugging.

Base URL & headers

All requests go to the Statisfy gateway, https://api.statisfy.com. The SDK components and clients use it by default, and inside a Statisfy-hosted portal they use the address the portal is set up with, so you don’t pass a URL. Every SDK data request carries:

Authentication

There are two ways a bearer token comes into existence. The SDK accepts either — it just returns whatever getToken gives it.

Portal sign-in

A Statisfy-hosted portal has no backend of its own, so its customers sign in passwordless: Statisfy emails a one-time code, the portal exchanges that code for a session token, and a rotating refresh token keeps the customer signed in afterwards. All three routes take a JSON body only — no Authorization header and no publishable key — and live on the same base URL as the rest of the API.
You rarely call these yourself. <PortalAuthGate> from @statisfy/portal-react runs the whole flow — it renders the sign-in form, stores the refresh token, silently re-mints the access token before it expires, and supplies StatisfyProvider internally. Every portal template ships with it already wired up.
entity_type must be "portal" and entity_id is the portal’s id; together they are how the service resolves your tenant. Any other entity_type is rejected with unsupported_entity — there is no “latest published portal” fallback.

POST /auth/otp — email a one-time code

Response 200 — an empty object, {}. The response is deliberately empty and identical for every address: it never reveals whether the email belongs to one of your customers. A code is created and sent only when the portal admits the email: a customer contact matched by one of the portal’s sign-in grants while its Customers switch is on, or one of your own team while its Your team switch is on. See How Sign-In Is Decided. A malformed or unknown entity_id is the one exception — that’s a misconfigured portal, not an enumeration signal, so it returns 404. The code is a 6-digit number and expires after the window set in the portal’s Settings → Auth (1–60 minutes, default 10) — see Customizing the Login Email. A portal that hasn’t been published yet is admitted only when the request comes from one of its own preview hosts — Statisfy matches the browser’s Origin against the portal’s deployed preview versions and pins the resulting session to that build. This is what lets you walk a real customer through a brand-new portal on its preview URL before you take it live. The check reads Origin only to choose which build renders; it grants nothing on its own, and every other authorization gate still applies.

POST /auth/verify — exchange the code for a session

Response 200
token is an HS256 JWT signed with your tenant’s shared secret, carrying tenant_id, customer_id, portal_id, email, iss (statisfy-sdk), iat, and exp. The gateway scopes every subsequent request to that customer_id. refresh_token is opaque — store it, don’t parse it.

POST /auth/token — refresh the session

No entity_type / entity_id is needed — the refresh token carries its own context. The response is the same shape as /auth/verify, including a new refresh_token.
Refresh tokens rotate on every use. Replace the stored token each time, and never present one that has already been exchanged — reuse revokes the whole token family and signs the customer out. A refresh token is valid for at most 30 days.

Contacts on several accounts: switching account

A session token is scoped to exactly one account (its customer_id claim), so a contact associated with more than one account switches by re-minting, not by passing an account id per request: customer_id is validated against that contact’s own accounts on every call, so it can only ever narrow to something they already had; an account they’ve since been removed from quietly resolves back to their primary, and the response’s customer_id reports the account actually bound. The server stores no account choice: a refresh without customer_id lands on the contact’s primary account, so a client staying on another account must send its customer_id on every refresh, not only when switching. createPortalAuthClient and <PortalAuthGate> do this for you. A GET /sdk/v1/me/accounts response looks like this:
active_customer_id is the account the current token is scoped to. name and domain can be null — fall back to customer_id for a label. is_primary marks the account a session lands on when none is requested. For a customer contact, scope is null, available_scopes is empty, has_more is false, and active_account is null: a contact always gets their whole list in one response. Your own team. When one of your own team signs in (the portal’s Your team switch is on), GET /sdk/v1/me/accounts returns one page of the accounts they can see instead of a contact’s own accounts. It takes optional query parameters, which a customer contact’s request ignores: In the response, scope is the scope actually applied (never null for your team, which is how you tell the two audiences apart), available_scopes lists every scope this viewer may request (all only when their role can see all accounts), and has_more says whether another page exists. is_primary is always false here; use active_customer_id for the current account. active_account labels that account even when it isn’t on the page returned (a search, a later page, or another scope), so you can name it without fetching again. The SDK wraps this: listAccounts(query?) and switchAccount(customerId) on the portal auth client, with getActiveCustomerId() for the current one. accounts is a single entry for most viewers; the SDK’s switcher then shows that account’s name as a plain label rather than a picker — see the account switcher. Error codes

Impersonation — an admin opens a customer’s portal

An admin with the manage portals permission can open a customer’s real portal as one of their contacts, from the ⋮ menu next to Preview as on Portal Studio’s Preview tab. This is not a preview: the tab lands on the portal’s own address, already signed in as that contact, and the session writes and is recorded like any other. The exchange rides /auth/verify — the same endpoint OTP verification uses — but presents a short-lived signed grant instead of an email code:
Response — the same shape as an OTP verify: a session token, its refresh_token, and the portal details. The session carries an actor on it, echoed by the server as a top-level actor: { sub, email } on every mint and refresh, and re-stamped onto the token — so a page reload, a silent refresh, or a token expiring all keep the session marked with the acting admin. The account is pinned when the grant was minted and doesn’t change. <PortalAuthGate> handles this end to end: an impersonation link arrives with the grant in the URL fragment, portalAuthClient.getAccessToken picks it up on first call, trades it for a session, and re-renders. A host that carries the grant some other way (a postMessage, a server render) can call client.verifyGrant(grant) directly. Because the pickup lives in getAccessToken (SDK 0.1.2 and later), any host that calls it gets the handover, and a portal picks it up on its next build — portal builds install the latest published SDK. Disclosure is guaranteed: if the shell renders <ImpersonationBanner> (or <PortalAuthGate>, which renders it for you) that banner owns the on-screen notice; if it doesn’t, the SDK draws a plain fallback bar itself so a portal built from an older template never silently acts as a customer. Mounting your own <ImpersonationBanner> suppresses the fallback.
Response 204 — always. Unauthenticated by design: the refresh token is the credential, and presenting one can only ever end the session it belongs to. Revokes the session and its whole refresh family, so the tab can’t silently re-mint. The banner’s Stop button calls this via client.stopImpersonation().
Every impersonation is recorded in a server-side audit record — the acting admin’s sub and email, the customer they were viewing, and the build the session was pinned to. That record is the durable audit trail: the in-portal banner is a live disclosure a human can see, and can be hidden by a hostile page; the record survives regardless.

Project endpoints (/sdk/v1)

Authenticated by the session token’s own tenant_id + customer_id claims (token-only — the publishable key is not required here). All errors return { "detail": { "code": "...", "message": "..." } }.
These routes scope every query by the signed customer_id, so they require a portal-session token. A token minted by your own backend without that claim is rejected with 401 not_a_portal_session, even though it is validly signed and works for chat.
Comments posted through the SDK are always customer-visible (external), and an external session only ever sees external comments. Editing is author-only and text-only; it does not yet have an SDK client method. For module=portal, the config response exposes the account’s onboarding project under the onboarding_project__c_ key — the Onboarding Project lookup field on the Account, resolved for the account the token is scoped to. null means that account’s field is unset. See Portal SDK: Project API. Error codes

Account and contact endpoints

/sdk/v1/account reads and edits the account the session token is scoped to. /sdk/v1/me reads and edits the signed-in contact, matched by the session’s email. Use them to show customers their own account or profile data, or to let them update it from the portal. These routes only serve fields the portal exposes in Settings → Account fields and Settings → People fields. Every field starts hidden. See Exposing account and people fields. Reading. Pass the keys you want in fields, comma-separated, up to 100 per request. The response includes only keys that are both requested and exposed. Unexposed or unknown keys are left out silently. With no fields, the response is empty, never every exposed field.
schema[].editable is true only for fields exposed as Edit that can currently be saved. type can also be list for text-list fields, whose values are arrays of strings. meta.view is draft for a preview session and live otherwise. Editing. Send only the changed values in properties. Every key must be exposed as Edit. Statisfy validates the whole body before writing anything. Then it saves the change the same way as an edit in the Statisfy app, including CRM sync. The response returns the edited fields as they now read.
  • null clears a custom field, unless the field is mandatory. Standard fields can’t be cleared.
  • Text values are capped at 1,000 characters, text-area values at 10,000, and URLs at 2,048. URL values must be http or https.
  • Number and currency fields accept whole numbers only. Dates use YYYY-MM-DD.
  • A contact’s email and account associations (cust_ids) are never editable.
  • From a preview session, an edit is simulated. The response shows the result with meta.simulated: true, and nothing is stored.
Error codes Field-level 400 errors include a field key in detail naming the rejected field.

Chat endpoints (/sdk/dw/v1)

Authenticated by the publishable key (resolves the tenant) plus the session/worker token. A worker that isn’t Active (it’s Archived or a Draft) refuses new messages. The two POST routes return 409 with code: "worker_unavailable" and the message “This assistant isn’t available right now.” The two GET routes still answer, so a customer still sees past threads, including any reply a person approves after the worker stopped.
The chat endpoints live under the /sdk namespace (/sdk/dw/v1/...). The old /dw/v1/workers/... paths were a deprecated alias and have been removed — they now 404. The official SDK calls the /sdk paths; upgrade if you are pinned to an older build that still calls /dw/v1.

Streaming frames

/messages/stream runs the same turn as /messages and sends its outcome as Server-Sent Events. Every reply is checked before it reaches your customer, so nothing is sent while the worker is still working: the reply arrives whole, not token by token. The stream no longer sends tool_call_started, tool_call_completed or tool_call_error frames; forms arrive as next_action on done. The headless client (createDigitalWorkerClient) maps text_delta, done and error to the streamMessage handler callbacks (see Digital Worker Chat). It does not surface pending_approval yet: if your worker holds replies for review, use sendMessage, whose response carries pending_approval.

TypeScript types

The package is fully typed. Public exports: Provider & chat
workerId is optional in the type because a portal component in config mode takes its worker from portal.runtime.json instead. Supply workerId for non-portal hosts and configId inside a portal — when both are set, configId wins and workerId is ignored with a dev-mode warning.
Forms & messages
Portal accounts — see the account switcher
Portal runtime config — see Portal Runtime Config
Project
OnboardingProject’s configId is required — there’s no embed-mode fallback, so a portal must set it for the component to ever resolve. ProjectModule’s configId and projectId are both optional: supply projectId outside a portal and configId inside one — when both are set, configId wins and projectId is ignored with a dev-mode warning. A ProjectModule pinned via configId resolves only for the account that owns the pinned project; every other signed-in account sees the empty state.
The hook result types — UseProjectResult and UseCommentsResult — are documented field-by-field on the Project Module page.

Security notes

  • The publishable key is safe in client-side code; it only identifies the tenant and can’t authenticate on its own.
  • The session token is short-lived (default 1 hour) and scoped to a single customer. Re-mint it before expiry inside your getToken callback — from your own backend in the vendored path, or via POST /auth/token in a portal. <PortalAuthGate> already does this for you.
  • Cross-origin requests are scoped per publishable key — the gateway only accepts SDK requests from origins configured for your key.
  • The gateway derives customer_id from the signed token, never from client-supplied input, so a customer can only ever read or write their own project and conversations.