Everfur SDK Docs

Quickstart

Get a streaming answer out of the Everfur chat widget. React Native; for a website, see 13-WEB-INTEGRATION.md.

One honest caveat before you start: keys are not self-serve yet. There is no signup page and no dashboard that issues a pk_live_ today; the Everfur team provisions your tenant during onboarding and hands you the values below (contact support@everfur.com). Everything after that is code you write yourself, or code your coding agent writes for you through the Everfur MCP server (12-CLI-AND-MCP.md).


What you need

Value Looks like Where it lives
Publishable key pk_live_… Ships inside your app. Sent as x-everfur-partner-key.
API base URL https://api-staging.everfur.com/api/v1 Ships inside your app. The SDK has no default host and fails config resolution without one.
Secret key sk_partner_… Your backend only. Needed for step 3, not step 2. Never in an app bundle.
Docs key dk_partner_… Only in the MCP server or CLI configuration on a developer machine (EVERFUR_DOCS_KEY). Read-only: it fetches the Everfur contract from GET /partners/contract and opens no other route.

Ask your Everfur contact for the publishable key and the base URL for the environment you are targeting. Ask for the secret key only when you want per-user chat history, and for the docs key when you use the MCP server or CLI.

Test mode is a sandbox tenant on staging, not a separate key. No deployed Everfur environment accepts a pk_test_ key: a pk_test_ key is refused with 401 before any lookup, on staging and on production alike. Your test tenant is a tenant Everfur created as a sandbox on https://api-staging.everfur.com/api/v1, issued an ordinary pk_live_ key; its entitlements report livemode: false, which is how the SDK, the MCP test tools and your own code can tell it apart from a live tenant. If your first request 401s, check which key you pasted and which base URL it belongs to before checking anything else.

Who does what

Everfur You
Creates your sandbox tenant on staging and, later, your production tenant, and enables the capabilities in your plan. Keep sk_partner_… in your backend's secret manager and dk_partner_… on developer machines; ship only pk_live_… in the app.
Issues the publishable key, the secret key and the docs key for each tenant. Write one session route on your backend that authenticates your own user and calls Everfur's mint endpoint with the secret key (step 3), and one getToken in the app that calls it.
Ships the SDK, the MCP server and the CLI. Supply one stable, opaque, non-guessable id per person as userRef (any sign-in protocol on your side is fine).
Serves the partner console and switches each tenant's features on. Register each pet with your own pet id, place the surfaces in your app, and replace every placeholder the MCP plan leaves for you.
Issues production keys only after everfur_verify_integration passes on the sandbox tenant. Run everfur_verify_integration on the sandbox tenant, then swap in the production base URL and the production keys to go live.

Two provisioning facts that bite later

  • Your tenant's client_type must be partner. Anything else is not on this API plane at all.
  • Your tenant needs an entitlement grant covering chat.message.create. The SDK compiles in a default that grants exactly that one capability (compiledDefaultDecisions in src/core/runtime.ts), so chat renders immediately on first mount. The entitlement poller then replaces that snapshot wholesale with the server verdict, on a cadence of at least 30 seconds. An unprovisioned tenant therefore works for half a minute and then goes dark. That is not a bug in your integration.

1. Install

npm install @everfur/sdk react-native-safe-area-context

The package is public on npm under the MIT license, ships prebuilt, and has no postinstall scripts.

react-native-safe-area-context is named explicitly because the React Native EverfurProvider mounts it for you, and it contains native code. Since 0.2.0 it is declared an optional peer, so that a web project installing the package is not handed React Native: no package manager installs it for you, and a React Native app installs it itself, as this step does (in an Expo project, npx expo install react-native-safe-area-context picks the version your SDK supports). A native module needs a rebuild (npx pod-install on iOS, or a new development build on Expo; Expo Go already includes it) before it resolves at runtime.

Peer dependencies for React Native: react >=18, react-native >=0.74, and react-native-safe-area-context >=5, all installed by your app (the last two are declared optional only so the web entry point can do without them). react-native-vision-camera is optional and is not needed for chat. Records use your app's document picker and upload transport, so choose the native modules compatible with your Expo SDK; the SDK does not declare Expo file-system or image-manipulator peers. react-native-sse and zod are bundled dependencies; you do not install them.

2. Render chat, anonymous

This is the whole integration. No backend of yours is involved.

import { EverfurProvider, EverfurChat, petRef } from '@everfur/sdk';

export default function Root() {
  return (
    <EverfurProvider
      config={{
        publishableKey: 'pk_live_…',
        apiBaseUrl: 'https://api-staging.everfur.com/api/v1',
      }}
    >
      <EverfurChat petRef={petRef('your-pet-id')} />
    </EverfurProvider>
  );
}

Wrap the tree once, high. EverfurChat renders its own loading, empty, error, and populated states, plus a composer, and turns itself off with a deliberate off-state if your tenant is not entitled. It never renders blank.

petRef is optional. Drop it and you get an unscoped conversation.

What you get is the Everfur consumer app's chat screen: the pet header (a switcher once the user has two or more pets), quick prompts, streamed replies with citations, a timestamp, Copy and feedback thumbs under every reply (POST /widget/v1/messages/{message_id}/feedback), the urgency banner (the server's tier, including the calm general one), and the saved-conversation drawer. Three things are yours to pass, because the SDK depends on no clipboard, picker or camera module: onCopy (your clipboard, else no Copy control), attachments (pick(source) opening your image picker, else no attachment control) and, if you have such screens, onVetPrep / onFindVet / renderVetAction. Your registered font faces go on the provider's theme as fonts. All of it is in 05-SDK-INTEGRATION.md; the web surface adds dictation and needs no onCopy or picker (13-WEB-INTEGRATION.md).

The SDK mints one v4 UUID per runtime and sends it as x-everfur-session-id on every request. In anonymous mode this is what makes you a stable user: the server derives the identity as anon-{session_id}, and substitutes a fresh UUID per request when the header is missing or is not a strict v4. That is why the header is not optional in practice, and why the SDK sends it for you.

3. Personalized, when you want per-user history

Pass a user whose getToken calls your own backend. Use an absolute URL: a relative path does not resolve in a React Native bundle.

import { EverfurProvider, EverfurChat, userRef } from '@everfur/sdk';

<EverfurProvider
  config={{
    publishableKey: 'pk_live_…',
    apiBaseUrl: 'https://api-staging.everfur.com/api/v1',
  }}
  user={{
    userRef: userRef('your-stable-user-id'),
    getToken: () =>
      fetch('https://your-backend.example.com/everfur-session').then((r) => r.text()),
  }}
>
  <EverfurChat />
</EverfurProvider>

On your server, exchange both credentials for a short-lived bearer. @everfur/sdk/server maps its react-native export condition to null, so a bundler fails at resolve time if this import ever reaches an app:

import { mintPartnerSession } from '@everfur/sdk/server';

const partnerSecretKey = process.env.EVERFUR_PARTNER_SECRET_KEY;
if (!partnerSecretKey) throw new Error('EVERFUR_PARTNER_SECRET_KEY is not set');

const result = await mintPartnerSession({
  apiBaseUrl: 'https://api-staging.everfur.com/api/v1',
  publishableKey: 'pk_live_…',
  partnerSecretKey,
  userRef: 'your-stable-user-id',
  // ttlSeconds is optional and clamped to [60, 3600]; omit it and the server defaults to 900.
});

// EverfurResult is a tagged union. Narrow on `ok` before reading `value`.
if (!result.ok) throw new Error(`Everfur session mint failed: ${result.error.code}`);
const { sessionToken, expiresAt } = result.value;

This is the one request in the system that carries both credentials. The device only ever sees sessionToken.

userRef rules the SDK enforces before the wire, because the server enforces them too: 1 to 128 code points, no @, no leading or trailing whitespace (the server strips it, so alice and alice would collapse into one user), and printable by Python's str.isprintable. Bob and bob are different users.


What you should see

Send a message and the SDK issues POST /widget/v1/conversations/{conversation_id}/messages with this body:

{ "message": "My dog has been limping since yesterday." }

The field is message. Sending content returns 422 naming body.message as required. Maximum 4000 characters, counted in code points.

The response is text/event-stream. Frames arrive in this shape:

data: {"status": "reasoning"}

data: {"token": "Limping"}

data: {"token": " that"}

data: {"token": " started"}

data: {"done": true, "conversation_id": "…", "follow_up_questions": []}

{"status": "reasoning"} is the accepted frame. Each {"token": "…"} is one delta. {"done": true, …} is terminal and may also carry message_id and citations. A later {"metadata": true, …} frame can follow with a generated title and follow_up_questions. Failures arrive on the same channel as {"error": "…", "code": "…", "retryable": false}, and the SDK maps them to a typed code such as conversationNotFound, authRejected, or rateLimited.

Reading this yourself is optional. useEverfurChat() exposes the same stream as status, messages, isStreaming, error, send, retryLast, and stop, and EverfurChat renders it.

If chat renders but nothing streams

  • 401 on every request: a pk_test_ key (no deployed environment accepts one), a key issued for another deployment than apiBaseUrl (a staging tenant's key against production, or the reverse), or a sk_partner_ pasted into publishableKey. The SDK reports that last case as the config failure secret-key-as-publishable-key.
  • Config never resolves: apiBaseUrl is absent. The SDK ships no host default. It falls back to the EVERFUR_API_BASE_URL environment variable, and babel-preset-expo inlines only EXPO_PUBLIC_* variables, so an unprefixed variable is present locally and absent in a release bundle. Pass apiBaseUrl in config instead. A config that cannot resolve yields a disabled runtime, not a crash: children render, every capability shows its off-state, and the reason is logged.
  • Works for a minute, then every capability goes off: the first entitlement poll landed and your tenant has no grant. See the provisioning facts above.

What is actually available today

Twenty-five routes are live on /api/v1/widget/v1. Chat is the surface that is finished.

Widget Status
EverfurChat Available. Conversations, messages, SSE streaming, history, feedback (POST /widget/v1/messages/{message_id}/feedback) and image attachments (POST /widget/v1/uploads/initiate) are all live.
EverfurRecords (read) Available where PARTNER_RECORDS_ENABLED is on. The whole records sub-router mounts dark and 404s otherwise. Upload also needs an UploadTransport adapter the SDK does not ship.
PhotoCheckup Preview. photo.checkup.execute is granted on staging, but POST /widget/v1/photo/precheck and POST /widget/v1/photo/analyze do not exist. In-chat analysis exists instead, at POST /widget/v1/conversations/{id}/analyze (multipart, SSE).
EverfurVideo Preview. video.gait.execute is granted on staging, but POST /widget/v1/video/analyze does not exist.
PetStateView Not available. GET /widget/v1/pets/{pet_ref}/state does not exist.

The granted-but-absent rows are a real inconsistency, not a documentation gap. The SDK renders those widgets off. Do not plan a launch around photo or video.

If you go on to records

One sequencing fact, because it is the most common way a records integration stalls: consent is unreachable until the pet is registered. Reading or writing consent for a pet_ref the partner has never upserted returns 404 Pet profile not found. Register the pet first.

import { useEverfur, petRef } from '@everfur/sdk';

const everfur = useEverfur();

const result = await everfur.registerPet(petRef('your-pet-id'), {
  name: 'Marvin',
  species: 'dog',
  breed: 'Labrador Retriever',
  ageYears: 4,
  weightKg: 31.5,
});
if (!result.ok) { /* result.error.code */ }

That calls POST /widget/v1/pets/{pet_ref}, which is an upsert: safe to call again, absent fields are left unchanged, and every profile field is optional. After it succeeds, consent reads return 200 {"granted": false} instead of 404. An absent consent row is always 200 with granted: false, never a 404.


Next

A Flutter SDK ships from a separate repository. This quickstart does not cover it.

Everfur SDK Architecture: React Native and Flutter

How the two client SDKs are built. They are two implementations of one architecture and one wire contract. Flutter (everfur-sdk) is the original; React Native (everfur-sdk-rn) was built to parity against the same contract.


1. The shared design

Both SDKs use the same hexagonal layering (pure logic at the center, framework code only at the edges), and both consume the same backend /widget/v1/* API and the same wire contract.

flowchart TB
  subgraph contract["Shared wire contract (everfur-sdk/contract/)"]
    C1["everfur-chat-sse.schema.json: the SSE stream shape"]
    C2["openapi-overlay.yaml: the /widget/v1 HTTP surface"]
    C3["error-taxonomy.md: canonical error kinds"]
    C4["conformance/sse-vectors.json: parity test vectors"]
  end

  subgraph flutter["Flutter SDK: everfur-sdk (Melos monorepo)"]
    F1["everfur_sdk_client<br/>framework-free: transport, identity, entitlements, theme tokens"]
    F2["everfur_sdk_core<br/>controllers / state machines (chat, records, media)"]
    F3["everfur_sdk<br/>Flutter widgets + screens + theme"]
    F1 --> F2 --> F3
  end

  subgraph rn["React Native SDK: everfur-sdk-rn (tsup + subpath exports)"]
    R1["src/client<br/>framework-free (no React): transport, identity, entitlements, theme"]
    R2["src/core<br/>controllers / state machines (chat, records, media)"]
    R3["src/rn<br/>React components + hooks + theme provider"]
    R4["src/server<br/>node-only: session-mint helper"]
    R1 --> R2 --> R3
  end

  contract -. "both implement" .-> flutter
  contract -. "both implement" .-> rn
  flutter --> API["Backend /api/v1/widget/v1/*"]
  rn --> API

Why hexagonal: the framework-free client and core layers hold all the logic (transport, auth headers, SSE parsing, entitlement resolution, controller state machines). The UI layer is a thin skin. This is what lets the exact same behavior live in two languages, and it is what the shared conformance vectors verify.


2. Package / subpath layout, side by side

Concern Flutter (everfur-sdk) React Native (everfur-sdk-rn)
Framework-free logic package everfur_sdk_client subpath @everfur/sdk/client (src/client)
Controllers / state package everfur_sdk_core subpath @everfur/sdk/core (src/core)
UI (widgets/components) package everfur_sdk (lib/src/screens, widgets, theme) subpaths @everfur/sdk, ./chat, ./records, ./photo, ./video, ./animations
Server mint helper (partner backend, any lang) subpath @everfur/sdk/server (node)
Test seam conformance vectors + test_app @everfur/sdk/testing, ./testing/rn (MockTransport)
Monorepo tooling Melos per-capability subpath bundles (tsup), bundle-size gate

The RN SDK splits the UI into per-capability subpaths (./chat, ./records, and so on) so importing one capability does not pull the others in. This is enforced by a per-capability bundle-size gate. Flutter ships one everfur_sdk UI package with the capabilities as screens and widgets inside.


3. The integration surface (same shape in both)

A partner wraps their app once in a provider and drops in self-gating capability widgets.

flowchart LR
  App["Partner app"] --> P["EverfurProvider / EverfurSdk\n(publishableKey + getToken + activePet)"]
  P --> Chat["EverfurChat"]
  P --> Rec["EverfurRecords"]
  P --> More["Photo / Video / Animations"]
  P -. "polls" .-> Ent["/widget/v1/entitlements"]
  Ent -. "capabilities + branding (render_hints)" .-> P
  Chat -. "self-gates on 'chat'" .-> Ent
  Rec  -. "self-gates on 'records'" .-> Ent
  • One provider. publishableKey (safe on device) plus optionally a getToken that proxies to the partner's backend (for the minted session) plus activePet.
  • Drop-in widgets render all states (loading / empty / error / populated) and self-gate on the customer's entitlement. A disabled capability shows a deliberate off-state, never a blank screen.
  • Server-driven theming. Per-partner colors, logo, and copy arrive in the entitlements render_hints and re-skin the widgets, so the same components look native to each partner with zero client work.

4. The capability model

Every capability (chat, records, photo, video, animations) follows the same three-part shape in both SDKs:

flowchart TB
  UI["UI widget (EverfurChat / EverfurRecords / …)"]
  Ctrl["Controller / state machine (core)"]
  Repo["Repository + Transport (client)"]
  Gate["CapabilityGate / useCapability"]
  UI --> Ctrl --> Repo --> HTTP["/widget/v1/… (chat, records, media)"]
  UI --> Gate --> Verdict["entitlement verdict (live poll)"]
  • client: the repository plus transport (HTTP + SSE), auth headers, wire parsing.
  • core: the controller, a framework-free state machine (for example the chat streaming machine, or the records request/upload/read machine).
  • UI: the widget that renders the controller's state and gates on the entitlement.

Because the controller and repository are framework-free, the RN and Flutter UIs are thin and behaviorally identical. The same conformance vectors (contract/conformance/sse-vectors.json) drive both.


5. How parity is kept

flowchart LR
  spec["Backend /widget/v1 + SSE"] --> contract["contract/ (schema + overlay + vectors)"]
  contract --> flutter["Flutter SDK conformance tests"]
  contract --> rn["RN SDK conformance tests"]
  flutter -. "must match" .-> contract
  rn -. "must match" .-> contract

The contract/ directory is the source of truth: the SSE schema, the OpenAPI overlay for /widget/v1, the error taxonomy, and a set of SSE conformance vectors. Both SDKs run those vectors through their real parser, so a backend change or a behavior drift is caught in both languages the same way. This is why "RN parity with Flutter" is a testable claim, not a vibe.

Integrating the Everfur SDK into your app

For a partner developer adding Everfur to their React Native or Flutter app. You wrap your app once, drop in the capability widgets you want, and (for personalized answers) implement one small token function on your backend. The publishable key is the only Everfur credential that ships in the app.


What you get from Everfur first

From onboarding you receive:

  • pk_live_…: publishable key, safe to ship in your app.
  • sk_partner_…: session secret, for your backend only (personalized mode). Never ship this.
  • your API base URL (e.g. https://api.everfur.com/api/v1).

1. React Native

Install

npm install @everfur/sdk

Public on npm, MIT licensed, prebuilt, no postinstall scripts. The package is @everfur/sdk. Each capability is a separate subpath so importing ./chat does not pull ./records:

Subpath Contents
@everfur/sdk EverfurProvider, useEverfur, useCapability, CapabilityGate, theme hook, EverfurChat
@everfur/sdk/chat EverfurChat, useEverfurChat
@everfur/sdk/records EverfurRecords, useEverfurRecords, EverfurClinicRequest, EverfurClinicRequestBatch, SignaturePad, records types
@everfur/sdk/records/depth record depth (EverfurRecordsTimeline, EverfurRecordDepth, EverfurDocumentContributions), its own entry, dark until Everfur enables it
@everfur/sdk/photo PhotoCheckup, usePhotoCheckup
@everfur/sdk/video EverfurVideo, useEverfurVideo
@everfur/sdk/animations PetStateView, useAnimations
@everfur/sdk/server mintPartnerSession (Node, backend only: the subpath maps the react-native export condition to null, so an RN bundler fails at resolve time rather than bundling it)
@everfur/sdk/client, ./core Framework-free layers (advanced / testing)
@everfur/sdk/testing, ./testing/rn MockTransport + a preview seam (not production)

Wrap your app once

import { EverfurProvider, petRef, userRef } from '@everfur/sdk';

<EverfurProvider
  config={{
    publishableKey: 'pk_live_…',                    // required; safe on device
    apiBaseUrl: 'https://api.everfur.com/api/v1',   // required here or via EVERFUR_API_BASE_URL
  }}
  activePet={petRef('your-stable-pet-id')}          // optional default pet scope (a PetRef, not an object)
>
  <App />
</EverfurProvider>

config is an EverfurConfig object (not flat props). Only publishableKey is strictly required, and apiBaseUrl is required here or via env. If either cannot be resolved the SDK does not throw: it mounts a disabled runtime, your children still render, every Everfur capability shows its off-state with disabled.level === 'sdk_config', and the cause is printed to the console and reported on telemetry. That matters in a release build, where a throw from the provider would sit above your error boundaries and present as a blank screen. Note that babel-preset-expo inlines only EXPO_PUBLIC_* variables, so an unprefixed env var resolves to undefined at runtime even though every local check passed. Everything else is optional platform seams: storage/secureStore (a token only ever crosses to disk via secureStore), telemetry, logSink, camera/upload (native capture for photo/video), uploadTransport (records document upload: createRnRecordsUploadTransport() on React Native), debug.

Drop in the capability widgets

Every widget self-gates on the tenant's live entitlement and renders all four states (loading / empty / error / populated) plus a deliberate off-state when the capability is disabled, never a blank screen.

import { EverfurChat, petRef } from '@everfur/sdk';
import { EverfurRecords } from '@everfur/sdk/records';
import { PhotoCheckup } from '@everfur/sdk/photo';
import { EverfurVideo } from '@everfur/sdk/video';

<EverfurChat    petRef={petRef('your-pet-id')} />
<EverfurRecords petRef={petRef('your-pet-id')} onPickDocument={pickDocument} consentVersion={YOUR_CONSENT_VERSION} />
<PhotoCheckup   petRef={petRef('your-pet-id')} />
<EverfurVideo   petRef={petRef('your-pet-id')} />
Widget Required props Does
EverfurChat petRef?, conversationId?; seams onCopy?, attachments?, onPetChange?, onVetPrep?, onFindVet?, renderVetAction? the consumer app's chat screen: streaming AI-vet chat, quick prompts, citations, feedback thumbs, the urgency banner, the history drawer, image attachments, the clinical footnote under the composer (see What EverfurChat carries)
EverfurRecords petRef, onPickDocument, uploadTransport?, consentVersion? records timeline + document upload, consent-gated
PhotoCheckup petRef, region? photo capture + AI analysis (severity/confidence)
EverfurVideo petRef? gait/live-scan video capture + analysis
PetStateView petRef, plus explicit repo/cache/kv never-blank pet-state media (needs manual wiring, unlike the others)

consentVersion is not an Everfur value: pass the version of your own consent text, the one the user actually agreed to (1 to 64 characters). The server stores it verbatim with the grant, and without it the grant button renders disabled. Records upload on React Native needs your own document picker (onPickDocument) and the upload transport from createRnRecordsUploadTransport() (on EverfurConfig.uploadTransport or the uploadTransport prop), and the pet must be registered with registerPet first. See 09-GUIDE-RECORDS-AND-CONSENT.md.

Conversation history and resuming

EverfurChat includes a history control (Chats) and loads messages when given conversationId. It does not create an empty replacement for a thread it cannot read: a denied or missing thread shows the typed error. History is held in memory only and clears immediately on a user or pet switch.

For a custom UI, useEverfurChat({ petRef }) exposes:

  • listConversations({ cursor?, limit? }): the signed user's conversations for that pet; limit 1–100.
  • getMessages(conversationId, { cursor?, limit? }): a chronological message window; limit 1–200.
  • resumeConversation(conversationId): clears the old thread and loads the latest window.
  • loadOlderMessages(): prepends the next older window without duplicating the boundary message. historyCursor is null at the beginning of history; isLoadingHistory exposes pending state.

Each method settles to an EverfurResult. Treat cursors as opaque and use them with the same identity, pet and conversation that produced them. Do not store history, titles or cursors in analytics or logs. The paired backend's pet-scoped history filter must be deployed before relying on pet filtering.

Completed replies retain server-authored urgency; the prebuilt screen reuses the consumer app's existing urgency labels. It does not infer urgency from message text or automatically start a vet visit.

What EverfurChat carries

EverfurChat is the Everfur consumer app's chat screen, screen for screen: the pet header and its switcher, the empty thread's quick prompts, streamed replies with their citations, the footer under every settled message (timestamp, copy, the three thumbs), the urgency banner, the saved-conversation drawer and image attachments. Most of it needs nothing from you. The props below are the seams where the app reaches a module the SDK does not depend on, or a screen of yours.

Prop Type What it does
onCopy (text: string) => Promise<void> | void Your clipboard (for example expo-clipboard's setStringAsync). Given, every message gets the app's Copy control, which morphs to a check for 1.5 s. Absent, no Copy control renders: the SDK depends on no clipboard module.
onPetChange (petRef: PetRef) => void Called after the owner picks another pet in the header switcher and the provider has re-scoped to it, so your own screen can follow. The switcher appears with two or more pets from GET /widget/v1/pets; with one pet the header names it and nothing opens.
onVetPrep (pet: WidgetPet | null) => void Your vet-visit preparation screen, opened from the empty thread's Help me prep for {pet}’s vet visit chip (Book a video visit on the web). Absent, the chip opens Everfur's own vet visit entry when Everfur has it switched on for your tenant, else the chip is not shown (it is never sent as text).
onFindVet (level: UrgencyDisplayLevel) => void Your vet finder behind the urgency banner's Find a vet pill on an emergency or schedule_soon reply. The pill opens Everfur's vet visit entry when it is on for your tenant, else calls this, else does not render.
renderVetAction (level: UrgencyDisplayLevel) => ReactNode Your own control in the banner's action slot for those two tiers, in place of the pill; wins over both the vet visit entry and onFindVet.
attachments EverfurChatAttachments Your image picker. Given, the composer gets the app's attachment control, the previews, the lightbox and the thumbnails on a sent turn.
onOpenUrl (url: string, kind: 'citation') => void | Promise<void> Your opener for the source page behind a citation card (always https). Absent (the default), a source card is read-only: its title, meta line and excerpt show, and nothing takes the member out of your app. Given, the card becomes a link and a tap goes here, so you can show the page in your own in-app browser. To open the system browser as the Everfur app does, pass (url) => Linking.openURL(url). A throw or a rejection (a device with no handler for the link) is contained.
keyboardVerticalOffset number How far the top of the view that holds the chat sits below the top of the screen, in points: React Native's KeyboardAvoidingView prop of the same name, handed to the chat's own avoiding view so the composer clears the keyboard. Absent (the default, and the recommendation), the chat measures it on iOS from its own position in the window, with no navigation dependency: under a React Navigation or Expo Router native-stack header, and in a JavaScript-stack modal, where it measures again until the card has finished sliding in. A chat with nothing above it measures 0, the offset React Native uses when none is given. A value you pass is used as given, in place of the measurement, and the obvious value is wrong in two cases (measured on an iPhone 17 simulator, iOS 26.5). In a JavaScript-stack modal, useHeaderHeight() returns the header alone (56), while the chat starts 128 pt down (the 62 pt status-bar margin, the card's 10 pt offset and the header). Omit the prop there. On iOS 26, useHeaderHeight() reads 100.67 on its first render and 116 after, so a value captured once at mount is about 15 pt short: pass the hook's value from every render, never a copy taken at mount. Android is unaffected: there the window's own resize lifts the composer.

Attachments. The SDK depends on no picker or camera module, so you pass pick(source): open your own picker and resolve null when the user cancels, else one FileHandle (or several for a multi-select) with the local uri, the image mimeType (image/jpeg, image/png, image/webp, image/heic, image/heif) and sizeBytes. The queue validates type and size (10 MiB) before any request, mints the presigned policy (POST /widget/v1/uploads/initiate), posts the bytes to the media bucket and sends the returned keys as image_s3_keys on the message. The bytes go to the bucket through React Native's own XMLHttpRequest, which streams the file at uri from disk, never through the global fetch: Expo installs expo/fetch there, and it cannot post a local file part. With expo-image-picker, open the library DIRECTLY: do not request the media-library permission first. The picker runs in its own sheet and needs no permission, and one denied permission request kills the picker for good on iOS.

// docs:no-compile (expo-image-picker is your dependency, not the SDK's)
import { EverfurChat, petRef } from '@everfur/sdk';
import type { EverfurChatAttachments } from '@everfur/sdk';
import * as ImagePicker from 'expo-image-picker';

const attachments: EverfurChatAttachments = {
  pick: async () => {
    // No permission request before this call: the library picker needs none.
    const res = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ['images'], quality: 0.85 });
    if (res.canceled) return null;
    const asset = res.assets[0];
    return { uri: asset.uri, mimeType: asset.mimeType ?? 'image/jpeg', ...(asset.fileSize ? { sizeBytes: asset.fileSize } : {}) };
  },
};

<EverfurChat petRef={petRef('your-pet-id')} attachments={attachments} />;

Resizing and EXIF are yours: the consumer app resizes to 1920 px, re-encodes as JPEG 0.85 and strips EXIF (location, device) before an upload, and the SDK uploads the bytes at the uri as they are, so do the same in pick (for example with expo-image-manipulator) before resolving. sources (['photo'] by default, or ['photo', 'camera'] for a two-tile popover) and maxPerMessage (1 by default, at most 5, the server's cap) are the other two fields.

Built in, no prop.

  • Feedback thumbs. Every assistant reply the server gave an id carries Helpful, Neutral and Not helpful, recorded at once through POST /widget/v1/messages/{message_id}/feedback ({feedback_type}: helpful, neutral, not_helpful); the same tap again clears the rating locally. A thumbs-down also opens the app's reasons sheet (What went wrong?, five chips, optional notes up to 4000 characters), whose submit upgrades the type (wrong, outdated, dangerous, unclear, or not_helpful with notes).
  • Citations. The sources a reply cites (citations on the done frame and on history rows) render as the app's cards under the bubble. They are display data; the SDK never reasons over them. They are read-only unless you pass the onOpenUrl seam above, which decides where a tapped source opens.
  • Timestamps. Every settled row shows its time in the reader's locale (ChatMessage.createdAt: the wire created_at on a history row, the SDK's clock on a live turn).
  • The history drawer. The signed-in user's saved conversations for the pet (GET /widget/v1/conversations), with the app's search, unread counts and load-on-scroll paging; opening one resumes it. An anonymous session has no history to list.
  • The urgency banner. The server's tier on the latest classified reply: emergency (Call a vet now, with the ASPCA Animal Poison Control line and, when your onFindVet is the route, Nearest emergency vet), schedule_soon (Vet care suggested soon), home_care (Monitor at home) and the calm general tier (Good to know: the server classified the reply as informational, with the app's Just general info about {pet} line). A later reply the server did not classify clears it, and the SDK never infers urgency from message text. The tier reaches onFindVet and renderVetAction as UrgencyDisplayLevel.

Anonymous vs personalized

There are three identity modes, and the MCP planner asks you to pick one (never free text):

  • Anonymous (publishable key only): omit user. The app talks to Everfur with just the pk and a user-ref. General (non-per-user) answers, no session minted. Simplest to stand up; good for testing.
  • Personalized, partner-user-ref (recommended for production): pass a user with a getToken. Answers and entitlements are per-user, and a short-lived session JWT is minted server-side.
  • Shopify storefront, shopify-app-proxy: the storefront's Everfur app block calls a path on the store's own origin, Shopify signs the proxied request and names the signed-in customer, and Everfur mints from that signature. No partner backend mints. See 14-SHOPIFY.md.

Everfur is auth-protocol agnostic. How you sign people in is your business: OAuth, a JWT, Firebase, Cognito, a server session, a magic link all work, because Everfur never sees the credential. In partner-user-ref mode it needs exactly one thing from your side: a stable, opaque, non-guessable id per person (no email addresses, no sequential ids, at most 128 characters, no @). Your users do not create an Everfur account to use chat or records inside your app. Vet visits are the one exception: to see a vet the person opens an Everfur sign-in page on everfur.com, because a visit lives under their own Everfur account (16-TELEVET-VISITS.md).

What the SDK does and what your code must do, in partner-user-ref mode:

The SDK handles Your code must
Calls getToken lazily on the first authenticated request, and only then. Provide userRef: your stable, opaque id for the signed-in person.
Caches the token in memory only, and shares one in-flight mint across concurrent requests. Provide getToken: one function that POSTs to your own backend route and returns the token as a string.
Sends Authorization: Bearer <token> (and your x-everfur-user-ref) on every request, never the publishable key alongside it: both on one device request is a 403. On that route, authenticate your own user first, then call Everfur's mint endpoint with the secret key (the Node helper below) and return the token as the plain-text body.
On a 401, drops the cache, calls getToken again and replays the request once; a second 401 is a terminal auth error. Keep sk_partner_… in a secret manager on the server. Never take the user id from the request body.
Clears the cached token on logout(). Call logout() on the Everfur handle when your user signs out, so the next person never inherits a session.
import { EverfurProvider, petRef, userRef } from '@everfur/sdk';

<EverfurProvider
  config={{ publishableKey: 'pk_live_…', apiBaseUrl }}
  user={{
    userRef: userRef('your-stable-user-id'),   // opaque, CASE-SENSITIVE; keep it consistent per user
    getToken: async () => mintOnYourBackend(),  // returns a token string
  }}
  activePet={petRef('your-pet-id')}
>
  <App />
</EverfurProvider>

Keep userRef case-consistent. It is an opaque, case-sensitive identity. Bob and bob are two different users. Map each of your authenticated users to one stable ref and never vary its case.

Warm up during your own login

Everfur must not add a visible loading step of its own after your sign-in. Call useEverfur().prepare({ petRef }) from your login flow (or pass warmUp="onMount" to EverfurProvider): it mints the session token, resolves the entitlement verdict and primes that pet's chat, so the first EverfurChat or EverfurRecords you mount renders its content on its first commit with no skeleton and no spinner. It settles to a PrepareResult (ready, partial or failed, plus durationMs, the login-to-ready number, also emitted as sdk.prepare.timing on your telemetry port) and never throws; concurrent calls coalesce and a repeat after ready costs nothing. A mount you did not warm up keeps its skeleton and its delayed spinner, which renderPending on EverfurChat and EverfurRecords lets you replace with your own placeholder (or null). If your getToken returns { token, expiresAt } (the expiresAt from mintPartnerSession, in epoch seconds), the SDK re-mints about a minute before expiry so no request pays a 401 round trip mid-session; a plain string still works, with the 401 path as the fallback.

The getToken contract

  • Signature: () => Promise<string>, no arguments, returns a token string (not an object).
  • The SDK calls it lazily on the first authenticated request and caches the token in memory only (never on disk).
  • On a 401, the SDK force-refreshes (drops the cache), calls getToken again, and replays the request once. A second 401 surfaces a terminal auth error. A 403 is returned as-is (not a refresh, not a sign-out).
  • getToken must proxy to your backend, which holds the secret. The device never calls Everfur's mint directly. The shape the planner generates, and the one every example in these docs uses: your route answers 200 with the token as a text/plain body, and getToken returns res.text().
// docs:no-compile
// The client half, exactly as the MCP planner writes it (absolute URL on React Native).
getToken: async () => {
  const res = await fetch('https://your-backend.example.com/api/everfur/session', { method: 'POST' });
  if (!res.ok) throw new Error('session mint failed: ' + res.status);
  return res.text();
},

Your backend: mint the session

On your server (never the device), use the Node helper:

import { mintPartnerSession } from '@everfur/sdk/server';

// inside YOUR /session endpoint, after authenticating YOUR user:
const secretKey = process.env.EVERFUR_PARTNER_SECRET_KEY;   // sk_partner_… , from a secret manager
if (!secretKey) throw new Error('EVERFUR_PARTNER_SECRET_KEY is not set');

const result = await mintPartnerSession({
  apiBaseUrl: 'https://api.everfur.com/api/v1',
  publishableKey: 'pk_live_…',
  partnerSecretKey: secretKey,
  userRef: 'your-stable-user-id',
  // ttlSeconds?: clamped [60, 3600], server default 900
});

// EverfurResult is a TAGGED UNION: narrow on `ok` before reading `value`.
// (Destructuring `const { value } = ...` does not compile, and is the one mistake this
//  snippet used to make.)
if (!result.ok) {
  // result.error: { code, displayMessage, requestId? }. Quote requestId in a support ticket.
  throw new Error(`Everfur session mint failed: ${result.error.code}`);
}
// Answer with the token as the plain-text body (content-type text/plain, cache-control no-store), and
// nothing else: that is what the device's getToken reads with res.text().
return result.value.sessionToken;

mintPartnerSession POSTs to ${apiBaseUrl}/widget/v1/sessions carrying both credentials (x-everfur-partner-key + x-everfur-partner-secret-key), the one legitimate two-credential call, and it happens on your server. The token is signed, holds no secret, and lives 15 minutes by default (ttlSeconds is clamped to 1 minute to 1 hour). Your device getToken then just fetches this token from your /session endpoint.


2. Flutter

Flutter currently exposes the chat capability (EverfurChat / EverfurChatScreen). Records/photo/video/animations widgets are RN-only today.

import 'package:everfur_sdk/everfur_sdk.dart';

// 1. init once (e.g. in main())
await EverfurChat.init(
  publishableKey: 'pk_live_…',
  apiBaseUrl: 'https://api.everfur.com/api/v1',
  theme: const EverfurChatTheme(displayName: 'Your Vet Chat', primaryColor: yourColor),
);

// 2. personalized (optional): same token contract as RN, Future<String> Function()
EverfurChat.setUser(
  userRef: 'your-stable-user-id',
  getToken: () async => mintOnYourBackend(),
);

// 3. open the chat
EverfurChat.open(context, petRef: 'your-pet-id');
// or embed it:
final widget = EverfurChat.chatWidget(petRef: 'your-pet-id');

Flutter is a static singleton facade (init / setUser / setActivePet / setTheme / open / chatWidget / logout) rather than a React component, but the token contract is identical: getToken takes no args and returns a token string, minted by your backend with the secret key.


3. Server-driven behavior (no app update needed)

  • Which capabilities show is controlled by the tenant's entitlements, which the SDK polls live. Everfur can enable/disable a capability for your tenant without you shipping an app update.
  • Branding (colors / logo / copy) is set on Everfur's side, arrives in the entitlements render_hints and re-skins the widgets, so they look native to your product with zero client work. Your own theme prop can override it field by field (see the Theme section below). Whether "powered by everfur" is shown is decided by the server from your plan; hide_powered_by in a theme has no effect. On React Native, a branding font_family applies only if your app has registered that font; otherwise the system font is used.

4. Theme

Every widget paints from one resolved theme (useEverfurTheme()), and four layers meet in it:

Layer Who Where it is set
Tenant branding Everfur, per tenant the console; arrives in the entitlements render_hints.branding (wire schema_version: 2)
Your code you, the partner developer the theme prop on EverfurProvider (or Everfur.init({ theme }) in frame mode)
The end user their device light/dark, text size, reduced motion, more contrast; read by the SDK, never configured
The planner the MCP integration planner writes the first two layers for you when it auto-matches your site; nothing extra at runtime

Precedence is per field. A field the tenant lists in locked_fields (plus hide_powered_by, always) is server-wins; every other cosmetic field is client-wins: a value on your theme prop overrides the console value, and the console fills whatever your prop leaves unset. slots in locked_fields locks every slot, slots.<name> locks one. The device layer is applied last, on top of the merged result, and is never a value you set. A server branding written for a wire version this SDK does not know is ignored whole (the compiled default plus your theme render); nothing throws.

import { View } from 'react-native';
import { EverfurProvider, useEverfurTheme } from '@everfur/sdk';
import type { EverfurThemeInput } from '@everfur/sdk';

const theme: EverfurThemeInput = {
  primary_color: '#1D3A8A',
  // Absent, the dark palette derives its own variant of primary_color; set it to choose one.
  primary_color_dark: '#9DB4F0',
  surface_style: 'white',
  radius_scale: 'round',
  // The cap on the user's text size, 1 to 2 (default 1.3).
  accessibility_max_font_scale: 1.5,
  slots: {
    sendButton: { backgroundColor: '#1D3A8A', borderRadius: 8 },
    userBubble: { backgroundColor: '#1D3A8A' },
  },
};

// Your own views read the same resolved tokens the widgets paint with.
function BrandDot() {
  const { color, radius } = useEverfurTheme();
  return <View style={{ width: 12, height: 12, borderRadius: radius.pill, backgroundColor: color.primary }} />;
}

export function App() {
  return (
    <EverfurProvider config={{ publishableKey: 'pk_live_…' }} theme={theme}>
      <BrandDot />
    </EverfurProvider>
  );
}

Precedence in one line: a field Everfur locked for your tenant, then your code (the theme prop), then the tenant's console branding (your tenant's values over Everfur's platform default), then the SDK's compiled default.

Appearance (the Stripe-shaped way). If you have themed Stripe Elements or Connect embedded components, the same shape works here: appearance: { theme, variables, rules } on the theme prop. It is not a second model: it expands onto the flat fields below, so it has exactly their precedence, locks and contrast guard, and the console accepts the same object. Inside one theme prop: preset < variables < rules < any flat key written beside it.

import { EverfurChat, EverfurProvider } from '@everfur/sdk';
import type { EverfurThemeInput } from '@everfur/sdk';

const theme: EverfurThemeInput = {
  appearance: {
    theme: 'minimal', // 'everfur' (the default), 'minimal' or 'night' (the dark palette)
    variables: {
      colorPrimary: '#1D3A8A',
      colorBackground: '#FAF7F2',
      colorText: '#1A1A1A',
      colorDanger: '#B00020',
      fontFamily: 'Inter',
      fontSizeBase: '16px',
      borderRadius: 10,
      spacingUnit: 5,
    },
    rules: {
      userBubble: { backgroundColor: '#1D3A8A', textColor: '#FFFFFF' },
    },
  },
};

<EverfurProvider config={{ publishableKey: 'pk_live_…' }} theme={theme}>
  <EverfurChat />
</EverfurProvider>;
Variable Flat field Range and meaning
colorPrimary, colorAccent primary_color, accent_color the brand fill (bubbles, buttons) and the link/highlight colour
colorBackground background_color the surface, page, chat ground and assistant reply; the cream and subtle grounds derive from it
colorText text_color body text
colorDanger danger_color errors and destructive actions; without danger_color_dark the dark palette derives its own from it
fontFamily font_family as font_family below
fontSizeBase font_size_base the body size in px, 12 to 20 (default 17); every size scales from it, and no scaled size drops below 10px
borderRadius border_radius the medium corner in px, 0 to 32 (default 14); every radius but the pill scales from it. The finer twin of radius_scale; the two lock each other
spacingUnit spacing_unit the smallest spacing step in px, 3 to 6 (default 4); every step scales from it
colorScheme brightness light, dark or auto

With a dark scheme (colorScheme: 'dark' or the night preset) the colour variables land on the dark palette's fields (primary_color_dark, background_color_dark, text_color_dark, danger_color_dark). Otherwise they paint the light palette, and the dark palette derives its primary and accent and keeps Everfur's own dark grounds; set background_color_dark / text_color_dark / danger_color_dark to choose them. Sizes out of range are clamped. Every colour is guarded: body, muted, meta, timestamp, danger, error and warning text are pushed until they read at 4.5:1 on every ground they are drawn on, and the composer placeholder and the input edges until they read at 3:1, toward white when your background is dark (the inks you did not set start from Everfur's dark palette then), so no combination can paint unreadable text (the console also refuses such a combination before it is published).

rules reads the nine slot names only (composerInput, sendButton, userBubble, assistantBubble, vetBubble, recordCard, vetVisitButton, primaryButton, link). Stripe's .Class rule keys (.Input, .Tab:hover, .Label) are not slots and are ignored, as is any other unknown key.

Fields (snake_case as the console writes them; camelCase twins are accepted): display_name, logo_url, logo_url_dark, primary_color, accent_color, primary_color_dark, accent_color_dark, brightness, font_family, font_url, fonts, font_urls (web only), chat_placeholder, surface_style (white, cream, subtle; on the light palette cream is white since the design tokens retired Off-White), radius_scale (sharp, default, round), accessibility_max_font_scale, slots, background_color, background_color_dark, text_color, text_color_dark, danger_color, danger_color_dark, font_size_base, border_radius, spacing_unit, appearance. Unknown keys are ignored and malformed values are dropped; the SDK never throws on a theme.

Fonts. The Everfur app maps every weight to a registered FACE (Azeret-Regular, Azeret-Medium, Azeret-Bold, AzeretMono-Regular) and never pairs a named face with a synthetic fontWeight; fonts lets your app do the same. You load the font files yourself (expo-font's useFonts, or the native asset catalog) and pass the registered NAMES per weight, any subset: a missing weight falls back along regular -> medium -> bold, and mono stands alone. The SDK bundles no font. fonts beats font_family for the weights it names. The Everfur design tokens name Azeret too, but the compiled default stays the platform font: Azeret reaches a surface only when a branding supplies it through fonts (or font_urls on the web), like any other face. On the web, font_urls may name a woff2 per face, but only on the Everfur SDK CDN (sdk.everfur.com/fonts/, the SDK's allow-list); a font you serve from your own origin is loaded by your own @font-face and passed by name in fonts (see 13-WEB-INTEGRATION.md).

import { EverfurChat, EverfurProvider } from '@everfur/sdk';
import type { EverfurThemeInput } from '@everfur/sdk';

// The faces registered with expo-font / the native asset catalog; names only, no files.
const theme: EverfurThemeInput = {
  fonts: { regular: 'Azeret-Regular', medium: 'Azeret-Medium', bold: 'Azeret-Bold', mono: 'AzeretMono-Regular' },
};

<EverfurProvider config={{ publishableKey: 'pk_live_…' }} theme={theme}>
  <EverfurChat />
</EverfurProvider>;

Slots repaint one surface each, and nothing else (structure and copy never change): composerInput, sendButton, userBubble, assistantBubble, vetBubble, recordCard, vetVisitButton, primaryButton, link. vetBubble is a message a veterinarian wrote into the owner's thread (role: 'vet'); by default it is pink, under the vet's name, so it never reads as the assistant's answer. Each accepts backgroundColor, borderColor, borderRadius and textColor (the console's background_color, border_color, border_radius, text_color are read too). A slot replaces the console's slot of the same name whole. When a slot supplies a background, its text colour is guarded: a missing or illegible textColor becomes the legible black or white for that background (readableOn).

Text size. The user's text setting is honoured on both hosts and capped by accessibility_max_font_scale. React Native scales every SDK Text natively and passes the cap as maxFontSizeMultiplier; the resolved type.size stays the authored size. On the web the SDK reads the root font size (the browser's text-size setting; a root under 16px is read as a page convention, not a preference) and the resolved type.size already carries the scale, applied inline, no <style> element. type.scale reports the effective scale on both.

Dark and high contrast. brightness picks the palette: absent, it is light, as Everfur's own apps are, even on a dark-mode device; 'auto' opts in to the device scheme and 'dark' pins the dark palette. On the dark palette a *_color_dark is used when set and otherwise DERIVED from the light colour by the same algorithm the console runs (deriveDarkVariant on @everfur/sdk/client: the hue and saturation kept, the lightness lifted into a band that reads on the dark surface), so the console preview and the device agree byte for byte. logo_url_dark swaps the logo on the dark palette. prefers-reduced-motion collapses motion on both hosts; prefers-contrast: more (web) derives a high-contrast palette (text and borders pushed toward 7:1, translucent washes replaced with solid fills; theme.highContrast says so). React Native 0.74 exposes no high-contrast signal, so that host renders the normal palette.

Previewing a draft. previewTheme(theme, hints, options?) (root, web and client entries) resolves a draft exactly as the device would, from the raw theme prop and the server-shaped branding object, with the device inputs of the preview; the console uses it to render the real components against a draft.


5. Reference integration + testing

  • The reference integration is examples/minimal-integration.tsx in the SDK repository. It is type-checked by npm run docs:check on every run, so it always compiles against the current SDK. Copy this shape. (A fuller internal app, everfur-integration-example-rn / "Pawtrail", is not published where a partner can reach it; treat any reference to it as internal-only.)
  • Run the SDK offline with @everfur/sdk/testing (MockTransport), feed scripted wire responses without a backend.
  • Before you ship: confirm no sk_ secret is in your bundle or logs (the reference app has a check:no-secrets gate you can mirror).

6. Bundle size per entry point

What each React Native entry adds to your app, in gzip bytes. Measured is the published @everfur/sdk 0.5.4, weighed the way the SDK's own bundle-size gate weighs it (tests/supply-chain/bundle-size.test.ts): the entry's minified dist/<entry>/index.js plus every chunk it imports, gzip level 9, under Node 20 (the version CI runs). React, React Native and the optional native peers are not included: your app provides them. Budget is the line that gate enforces on every change to the SDK: an entry that grows past it fails CI, so a larger entry is a deliberate, reviewed change rather than a surprise in your app.

Entry What it contains When it runs Measured, 0.5.4 (gzip bytes) Budget (gzip bytes)
@everfur/sdk/chat EverfurChat (the consumer app's chat screen) and useEverfurChat when you import it 53,799 58,700
@everfur/sdk/records EverfurRecords (the records flow), EverfurClinicRequest, EverfurClinicRequestBatch, SignaturePad, useEverfurRecords when you import it 87,715 96,400
@everfur/sdk/records/depth record depth: EverfurRecordsTimeline, EverfurRecordDepth, EverfurDocumentContributions when you import it 24,195 26,100
@everfur/sdk/video EverfurVideo, useEverfurVideo when you import it 16,291 18,400
@everfur/sdk/photo PhotoCheckup, usePhotoCheckup when you import it 17,411 20,000
@everfur/sdk/animations PetStateView, useAnimations when you import it 16,303 18,500
@everfur/sdk/televet VetVisitButton, useVetVisit, EverfurTelevetHost; none of the visit screens when you import it 14,708 15,300
@everfur/sdk/televet/booking the in-app visit screens: booking, the member's visits, the visit and its Join, the vet thread and the visit summary lazily, on the first press of a vet entry 63,936 67,100
@everfur/sdk/televet/call EverfurVetCall, EverfurVetCallLobby, registerEverfurDaily; the Daily module is yours and not included lazily, when the member joins a visit 10,032 10,500
@everfur/sdk/notifications useEverfurNotifications: device registration, the preferences, the push routing parser when you import it 10,975 11,200
@everfur/sdk/consent EverfurConsentToggle, useEverfurConsent: the member's view of what Everfur holds on record, and the control that changes it when you import it 12,640 12,900
  • Figures add up; they do not overlap. Each entry is built on its own, with no chunk shared between entries, so each figure carries its own copy of the SDK's shared client code. Two entries cost about the sum of their rows.
  • Lazy on React Native means run later, not downloaded later. EverfurChat, VetVisitButton and useVetVisit reach @everfur/sdk/televet/booking through a dynamic import() on the first press of a vet entry, and the booking screens reach @everfur/sdk/televet/call the same way when the member joins (16-TELEVET-VISITS.md). Metro does not split a React Native bundle, so both are still weight in your app binary; they are not evaluated until used. On the web the same imports are real deferred downloads: see 13-WEB-INTEGRATION.md.
  • The package root @everfur/sdk and the framework-free ./client and ./core entries have no budget of their own and are not listed.

API Reference

Every route on the Everfur partner plane, with the fields it accepts and returns.

There is no public OpenAPI document. GET /openapi.json is refused on production hosts, so this page is the reference, and it was written by reading the routers and the response models rather than by transcribing a generated spec. Where a claim was checkable on the wire, it was checked on the wire.

Most partners should not call these routes directly. @everfur/sdk covers all of them and handles the session lifecycle, entitlement polling, retry policy, and the streaming frame protocol. This page is for understanding what the SDK is doing, for debugging a response you did not expect, and for the server to server calls that have no SDK equivalent.


Base URL and versioning

https://api-staging.everfur.com/api/v1     staging
https://api.everfur.com/api/v1             production

Every path below is relative to that base and begins with /widget/v1. The doubled version is not a typo: /api/v1 is the platform prefix and /widget/v1 is the partner plane mounted inside it. A full URL looks like https://api.everfur.com/api/v1/widget/v1/conversations.

There is no date-pinned API version and no version header. Behaviour changes arrive through the entitlement verdict, described under GET /widget/v1/entitlements.


Authentication

Four headers carry identity. Which ones you send depends on the call.

Header Value Sent by
x-everfur-partner-key pk_live_… Every request. Ships in your app. A sandbox tenant is issued the same pk_live_ form.
x-everfur-session-id A v4 UUID you generate Anonymous mode. See below.
x-everfur-user-ref Your own stable user id Identified mode, alongside a session token.
x-everfur-user-role Role string Only where your integration uses roles.

The publishable key is not a secret and is meant to ship inside your app bundle. The secret key (sk_partner_…) is used for exactly one call, POST /widget/v1/sessions, and must never leave your backend.

No deployed environment accepts a pk_test_ key. Staging and production both take pk_live_ and refuse pk_test_ with a 401 on the prefix, before any database lookup, so a mismatched key cannot be distinguished from a wrong key. Test mode is a tenant Everfur created as a sandbox on the staging API (https://api-staging.everfur.com/api/v1), issued an ordinary pk_live_ key; GET /widget/v1/entitlements reports livemode: false for it. A key is also bound to the deployment that issued it, so a staging tenant's key against production 401s the same way. If your first call 401s, check which key you pasted and which base URL it belongs to before checking anything else.

The session id header, and one trap in it

In anonymous mode the server derives the caller's identity from x-everfur-session-id. Two facts about it cost real debugging time:

  1. It must be a valid v4 UUID. If it is not, the server silently substitutes one of its own and reports nothing. Your create call and your send call then resolve to two different users, and the send fails with a 404 that names the conversation rather than the identity.
  2. It must be stable for the whole conversation. A fresh id per request means a fresh user per request.

The derived identity is anon-{session_id}. It is scoped to your tenant.


Errors

Every error on this plane returns the same envelope, whatever produced it:

{
  "code": "ResourceNotFoundException",
  "message": "Pet profile 'unknown-ref' not found",
  "request_id": "840de30f-dc73-4fa2-b42f-ec92fe9012e8"
}

Branch on code. Log request_id. Do not render message to a pet owner. Full table in 11-ERRORS.md.

Two shapes this API never returns, both of which are easy to assume:

  • {"detail": ...}. That is FastAPI's internal shape. An exception handler replaces it before the response leaves the server, so code branching on detail never matches.
  • HTTP 422. FastAPI's default validation status is remapped. Request validation failures arrive as 400 with code ValidationException.

Sessions

POST /widget/v1/sessions

Exchange your secret key for a short-lived session token bound to one of your users. This is the only route that takes the secret key, and the only one you call from your backend.

Headers

Header Required Notes
x-everfur-partner-key yes Publishable key.
x-everfur-partner-secret-key yes Secret key. Server to server only.

This is the one legitimate two-credential call. The secret header is deliberately not in the CORS allow-list, so a browser cannot send it even if the key were pasted into front-end code. The restriction is structural rather than a convention you have to remember.

Body

Field Type Required Notes
user_ref string yes Your stable identifier for the user. Opaque to Everfur: stable per person, not guessable (no email addresses, no sequential ids), 1 to 128 code points, no @, case sensitive.
ttl_seconds integer no Requested lifetime in seconds. Omitted: 900 (15 minutes). Clamped to 60 to 3600 (1 minute to 1 hour).

Returns 201 with:

{
  "session_token": "…",
  "expires_at": 1756150800,
  "capabilities": ["chat"]
}

session_token is a signed token that holds no secret; the device may hold it. expires_at is epoch seconds. capabilities is a list of coarse feature names and is advisory only. Do not gate UI on it. The authoritative verdict comes from GET /widget/v1/entitlements, and the SDK gates on that.

Behaviour worth knowing

  • Every credential failure returns one identical opaque 401. There is no probing oracle, so you cannot tell a bad publishable key from a bad secret key from a mismatched pair. That is deliberate.
  • Re-minting is safe. A second mint for the same (client_id, user_ref) returns a fresh token. The token is stateless and there is no server-side session table to race against.

Entitlements

GET /widget/v1/entitlements

The resolved capability verdict for the calling tenant. This is the authoritative answer to "what is this partner allowed to do", and the SDK polls it.

Returns 200 with a verdict carrying capabilities, a monotonic integer revision, and livemode.

  • capabilities is a map. A capability that is absent is off. The SDK renders every gate off for an empty map, which is correct behaviour and not an error state.
  • livemode is false when the tenant holds test data only, true otherwise. It mirrors the immutable is_sandbox fact on the tenant. Tooling that must never touch real records reads this.
  • revision doubles as the ETag. There is no separate opaque token: the quoted integer is the ETag value.

Conditional requests

Send either If-None-Match: "<revision>" or the query parameter if_revision=<n>. When the server's current revision matches, it returns 304 with no body.

An empty verdict is the most commonly misread response on this API. It has four causes that look identical from the client, and separating them by hand is slow. everfur_diagnose in the MCP server exists for exactly this; see 12-CLI-AND-MCP.md.


Conversations

POST /widget/v1/conversations

Open a conversation.

Field Type Required Notes
title string no Display title.
pet_ref string no Scope the conversation to an already registered pet.
pet object no Inline pet, same fields as WidgetPetInput below.

Returns 201 with conversation_id, title, created_at, updated_at, and optionally messages.

GET /widget/v1/conversations

List conversations for the calling identity. Returns conversations and a next_cursor that is null on the last page. Optional pet_ref narrows the list to an owned, registered widget pet; an unknown pet is 404, not an unfiltered fallback. limit is 1–100 (default 20).

GET /widget/v1/conversations/{conversation_id}/messages

Returns messages and next_cursor. Each message carries message_id, role, content, message_type, created_at, and optionally urgency_level, citations, citation_status, citation_status_reason, analysis_result_id, and proactive_origin_ref. The first page is the newest window, ordered chronologically. A next_cursor fetches older messages; prepend those pages. limit is 1–200 (default 50). A cursor from another conversation is not accepted as an anchor for the requested thread.

POST /widget/v1/conversations/{conversation_id}/messages

Send a message and stream the answer.

Field Type Required
message string yes
image_s3_keys string[] no; up to 5 s3_key values from POST /widget/v1/uploads/initiate, each under the calling member's own prefix, in the order sent

message and image_s3_keys are the only fields this route accepts. Sending content, which is what the message objects are read back as, fails validation with a 400 naming body.message. A key minted for anyone else refuses the whole turn with a 400 (ValidationException, s3_key must reference an object you uploaded); with an Idempotency-Key, the same text with different images is a conflict, not a replay.

Send Accept: text/event-stream to stream. Set Idempotency-Key so a retry cannot become a second message. Frame protocol is in 10-TESTING.md, including how to tell a stream that died mid-answer from a genuinely short one.

POST /widget/v1/messages/{message_id}/feedback

Rate an assistant message in one of the member's conversations (the SDK's feedback thumbs and reasons sheet). Behind the tenant's chat surface and chat.message.create.

Field Type Required
feedback_type helpful, neutral, not_helpful, wrong, outdated, dangerous, unclear yes
notes string, up to 4000 characters no

Answers 201 with the recorded row (feedback_id, message_id, conversation_id, feedback_by, feedback_type, notes, ...): a second rating by the same member updates the first (one row per (message_id, feedback_by)), so the SDK's "tap again to clear" is local only. 404 when there is no assistant message with that id in an active conversation the member owns, or the tenant's chat surface is off.

POST /widget/v1/uploads/initiate

Mint a presigned S3 POST policy for one chat image, under the calling member's own prefix; the purpose is always chat_attachment (never sent). Behind the tenant's chat surface and chat.message.create; counts against the partner's daily quota.

Field Type Required
filename string yes
content_type image/jpeg, image/png, image/webp, image/heic, image/heif yes
file_size integer bytes, at most 10 MiB yes

Returns upload_url, upload_fields, s3_key, expires_in_seconds and max_content_length. Post multipart/form-data to upload_url with every entry of upload_fields plus a final file part carrying the bytes (the signed policy enforces content-length-range), then send s3_key in image_s3_keys on the message. 400 on invalid metadata, 404 while the tenant's chat surface is off, 429 over the daily quota, 503 when the upload provider is unavailable. The upload target is the media bucket's exact virtual-hosted origin, which the SDK's frame documents name in their connect-src (never an S3 wildcard).

POST /widget/v1/conversations/{conversation_id}/classify

Returns an urgency classification: urgency and a reason.

POST /widget/v1/conversations/{conversation_id}/analyze

Analysis over the conversation. Streams.


Pets

A pet must be registered before anything scoped to it will work. A pet reference that was never registered produces a 404, including from routes whose failure looks unrelated, such as granting records consent.

POST /widget/v1/pets/{pet_ref}

Register or replace a pet under a reference you choose. pet_ref is your own identifier.

Field Type Constraints
name string max 100
species string max 50. Dog, cat, and similar.
breed string max 100
age_years number 0 to 50
weight_kg number 0 to 500. Kilograms, always.

Every field is optional, so {} registers a bare pet.

Returns 201 with pet_id, pet_ref, the fields above, and updated_at.

GET /widget/v1/pets

Returns pets.

PATCH /widget/v1/pets/{pet_ref}

Same body as registration. Returns the updated pet.

DELETE /widget/v1/pets/{pet_ref}

Returns 204.

DELETE /widget/v1/me

Deletes the calling identity and its data. Returns 204. There is no undo and no confirmation step.

GET /widget/v1/suggested-prompts

Returns categories, each holding prompts. Accepts an optional species. With no species the server falls back to dog prompts, which is a legacy widget default rather than a considered one.


Consent is recorded per scope, and optionally per pet.

Returns scope, pet_ref, granted, has_decision, consent_version, actor, recorded_at.

granted and has_decision are not the same question. has_decision: false means nobody has been asked yet, which is the state that should trigger your consent prompt. granted: false with has_decision: true means the user said no, and re-prompting them is a dark pattern.

Field Type Required Notes
scope string yes transcript_exposure or records_content.
granted boolean yes true to grant, false to withdraw.
pet_ref string no Scope the decision to one pet.
consent_version string no The version of the consent text shown.

Records

The entire records surface is dark by default. It sits behind partner_records_enabled, which defaults to off, enforced at a router-level dependency. With the flag off every route below returns 404, not 403, so the surface is indistinguishable from one that was never built. Ask your Everfur contact to enable it for your tenant before you integrate against it.

Uploads are PDF only (application/pdf) and capped at 25 MB.

Returns pet_ref, granted, version, granted_at.

Field Type Required Constraints
consent_version string yes 1 to 64 chars
idempotency_key string yes 1 to 128 chars

Returns the consent state. A 404 here usually means the pet was never registered, not that consent is missing.

Withdraws consent. Returns the consent state.

GET /widget/v1/records/pets/{pet_ref}/record

The pet's health record. Consent is checked before anything is serialized, and it fails closed.

The response is always 200. What changes is the envelope, and the difference is not cosmetic:

consent state withheld body
never granted true withheld, pet_ref, schema_version, disclaimer only
granted false adds identity, entities, entity_series, is_empty, generated_at, and the rest
withdrawn true back to the four-field envelope

The protected fields are absent, not empty. Withdrawing consent removes them again on the next read, verified against staging. So withheld: true and is_empty: true are different answers: the first means you may not see it, the second means there is nothing to see. Render them differently, because telling an owner their pet has no records when consent is simply missing is a support ticket.

Do not gate your UI on the HTTP status here. A 200 carrying withheld: true is the refusal.

POST /widget/v1/records/requests

Open a records request.

Field Type Required Constraints
pet_ref string yes max 128
idempotency_key string yes 1 to 128

Returns 201 with request_id, pet_ref, status, and the lifecycle timestamps received_at, parsing_started_at, review_started_at, created_at, updated_at. A timestamp that is null means that stage has not happened yet, which is how you track progress.

Observed status progression on a real upload: awaiting_upload at creation, then received once the upload is completed, then parsing. Poll the request, or show the stage; do not assume the record is readable the moment the upload returns.

GET /widget/v1/records/requests

Returns a list of request objects.

GET /widget/v1/records/requests/{request_id}

Returns one request object.

POST /widget/v1/records/requests/{request_id}/uploads/initiate

Field Type Required Constraints
content_type string yes application/pdf. Nothing else is accepted.
file_size integer yes Above 0, at most 26214400 (25 MB).

Returns upload_url, upload_fields, upload_id, expires_in_seconds, max_content_length.

A live response carried expires_in_seconds: 600 and max_content_length: 26214400, with upload_fields holding the S3 POST policy: Content-Type, key, x-amz-algorithm, x-amz-credential, x-amz-date, x-amz-security-token, policy, x-amz-signature. Storage answers a correct upload with 204, not 200.

Post the file to upload_url as multipart form data, sending every key in upload_fields first and the file last. The fields are a signed policy: omitting one, reordering the file ahead of them, or sending a file larger than the size you declared will be rejected by storage, not by this API, so the error you get back will not use the envelope above.

POST /widget/v1/records/requests/{request_id}/uploads/complete

Field Type Required Constraints
upload_id string yes 1 to 128

Returns source_doc_id, request_id, and status, which begins at received.

The full-depth routes (behind partner.records_full_depth_enabled)

Off by default and turned on per deployment. Off, every route here answers 404 like an unmounted path, the record read carries none of the depth keys, and the SDK hides the surfaces that depend on them rather than showing an error. On, the record read adds lab_results, encounters, clinician_notes, physical_exams, completeness and conflicts, every entity row carries canonical_key, and these answer. A pet without records consent answers 200 {withheld: true, pet_ref} on the reads and the typed 409 records_consent_required on the writes.

method path body returns
GET /widget/v1/records/pets/{pet_ref}/timeline {withheld, pet_ref, visits, vaccines, weights, medications, disclaimer}
GET /widget/v1/records/pets/{pet_ref}/documents/{doc_id}/contributions {withheld, pet_ref, document, contributions, contribution_count}
GET /widget/v1/records/pets/{pet_ref}/markers {withheld, pet_ref, markers, summary, disclaimer, featured_marker_id}; markers: [] for consent without lab data. Per point: collected_at, value, unit, ref_low, ref_high, status, source_clinic, doc_id, reviewed; the reviewer's confidence and value_source are withheld
POST /widget/v1/records/pets/{pet_ref}/everfur_record/share none {share_id, share_url, expires_at}; share_url is relative to the API origin
GET /widget/v1/records/pets/{pet_ref}/everfur_record/shares {shares: [{share_id, expires_at, created_at, view_count, last_viewed_at}]}; never repeats a URL
DELETE /widget/v1/records/pets/{pet_ref}/everfur_record/shares/{share_id} 204
GET /widget/v1/records/pets/{pet_ref}/everfur_record/pdf-link {download_url, expires_at}, presigned; 404 before a record exists
POST /widget/v1/records/pets/{pet_ref}/everfur_record/facts/edit {entity_type, canonical_key, corrected_fields, idempotency_key?} (entity_type is the row's category; corrected_fields carries ONE typed slot, value_num as a number, value_text, value_date or value_bool, and setting one clears the other three) 200; a plain 409 once an Everfur reviewer decided that fact
POST /widget/v1/records/pets/{pet_ref}/everfur_record/facts/remove {entity_type, canonical_key, idempotency_key?} 200
GET /widget/v1/records/authorization-signatures {signatures: [{signature_id, preview_url, created_at}]}, the member's reusable signatures

The SDK wraps all of them (createRecordsDepthClient on @everfur/sdk/core; useRecordsMarkers, useRecordShares, useRecordPdfLink and useRecordFactEdit on the records subpaths) and folds the 404 to available: false on each hook. See the record depth section of 09-GUIDE-RECORDS-AND-CONSENT.md.


Notifications

The member's push devices and switches, behind partner.notifications_enabled: every route here answers 404 until Everfur turns it on for your client. Signed session only (403 session_required for a publishable-key caller). The SDK wraps all three (@everfur/sdk/notifications); see 17-NOTIFICATIONS.md.

POST /widget/v1/me/devices

Register or refresh this install's push token for the calling member. Idempotent on the token: the same token is the same device, and a token another member of your tenant registered rebinds to the caller. Ten a minute per member (429 above).

Field Type Required Constraints
token string yes 1 to 4096. ExponentPushToken[...] for expo; an FCM registration token for fcm
provider string yes expo or fcm. apns is refused with 422 provider_unsupported in this version
platform string yes ios or android
installation_id string no 1 to 128, your own id for the install; echoed, never interpreted

Returns 200 with device_id, provider, platform, installation_id, registered_at and last_seen_at. A token that is not the provider's shape is 422 token_invalid; a fault sealing it at rest is a retryable 503 device_cipher_unavailable and nothing is stored.

DELETE /widget/v1/me/devices/{device_id}

Unregister one of the calling member's devices. Returns 204; an unknown id and another member's device are the same 404.

PUT /widget/v1/me/notification-preferences

Both switches, always (a replacement, not a patch).

Field Type Required
push_enabled boolean yes
email_enabled boolean yes

Returns 200 with push_enabled, email_enabled and updated_at.

The member's email is never set from a device: your backend supplies it with its consent through PUT /api/v1/partners/members/{user_ref}/notification-profile on the secret key (setPartnerMemberNotificationProfile on @everfur/sdk/server, 17-NOTIFICATIONS.md).


Routes that do not exist

These are asked about often enough to be worth naming. All five have zero route decorators anywhere in the API. Three of them 404 because there is nothing there, and two never reach the application at all. (POST /widget/v1/uploads/initiate used to sit in this table; it exists now, for chat attachments, and is documented above.)

Route Status Answered by
POST /widget/v1/photo/precheck 404 the app
POST /widget/v1/photo/analyze 403 API Gateway
POST /widget/v1/video/analyze 403 API Gateway
GET /widget/v1/analysis/jobs/{job_id} 404 the app
GET /widget/v1/pets/{pet_ref}/state 404 the app

The two 403s are an explicit deny in an IAM policy at the edge, measured on staging:

x-amzn-errortype: AccessDeniedException
{"Message":"User is not authorized to access this resource with an explicit deny in an identity-based policy"}

Note the body. That is an AWS shape with a capital Message, and it carries no code and no request_id. Error handling that branches on code gets undefined here. See 11-ERRORS.md.

Photo checkup and gait video are preview, not v1.

There is a trap attached to this. A staging entitlement verdict may still grant photo.checkup.execute and video.gait.execute, because the capability catalog lists them while the routes do not exist. The verdict is wrong, not the route. Do not build against a capability because entitlements reported it; the SDK already renders these off regardless, on the grounds that a known-absent route cannot be called.

Guide: medical records and consent

Take a pet from "not known to Everfur" to "the partner app can read a parsed medical record". This is a task guide. For the field-by-field surface, see the reference table at the end.

The flow is the same on React Native (@everfur/sdk/records) and on the web (@everfur/sdk/web/records, in-page or in frame mode); the hook is one and the same, and only the upload seam differs (a browser File on the web). The web-specific parts are in the web section below and in 13-WEB-INTEGRATION.md. On the web, records is available when Everfur enables it for your account; until then the surface renders its off-state.

This is the fact that costs the most time, and it is documented nowhere else.

Every records and consent route resolves pet_ref through the widget pet table, scoped to the resolved user. If no widget pet row exists for that pet_ref, the route answers 404, not an empty consent state.

Verified against staging:

# Before registering the pet
GET /api/v1/widget/v1/records/pets/rex-42/consent
404 {"code":"ResourceNotFoundException","message":"Pet profile 'rex-42' not found","request_id":"..."}
# From the next backend release the body no longer echoes the ref and carries a stable reason:
# {"code":"ResourceNotFoundException","message":"Pet profile not found","reason_code":"pet_not_registered","request_id":"..."}

# Register the pet
POST /api/v1/widget/v1/pets/rex-42
201

# The same consent call, unchanged
GET /api/v1/widget/v1/records/pets/rex-42/consent
200 {"pet_ref": "rex-42", "granted": false, "version": null, "granted_at": null}

Every error on this plane uses the same envelope: {"code", "message", "request_id"}. Branch on code, log request_id, and never render message to a pet owner: it is server-authored text meant for your logs. The SDK surfaces the safe half as error.displayMessage and keeps the raw text on the non-enumerable error.wireMessage.

The 404 is not recoverable by granting consent, because the grant PUT resolves the same missing pet and 404s identically. Before the release that adds reason_code it is indistinguishable, on any field you can branch on, from the 404 the whole records surface returns when it is dark for your tenant; after it, the unregistered pet carries reason_code: "pet_not_registered" and the dark surface carries none. src/core/records/repository.ts says so in the tagReason comment, and tags every 404 on this surface as entitlement for that reason. So register the pet before you touch records, and treat a 404 afterwards as "records is not on for this tenant".

Registering a bare pet is enough for consent. Every profile field is optional.

import { petRef, useEverfur } from '@everfur/sdk';

const everfur = useEverfur();

const registered = await everfur.registerPet(petRef('rex-42'), {
  name: 'Rex',
  species: 'dog',
  weightKg: 28.4,
});
if (!registered.ok) {
  // registered.error is a typed EverfurError. Nothing throws.
}

registerPet is POST /widget/v1/pets/{pet_ref} and it is an upsert. Calling it again for the same pet_ref merges the fields you send and leaves the rest unchanged.

Change a pet, delete a pet, erase a user

The same handle changes and removes what you registered.

import { petRef, useEverfur } from '@everfur/sdk';

const everfur = useEverfur();

// PATCH /widget/v1/pets/{pet_ref}: an omitted field is unchanged, null clears it.
const updated = await everfur.updatePet(petRef('rex-42'), { weightKg: 29.1, breed: null });

// DELETE /widget/v1/pets/{pet_ref}: the pet profile, and the server retires its Records data.
const deleted = await everfur.deletePet(petRef('rex-42'));

// DELETE /widget/v1/me: everything Everfur holds for the signed-in user (right to erasure).
const erased = await everfur.eraseUserData();
if (!erased.ok && erased.error.retryable) {
  // The erasure did not finish (503). Call eraseUserData again; it resumes.
}
  • updatePet needs at least one field; an empty change is refused before any request. Once the pet uses Records its species must stay one Records supports, so clearing it is refused by the server.
  • An unknown pet_ref settles updatePet and deletePet to the not-found error.
  • eraseUserData is sent once per call with a 60 second deadline (the server allows itself 45), and never retried automatically, so two erasures never overlap. It does not sign the user out: call logout() afterwards so a mounted surface stops showing the erased data. A later request with the same user ref starts a new, empty Everfur user.
  • On @everfur/sdk/client, PetsRepository carries the same three methods for an integration without the provider.

Give the pet a real name and a real species. Species matters twice: POST /records/requests maps the free-text widget species onto the records domain and returns 422 for anything it cannot map, and the name is what the publish gate matches a document against.

Preconditions

Five things must hold before any of this works.

  1. The records surface is enabled for your tenant. It is gated at the router, off by default, and the whole sub-router returns 404 when off. This is an Everfur-side switch, not something you can set.
  2. Your tenant is granted both records.upload.create and records.record.read. The SDK requires both for the coarse records capability (src/client/entitlements/resolve.ts); either one missing renders the disabled surface instead of the records UI.
  3. The pet is a dog or a cat. map_species accepts dog, canine, k9, puppy, cat, feline, kitten, case-insensitive and trimmed. Anything else gets 422 {"code":"ValidationException","message":"Records are supported only for dogs and cats","reason_code":"species_unsupported","request_id":"..."} when you create an upload or clinic request.
  4. The document is a PDF, at most 25 MiB (26214400 bytes). application/pdf is the only accepted content_type at initiate, and the SDK rejects a non-PDF before it reaches the network. While Everfur has photo upload switched on for your tenant, a JPEG, PNG or HEIC/HEIF photo of a paper record is accepted too (see Photos of a paper record).
  5. The caller is a signed-in user (a bearer session your backend minted). A publishable key alone, anonymous or identified, is refused on every records route with 403 session_required; the SDK surface shows "Sign in required" without a user on the provider and issues no records request.

The flow, in order

registerPet            POST   /widget/v1/pets/{pet_ref}
grant consent          PUT    /widget/v1/records/pets/{pet_ref}/consent
create a request       POST   /widget/v1/records/requests
initiate an upload     POST   /widget/v1/records/requests/{request_id}/uploads/initiate
send the bytes         POST   {upload_url}                       (direct to S3, multipart/form-data)
complete the upload    POST   /widget/v1/records/requests/{request_id}/uploads/complete
poll the request       GET    /widget/v1/records/requests/{request_id}
read the record        GET    /widget/v1/records/pets/{pet_ref}/record

Consent and the upload lane are independent on the server: you can create a request and upload without consent. Consent gates the content read. The order above is the one the SDK's own surface follows, because a user who will not consent should not be asked to upload a document first.

import { petRef } from '@everfur/sdk';
import { useEverfurRecords } from '@everfur/sdk/records';

const records = useEverfurRecords({ petRef: petRef('rex-42') });

const granted = await records.grantConsent('records-consent-v1', 'grant-rex-42-2026-08-25');

On the wire that is PUT /widget/v1/records/pets/rex-42/consent with:

{ "consent_version": "records-consent-v1", "idempotency_key": "grant-rex-42-2026-08-25" }

consent_version is yours: it is the version of the consent text your user actually agreed to, 1 to 64 characters, and the server stores it verbatim so the grant is auditable. The SDK mints no default. The prebuilt <EverfurRecords> component takes it as the consentVersion prop, and without it the grant button renders disabled rather than doing nothing on tap.

idempotency_key is 1 to 128 characters. The grant is a row-grain upsert, so the body key is accepted and ignored; the wire Idempotency-Key header is minted by the SDK's request funnel and never taken from a caller.

Both the body and the response carry granted, version, granted_at:

{ "pet_ref": "rex-42", "granted": true, "version": "records-consent-v1", "granted_at": "2026-08-25T09:14:03+00:00" }

Do not write if (status === 404) showConsentPrompt(). That branch is a bug.

The backend answers 200 {"granted": false, ...} for a pet that has never consented. A 404 on the consent route means one of the two unrecoverable things above: the pet is not registered, or the surface is dark. The SDK used to fold every consent 404 into a synthetic granted:false, which rendered an Allow button the user could press forever; that fold was removed, and the comment in RecordsController.getConsent records why.

Revoking is DELETE /widget/v1/records/pets/{pet_ref}/consent. It is prospective: the row is kept, granted flips to false, every grant, new version and revoke is also appended to a consent history (from the release that adds it), and the SDK drops the cached record from memory so the next read re-checks the gate.

What a withdrawal does to everything already on screen

A withdrawal is not only a flag for the next read. Call revokeConsent() on useEverfurRecords and, in the same transaction on the server, every live share link for that pet is revoked; in the same render on the device, the SDK drops what it is holding:

  • the record read's cached view, and every depth surface's own copy (the depth sections, the timeline, a document's contributions, the lab markers) go at once, without waiting for a re-read to confirm it. The re-read that follows answers {withheld: true} and the surfaces render nothing;
  • a read that was already in flight when the withdrawal happened cannot put the content back: it is discarded when it lands;
  • the share sheet forgets the link it minted during this session, so no Copy link control can hand out a credential the server has already revoked.

What stays reachable is deliberate. The share list and the per-link Turn off are gated on ownership alone, so a member can still see and kill a link after withdrawing. The two consent-gated actions, minting a link and exporting the PDF, answer the typed records_consent_required 409; the prebuilt sheet stops drawing them and says so (Records access is off) with a Try again that re-reads once the member allows records again. If you build your own surface, read that state from consentRequired on useRecordShares, and do not answer that 409 with a connection message: a retry cannot fix it.

Withdrawal is a fact about the pet, not about one screen, so it reaches every mounted records surface for that pet whether or not they share a provider subtree.

2. Create a records request

import { petRef } from '@everfur/sdk';
import { useEverfurRecords } from '@everfur/sdk/records';

const records = useEverfurRecords({ petRef: petRef('rex-42') });

const request = await records.createRequest('req-rex-42-2026-08-25');

POST /widget/v1/records/requests with {"pet_ref": "rex-42", "idempotency_key": "req-rex-42-2026-08-25"} returns 201 and the request wire:

{
  "request_id": "…",
  "pet_ref": "rex-42",
  "status": "awaiting_upload",
  "source": "owner_provided",
  "received_at": null,
  "parsing_started_at": null,
  "review_started_at": null,
  "created_at": "…",
  "updated_at": "…"
}

idempotency_key is what this route dedups on, scoped to the resolved user. A replay returns the same request rather than creating a second one. Use a value that is stable for the logical attempt, not a fresh UUID per retry.

This call is also where the species check runs, because it materializes the records-side shadow pet row.

3. Upload the document

Two phases, with the bytes going direct to S3 and never through the Everfur API.

import { petRef } from '@everfur/sdk';
import { useEverfurRecords } from '@everfur/sdk/records';

const records = useEverfurRecords({ petRef: petRef('rex-42') });

const request = await records.createRequest('req-rex-42-2026-08-25');
if (!request.ok) return;

const uploaded = await records.uploadDocument(request.value.id, {
  uri: localFileUri,
  mimeType: 'application/pdf',
  sizeBytes: 1_482_113,
  name: 'rex-vaccines.pdf',
});

Phase one, POST /widget/v1/records/requests/{request_id}/uploads/initiate:

{ "content_type": "application/pdf", "file_size": 1482113 }

returns

{
  "upload_id": "…",
  "upload_url": "https://…",
  "upload_fields": { "key": "…", "policy": "…", "x-amz-signature": "…" },
  "expires_in_seconds": 600,
  "max_content_length": 26214400
}

Post multipart/form-data to upload_url with every entry of upload_fields verbatim, then the file part last. The raw S3 key is deliberately not returned, so complete cannot be pointed at an arbitrary object.

Phase two, POST /widget/v1/records/requests/{request_id}/uploads/complete with {"upload_id": "…"}, returns {"source_doc_id": "…", "request_id": "…", "status": "received"}. The server validates the file by magic bytes here, so a renamed non-PDF is rejected at complete, not at initiate.

If you are driving this yourself rather than through the SDK: the SDK bounds the S3 POST at 90 seconds and reports a failure as timeout or upload_error only. Never log the raw cause. The presigned URL carries X-Amz-Signature and X-Amz-Credential in its query string, and an S3 client's error message often embeds the whole URL.

An upload never auto-publishes. status: "received" is the end of your part.

Photos of a paper record

GET /widget/v1/records/features says which records features are live for the signed-in user. It needs the records.record.read capability and nothing else. While Everfur has photo upload switched on, the answer carries upload_content_types:

{ "features": { "markers": true, "sharing": true }, "upload_content_types": ["application/pdf", "image/jpeg", "image/png", "image/heic", "image/heif"] }

Absent, the upload is PDF only. Initiate then accepts those types (the same 25 MiB bound), and complete reads the photo from its bytes and turns it into a one-page PDF. With the switch off, a photo type at initiate is the same 400 as before.

The SDK reads the route for you and fails closed: a failed or malformed answer means PDF only. The prebuilt upload, request and dashboard screens on both hosts read it themselves.

  • controller.readFeatures() returns { features, uploadContentTypes }, and from then on the controller's upload gate accepts the listed photo types. useEverfurRecords({ petRef, readUploadFeatures: true }) does the read on mount and exposes uploadContentTypes; acceptsPhotos(uploadContentTypes) is the "photos live" test.
  • Web: pass uploadContentTypes to fileToHandle(file, types), pickDocument(document, types) and recordsDocumentAccept(types). The type is read from the file's first bytes, never from File.type.
  • React Native: the upload screen adds Take a photo when you set camera on the EverfurConfig (the capture must report sizeBytes, because initiate needs the byte count), and Choose from photos when you pass onPickPhotos (same contract as onPickDocument, resolving image/* handles). With neither, or with the switch off, the upload screen is unchanged.

4. Poll the request, patiently

GET /widget/v1/records/requests/{request_id} returns the same request wire with a moving status. The SDK exposes it as records.activeRequest.

The SDK's partner-facing status set is:

status terminal meaning
awaiting_upload no created, nothing uploaded yet
requested no clinic request submitted or being sent
awaiting_clinic no awaiting the clinic; follow-ups may still be active
received no bytes accepted
parsing no automated read in progress
in_review no a human is reading it, because the parse could not be completed
published yes facts are readable through the record endpoint
failed yes
revoked yes

The backend enum is wider than this. The SDK uses the backend's canonical simple_status when present, while preserving upload and review states that have a specific UI action. Older responses map submitted and sent to requested, awaiting_response and clinic_responded_no_records to awaiting_clinic, and parsing_failed and review_email_failed to in_review. An unknown status remains non-terminal rather than falsely reporting success. simpleStatusDetail distinguishes conditions within a canonical stage.

Cadence, honestly. These are not sub-minute states. awaiting_response and clinic_responded_no_records can rest for days. The SDK polls every 4 seconds for at most 30 ticks, about two minutes, and then stops; reaching that ceiling is the normal path, not a failure. It also stops polling at in_review, because a human review is not a seconds-scale wait either.

If you build your own poller: do not sit on a 4-second interval for a state measured in days. Poll briefly while the user is looking at the screen, stop, and re-check on the next foreground or pull-to-refresh. records.refresh() re-arms the SDK's budget for exactly this reason.

review_started_at being non-null is the honest signal that a parse gap occurred and a person is involved. The SDK emits records.parse.gap once per request when it first sees that.

Two fields on RecordsRequestView are always zero on this plane: documentCount and heldItemCount. The widget request response carries neither. statusLabel carries backend-authored status copy when available; an older deployment may omit it. Do not expose raw internal status enums as owner-facing copy.

5. Read the record

GET /widget/v1/records/pets/{pet_ref}/record. Consent is checked first, fail-closed.

import { petRef } from '@everfur/sdk';
import { useEverfurRecords } from '@everfur/sdk/records';

const records = useEverfurRecords({ petRef: petRef('rex-42') });
// records.status is 'loading' | 'empty' | 'error' | 'populated'
// records.record  is a RecordsView, or null
// records.withheld tells the two suppressed states apart

Three outcomes, and they are not the same thing:

  • Withheld. withheld: true. Consent is not granted, or the server could not prove the redaction was safe. Render the withheld notice. Never render it as empty and never fall back to raw content. The SDK does not cache a withheld record.
  • Granted but empty. is_empty: true, with well-formed empty sections and a generated_at. The pet is valid and you own it, there is simply nothing published yet. This is a 200, not a 404. A pet with no request at all lands here too.
  • Populated. The parsed projection.

The record wire keys are withheld, pet_ref, schema_version, is_empty, generated_at, disclaimer, identity, entities, entity_series, forward_looking, documents. That list is an allowlist enforced at the response boundary on the server and again by the mapper in the SDK.

disclaimer is backend-authored and must be rendered verbatim. The SDK invents no copy here.

Provenance is deliberately absent. There is no raw_value, source_span, source_bbox, source_page, source_doc_id, value_source, extraction_method or source_precedence anywhere in RecordsView. The one provenance-adjacent field is origin, a two-value display label: owner_reported or clinic_document. Owner-entered and clinic-measured readings share one series, so a clinician dosing off a weight needs to know which it was. origin: null means the backend did not label it. It does not mean clinic.

Records in Chat

Where Everfur has enabled live records context for the deployment and your account, a Chat turn about a pet can be grounded in that pet's published records. Every turn checks, fail-closed:

  • the caller is a signed session (a publishable key alone never reads records),
  • the records surface is enabled for your account and the session holds records.record.read,
  • the conversation's pet belongs to the same member, and
  • that pet's records consent is granted at the moment of the turn.

Revoking consent stops the next turn from reading records. It is prospective: answers already given are not rewritten.

Depth is the same records brief the Everfur app's own chat uses. It can include clinical detail that GET /record deliberately withholds, such as clinician notes, and an assistant reply may repeat it. The conversation transcript then contains that detail: in the SDK, and in the conversation and message exports of the partner egress API. Store, log and retain transcripts with the same care as the records themselves.

Errors you will actually see

status body cause what to do
404 {"code":"ResourceNotFoundException","message":"Pet profile not found","reason_code":"pet_not_registered"} pet_ref is not a registered widget pet of this user (missing, deleted and another user's pet all answer the same) call registerPet first
404 {"code":"ResourceNotFoundException","message":"Not found"} the records surface is off for this tenant talk to Everfur; there is no client-side recovery
404 {"code":"ResourceNotFoundException","message":"Request not found"} the request_id is not yours, or its pet is not one of your widget pets
422 {"code":"ValidationException","message":"Records are supported only for dogs and cats","reason_code":"species_unsupported"} species did not map to dog or cat at request or clinic request creation set a supported species on the pet
422 Pydantic field error a bound was violated: consent_version 1..64, idempotency_key 1..128, pet_ref max 128, file_size 1..26214400

Branch on reason_code where it is present; it is stable. The SDK carries it as result.error.reasonCode. The surface-off and request 404 bodies carry none and differ only in text you should not branch on. The server returns 404 rather than 403 for a dark surface on purpose, so a probe cannot tell a disabled PHI surface from a missing one.

All lengths are measured in code points, the way Python's len() counts, not UTF-16 units. A pet_ref of 128 emoji is 128 characters to this API. The SDK mirrors that rule in boundsError, and getting it wrong once made consent unreachable for exactly the pets whose ids were not Latin-1.

The SDK settles rather than throws. Every repository and controller method returns an EverfurResult, and a denial carries a coarse recordsReason you read with recordsDenialReasonOf(error): consent_required selects the consent CTA, session_required the establish-session CTA, entitlement the disabled surface.

Using the prebuilt surface instead

Register the pet first (see the top of this guide), then mount the surface.

import { createRnRecordsUploadTransport, EverfurProvider, petRef, type RecordsDocumentPicker } from '@everfur/sdk';
import { EverfurRecords } from '@everfur/sdk/records';

// Create it once, outside render.
const uploadTransport = createRnRecordsUploadTransport();

// Your own document picker. The SDK does not depend on one.
const pickDocument: RecordsDocumentPicker = async () => {
  const picked = await pickPdfFromDevice();
  if (picked === null) return null; // the user cancelled
  return { uri: picked.uri, mimeType: 'application/pdf', sizeBytes: picked.size };
};

export function RecordsScreen(): React.ReactNode {
  return (
    <EverfurProvider config={{ publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.everfur.com/api/v1', uploadTransport }}>
      <EverfurRecords petRef={petRef('rex-42')} consentVersion={YOUR_CONSENT_VERSION} onPickDocument={pickDocument} />
      {/* Optional: onPickPhotos={pickPhotos} adds `Choose from photos` while photo upload is on. */}
    </EverfurProvider>
  );
}

EverfurRecords is the Everfur app's records flow, not one screen: it starts on the dashboard and keeps its own stack, so the member sees the same screens in the same order as in the consumer app. Every screen renders loading, empty, error and populated, wrapped in a capability gate so an entitlement denial shows a deliberate off-state rather than a blank region, with the records footnote under every state (Everfur summarizes what your vet recorded. It is informational, not diagnostic., compiled and not overridable; it replaced the clinical disclaimer band on records on 30 Sep 2026). It has no render slots: Everfur owns this UI. The screens, in the order the member meets them:

  • Dashboard (titled with the pet: Rex's Records on the web, Rex on React Native; Health records when you pass no petName): the At a glance marker summary ring and the lab trend carousel (each reading opens the Where this comes from sheet), the record's sections with View full medical record and View full timeline, the in-progress card (Rex's records are on the way, with Another clinic and Upload a document beside it), or the actionable-empty prompt (Request from a clinic / Upload a document). A pet that already has a record (on React Native, any registered pet: its profile is a record) still gets Request from a clinic and Upload a document, and a Your record requests row into the request list. Request from a clinic is hidden when the tenant is known not to have records.clinic.create.
  • Request: one request, with the Where it stands tracker (for a clinic request Request sent, Clinic notified, Waiting for records…, Records received, ... Added to Rex's records; an upload starts at Request created), Request details for a clinic request, the server's status sentence, the day-7 waiting note, the failure card with the recovery actions the server authorises (can_update_clinic_email, can_retry_send, can_convert_to_upload, can_revoke), the tap-to-call card when the server gates the clinic phone on, and Cancel request with its inline confirm.
  • Visits & records (the full record): the section stack (Needs attention, the What's on file coverage card, Sources on file, identity, upcoming care, then each entity type), the owner's per-row fact editor, and the Send Rex's record sheet (Who it's for with Vet / Groomer / Boarding, Send to and Send, Create link and share on the web, Share link on React Native, Download PDF, the active links with Turn off). See "Share a record with an audience, by email" below.
  • Document: the print-like Rex's health record preview with the Download PDF control.
  • Timeline and per-document contributions: the depth views, now with the screen header and skeleton.
  • Clinic picker, clinic-release consent and owner upload: the request path (see the two sections below; the picker needs the clinic-request switch).

Everything the member reads is either the Everfur app's own copy, verbatim (since 30 Sep 2026 the wording of the owner-approved Everfur app designs), or the server's clinical text, rendered verbatim. The old single-surface request list is still available to a hook-based UI: selectRequestList, selectPrimaryLabel, selectClinicCall, selectRequestTitle and buildRequestTimeline from the same subpath.

  • Requests (Medical records): every upload and clinic request for the pet, each with its own status, and Request records / I already have the records. The dashboard's request card (2 active record requests, 1 document being read) opens it, so every clinic of a multi-clinic request has a visible status.

Mounting the screens in your own navigator. Every screen is also exported by name from @everfur/sdk/records (and @everfur/sdk/web/records): EverfurRecordsDashboard, EverfurRecordsRequests (every request), EverfurRecordsRequest (requestId), EverfurRecordDetail, EverfurRecordDocument, EverfurClinicPicker, EverfurRecordsConsent (clinic, the ClinicDraft the picker hands over) and EverfurRecordsUpload; EverfurRecordsTimeline and EverfurDocumentContributions (docId) stay on records/depth. Each takes the same base props: petRef, petName?, onNavigate?(to: RecordsScreen) (the screen it wants next, as data: { name: 'request', requestId }, { name: 'contributions', docId }, { name: 'consent', clinic }, or a bare { name } for the rest) and onBack?. Without onNavigate a screen draws no navigation control at all, never a dead one. The stack model is exported too (recordsNavReducer, pushRecordsNav, popRecordsNav, transitionRecordsNav): dashboard resets the stack, upload -> request replaces the upload screen, everything else pushes.

The host seams, all optional, all on EverfurRecords and on every screen that uses them:

  • petName: the pet's display name for headers and lead copy. Absent, the screens use the app's own nameless forms (your pet).
  • onOpenUrl(url, kind): your app's way to handle a share link (share_link), the presigned record PDF (record_pdf) or, on the web, one uploaded request document (request_document). The default never leaves your app: on React Native the link goes to the OS share sheet (Share.share, with the app's own message); so does the PDF's short-lived link (it expires in about ten minutes, and it is the link, not the file). To share the PDF FILE the way the Everfur app does, pass onOpenUrl and, for record_pdf, download it with expo-file-system and hand the local file to expo-sharing, then delete it. On the web a link is shared with navigator.share, else copied, and a file is saved in place (no new tab). The SDK never opens a relative or non-https URL.
  • onCopy(text): the clipboard. On React Native there is no clipboard dependency: without onCopy the share sheet renders the link as selectable text and hides Copy link.
  • onAsk(prompt, entityRef): the app's Ask Everfur pills (about the marker summary, one marker, one reading, a conflict, upcoming care, an exam, a visit, a request). The SDK builds the prompt; you route it into your chat. Without onAsk no pill renders, and the records bundle imports nothing from chat.
  • location ({ latitude, longitude }): biases the clinic search.
  • initialScreen: the screen the flow starts on (the dashboard when omitted); onExit: the root screen's back control (absent, the root draws none and your navigator owns leaving).

Hardware back (React Native). The flow subscribes to BackHandler on mount: while the stack is deeper than one screen a press pops and the handler returns true; at the root it returns false so your navigator handles the press. The subscription is removed on unmount. On the web, see historyMode below.

consentVersion is the version of your own consent text (see step 1), never a value Everfur supplies.

Two host seams are yours to supply. The upload control is enabled only when both are wired:

  • onPickDocument, a RecordsDocumentPicker: open your own picker limited to PDF, and resolve null when the user cancels, a FileHandle with the local uri, mimeType: 'application/pdf' and sizeBytes, or an ARRAY of handles for a multi-select pick. A batch uploads one file at a time on the same request with a row per file (Queued, the percentage, Verifying…, Done, Failed); a non-PDF or oversize file in it is skipped before any request is made and named in a banner with the reason, and one failed file never stops the rest. A single non-PDF is refused the same way. name is optional, shown in the rows locally, and never sent. With expo-document-picker, for example, that is assets from getDocumentAsync({ type: 'application/pdf', multiple: true }):

    // docs:no-compile (expo-document-picker is your dependency, not the SDK's)
    const res = await DocumentPicker.getDocumentAsync({ type: 'application/pdf', multiple: true, copyToCacheDirectory: true });
    if (res.canceled) return null;
    return res.assets.map((asset) => ({ uri: asset.uri, mimeType: 'application/pdf', sizeBytes: asset.size, name: asset.name }));
    

    The hook exposes the same loop as uploadDocuments(requestId, files) and the rows as uploadQueue.

  • An upload transport. createRnRecordsUploadTransport() (from @everfur/sdk) posts the picked file from its local uri straight to the presigned upload with React Native's own XMLHttpRequest and FormData, with no native dependency. It never uses the global fetch: Expo installs expo/fetch there, and it cannot post a local file part. Set it as uploadTransport on EverfurConfig, or pass it to one surface as <EverfurRecords uploadTransport={...} /> (the prop wins; useEverfurRecords({ petRef, uploadTransport }) takes the same option). It reports progress only on completion. Without a transport, records reads still work and the upload control renders disabled.

The surface does not prefetch. The first consent, request and record calls happen on mount, never at SDK init.

Request records from a clinic

The clinic adapter reuses Everfur's existing authorization, dispatch, reminder and recovery workflow. It is a separate, default-off capability: Everfur must enable both the global and tenant clinic-request switches and grant records.clinic.create, in addition to the records prerequisites above. An upload entitlement alone does not permit contacting a clinic.

Use useEverfurRecordsClinic from @everfur/sdk/records (native) or @everfur/sdk/web/records (web). This is the headless API. The opt-in <EverfurClinicRequest> from the same subpath supplies the clinic picker and authorization flow, while <EverfurRecords> supplies reading, consent and owner upload. Pass petRef, its displayed petName, and ownerName (the owner confirms/edits the name before signing). Use onCreated to refresh the Records view and optional onClose to dismiss your dialog/screen. Both components include drawing and accessible typed-signature capture. On the web it is a <canvas> registered through the signature upload; on React Native it is the built-in SignaturePad (a pen-stroke capture with no native dependency) whose one-bit PNG travels inline as signaturePngBase64, a few hundred bytes. A host with its own signature component passes onCaptureSignature(context) instead, just as it provides a document picker: show the displayed authorization, capture the signature on the owner's action, and return { signatureId } (uploaded with the signature methods below), { signaturePngBase64 }, or null on cancellation. Respect context.signal; never reuse another pet's signature. No new native dependency is required by any installation.

The clinic picker lists the clinics this member requested from before under Recently used (recentClinics({ limit }), GET /records/clinics/recent, 1 to 25) while no search is active.

  1. Call searchClinics({ query }) or recentClinics() to select a clinic, or collect its name for the free-text path.
  2. Call getAuthorizationCopy(). Present the returned scope and authorization templates with the pet, clinic and owner details. Use the returned authorizationVersion; do not hardcode legal copy.
  3. Obtain the owner's explicit signature. Supply its PNG as signaturePngBase64, or stage it through initiateSignatureUpload(size) → multipart POST to the returned policy → completeSignatureUpload(id). Supply the resulting signatureId. Never generate a signature without the owner's action.
  4. Call createClinicRequest with petRef, ownerName, authorizationVersion, a stable idempotencyKey, exactly one of clinicId/clinicFreeText, and exactly one of signatureId/signaturePngBase64. clinicEmail is optional; server anti-relay checks still apply.
  5. Refresh useEverfurRecords({ petRef }) to show the request. Everfur runs the follow-ups; the app must not schedule its own clinic emails. Use the same request ID for retryRequest, updateClinicEmail, revokeRequest, or convertToUpload when the corresponding recovery is available.

Clinic contact authorization is not the read-consent grant above. Both are independently audited. The request records the real signer's name without changing the partner's virtual account identity. Recovery/signature mutations are not automatically retried. Retrying creation uses the same body idempotency key; a changed authorization requires a new logical attempt.

Several clinics in one signed action

POST /records/clinic-requests/batch requests one pet's records from up to 10 clinics at once, behind the additional partner.records_multi_clinic_enabled switch (404 while it is off, like the rest of the clinic surface). Each clinic carries its own clinic target, signature and idempotency key, and each is validated as a single clinic request; one request is created per clinic under a shared request_group_id, and a clinic that fails does not fail the rest. The typed client is createRecordsClinicBatchClient on @everfur/sdk/core (kept off the records subpaths so a records-only bundle does not pay for it); it takes the same auth the clinic repository takes and answers { requestGroupId, petRef, results, createdCount, failedCount }, each result carrying created with the mapped request, or failureCode and failureReason. Call it only from the owner's own signed action, exactly as createClinicRequest.

The prebuilt composer is <EverfurClinicRequestBatch> on @everfur/sdk/records and @everfur/sdk/web/records (useEverfurRecordsClinicBatch() is its client over the provider's session). The owner picks up to 10 clinics from search, the Recently used list or a typed name (Add a clinic, then Up to 10 clinics), gives each clinic without an address on file an email (Email to add), reads every clinic's scope statement, and signs ONCE: the signature is registered once and its id is sent for every clinic, which is what the batch route asks of a multi-clinic caller (its inline signature bytes are capped at one signature's worth). The result shows the consumer's sentences (2 requests sent., 1 of 2 requests sent. 1 couldn’t be sent., or the all-failed line) with Sent / Not sent per clinic and the server's reason for a failed one; a failed send keeps the composed clinics and their idempotency keys, so Try again replays the same batch.

GET /records/request-groups/{request_group_id} (same switch) answers the group rollup: {request_group_id, pet_ref, total, responded, pending, members} with one request wire per clinic. getRequestGroup(id) on the clinic controller maps it to RecordsRequestGroupView; the composer reads it once after the batch and shows 1 of 2 clinics responded with each member's status label. A group for another pet, another member or a missing id is one opaque 404.

In the prebuilt flow the composer is the clinicBatch screen, reached from the full record screen's header control Request records from clinics (the consumer's own entry point) and left by the stack's own back; a sent batch lands on the dashboard, where the new requests are listed. A host that mounts screens in its own navigator still mounts <EverfurClinicRequestBatch> by name and supplies onClose and onCreated itself.

After conversion to owner upload, upload to that existing request ID. Do not create a second request. Revocation and conversion remain available when new clinic contact is disabled, as long as the base records surface and the relevant ownership/permission checks allow them.

Record depth (behind a separate switch)

The base record projection withholds four sections the owner sees in the Everfur app: lab work, encounters, clinician notes and physical exams. Behind the strict partner.records_full_depth_enabled switch (off by default; Everfur turns it on per deployment), the record read carries lab_results, encounters, clinician_notes and physical_exams, and two more routes open: GET /records/pets/{pet_ref}/timeline (the app's timeline: visits, vaccines, weights, medications) and GET /records/pets/{pet_ref}/documents/{doc_id}/contributions (what one published document contributed, in the record's own row vocabulary). Off, the keys are absent and the two routes answer 404 like an unmounted path; a pet without records consent answers {withheld: true}.

These ship on their own subpaths so a records integration that does not render them never downloads them: @everfur/sdk/records/depth and @everfur/sdk/web/records/depth export EverfurRecordsTimeline, EverfurRecordDepth and EverfurDocumentContributions (each petRef-scoped, entitlement-gated, rendering the app's empty leaf for a withheld answer, never partial content), the hooks behind them, and the mappers; createRecordsDepthClient is on @everfur/sdk/core (getRecordDepth, getTimeline, getDocumentContributions). RecordDepthView.available is false while the switch is off. Headings and chips are the Everfur app's own; every clinical sentence is the server's, rendered verbatim. The prebuilt EverfurRecords flow mounts the timeline and the contributions itself, so a host that uses the flow needs nothing from the depth subpath; the two views take the same petName, onNavigate and onBack seams as the other screens and draw the screen header and the skeleton the app draws.

The same switch opens the rest of the consumer record. With it on, the record read also carries completeness (the What's on file card: coverage flags, document counts, held facts) and conflicts (documents that disagree, BE-graded), every entity row carries its canonical_key, and six more routes answer:

route what the flow does with it
GET /records/pets/{pet_ref}/markers the dashboard's marker summary ring and trend carousel (useRecordsMarkers)
POST /records/pets/{pet_ref}/everfur_record/share, POST .../everfur_record/share/email, GET .../everfur_record/shares, DELETE .../everfur_record/shares/{share_id} the share sheet: mint a link, email one, list the active links, turn one off (useRecordShares)
GET /records/pets/{pet_ref}/everfur_record/pdf-link Download PDF on the record and the document screens (useRecordPdfLink)
POST /records/pets/{pet_ref}/everfur_record/facts/edit, POST .../everfur_record/facts/remove the owner's per-row fact editor, optimistic with rollback (useRecordFactEdit)
GET /records/authorization-signatures the reusable signature gallery on the clinic-release consent screen

Each of these features carries its own platform switch, configurable per tenant. The SDK never sends or reads a switch NAME: every surface keys on the answer its OWN routes give, so however the platform splits the switches, turning one feature off leaves every other feature exactly as it was. The record screen still loads, the features the tenant left on still work, and the feature that is off is simply absent. Take the canonical names from the platform when you configure a tenant; do not derive them from anything in this SDK.

With a feature off its routes are 404 and the SDK treats that as "surface hidden", never as an error: the marker ring, the share sheet's link and PDF controls, the download control and the edit affordances simply do not render (available: false on each hook), and the rest of the screen is unchanged. The same is true of the three depth SCREENS: the timeline, the record depth sections and a document's contributions each render their absent state on a dark tenant, not the generic "check your connection" leaf with a Try again that would re-issue the same 404 for ever (available: false on useRecordsDepthRead). A share_url comes back in the consumer's relative form; the SDK resolves it against your apiBaseUrl origin and refuses anything that is not https. The markers read answers {withheld: true} without records consent and an empty markers list for a pet with consent and no lab data (the dashboard renders the empty state, not an error). A fact edit is refused with a 409 once an Everfur reviewer has decided that fact (the app's "A clinician already reviewed this detail" line); the typed records_consent_required 409 is a different answer and is not mistaken for it. The two are told apart by the WIRE code the body carries, not by HTTP status or by the SDK's normalized EverfurErrorCode: the reviewer refusal is raised with a plain sentence and so reaches you as ConflictException, the consent refusal as records_consent_required, and both normalize to conflict. The SDK sends the row's category as entity_type, its canonical_key, and the corrected value under the slot the ORIGINAL value lives in (corrected_fields.value_num as a number for a numeric row such as a weight, value_date for a YYYY-MM-DD row, value_bool for true / false, value_text for everything else, and for a corrected value that does not fit its slot), because the read overlay clears the sibling slots when one is set: a weight corrected as text would leave the row with no number.

import { petRef } from '@everfur/sdk';
import { EverfurRecordsTimeline } from '@everfur/sdk/records/depth';

export function TimelineScreen(): React.ReactNode {
  return <EverfurRecordsTimeline petRef={petRef('rex-42')} />;
}

Share a record with an audience, by email

The share sheet sends a pet's record to someone by email: the member picks who it is for (Vet, Groomer, Boarding), types an address under Send to and presses Send. The server mints a share link and emails it; the link is never returned to the SDK or to your app, so it cannot reach onOpenUrl or the clipboard. The answer is the new link's id and expiry, and the link appears under Active links with no URL (its Copy link is disabled) so the member can still turn it off. On the web the sheet announces Sent to {email}. The link works for 7 days..

The email send works whether or not Everfur has the share-audience switch on. What the switch changes:

  • Off (the default). The pick only chooses the email's wording (recipient_kind); every recipient gets the same record. The sheet draws no What's included list and keeps its old helper line, because saying "we include the right pages" would be false.

  • On. GET /widget/v1/records/features adds share_audiences, one entry per audience in the order the sheet offers them, each with the content sections its link carries and, for a groomer or a kennel, the only entity_types it keeps (null means every type):

    { "features": { "sharing": true }, "share_audiences": [
      { "audience": "vet", "sections": ["identity", "documents", "encounters", "entities", "entity_series", "lab_results", "forward_looking", "physical_exams", "completeness"], "entity_types": null },
      { "audience": "groomer", "sections": ["identity", "documents", "entities", "entity_series", "forward_looking"], "entity_types": ["allergy", "problem", "vaccination"] },
      { "audience": "boarding", "sections": ["identity", "documents", "entities", "entity_series", "forward_looking"], "entity_types": ["allergy", "medication", "parasite_prevention", "vaccination"] }
    ] }
    

    The emailed link is then stamped with the picked audience and carries only that scope, the Create link and share / Share link mint sends {"audience": ...} too, and the sheet draws the lead Pick who it's for; we include the right pages. with a What's included list built from the served scope and the SDK's own section labels (a depth section your tenant has switched off is not listed). The scope is the most a link carries: your tenant's own switches still apply when the link is opened.

The SDK reads the features route itself and fails closed: a failed read, or a share_audiences that is not the documented shape, means no audiences (today's sheet), and never costs the photo upload types. With the switch off the mint refuses an audience key with a 400, so the SDK only sends one the server served.

If you build your own surface: useRecordShares(petRef) exposes shareAudiences, features, sending, sendEmail({ recipientEmail, recipientKind }) and createLink(audience?); the core client exposes emailShare(petRef, { recipientEmail, recipientKind }), createShare(petRef, { audience }) and readFeatures(). The email route has the mint's gates (the sharing feature's 404, the records.record.share capability, the typed 409 records_consent_required, the tenant's daily quota 429) plus the member's hourly email budget (429) and the live-link cap (429 share_limit_reached). A mail that fails after the link was minted is a retryable 502; that link is listed and revocable. recipient_kind is vet, groomer, boarding or other (the neutral wording, no audience). The SDK checks the address with the server's own lenient rule before it sends.

On the web

The same hook, the same prebuilt surface, from @everfur/sdk/web/records under the EverfurProvider from @everfur/sdk/web. Three things differ from React Native:

  • The upload seam is a browser File. Set uploadTransport: createWebRecordsUploadTransport() on the config; the prebuilt surface then renders a real file input (accept="application/pdf"), and a hook-based UI turns a File into the handle with fileToHandle(file) (or asks for one with pickDocument()). The type is read from the first bytes (%PDF-), never from the name, so a renamed image is refused before a request exists. Without the transport the upload control is disabled rather than a control that fails after a request has been created.
  • Nothing is stored. The record lives in memory for the life of the page; no object URL is created for the picked file, and neither tokens nor record content reach localStorage, sessionStorage or IndexedDB.
  • The browser POSTs the bytes to the upload host itself, so an in-page host must allow that host in its connect-src (production: https://everfur-media-prod-495688866294.s3.amazonaws.com; the upload_url in the initiate response is authoritative). Request records does the same with the drawn signature, to the consent-signature bucket (production: https://everfur-records-signatures-prod.s3.amazonaws.com), which also serves the saved-signature previews, so that origin goes in connect-src and img-src. A page that is itself inside an iframe also needs the media bucket in frame-src, because there a file is saved through a hidden frame (the full table is in the web integration guide). In frame mode the records frame document carries that policy itself: Everfur('init', { surface: 'records', user, activePet, consentVersion }) opens records.html beside the chat frame, on the same fence and the same token channel.
import { EverfurProvider, petRef, userRef } from '@everfur/sdk/web';
import { EverfurRecordsPage, createWebRecordsUploadTransport } from '@everfur/sdk/web/records';

<EverfurProvider
  config={{ publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.everfur.com/api/v1', uploadTransport: createWebRecordsUploadTransport() }}
  user={{ userRef: userRef('customer-123'), getToken: async () => (await fetch('/api/everfur/session', { method: 'POST' })).text() }}
>
  <EverfurRecordsPage petRef={petRef('rex-42')} petName="Rex" ownerName="Jane Doe" consentVersion="records-consent-v1" />
</EverfurProvider>

On the web, mount EverfurRecordsPage: it is the Everfur web app's records page, not the mobile stack, and it is complete on its own. Request records (the clinic picker, then the authorization and the signature), Upload records I already have and a request's detail open in a right-side sheet over the page; Visits & records (the full record, vaccinations included) and Timeline replace the page body with a Back control. It never touches history and nothing opens a new tab: the share sheet shares a link with navigator.share (else the Clipboard API, unless you pass onCopy) and saves the PDF and an uploaded document in place, unless you pass onOpenUrl. The hosted records frame serves this page too.

EverfurRecords on the web is the React Native-shaped flow, kept for existing embeds. Its historyMode decides how the browser's back button relates to the flow's stack: memory (the default) keeps the stack in component state and never touches history; browser mirrors the stack onto the history stack (one entry per screen, the browser's back button pops the flow, an in-app pop rewinds history) and removes its popstate listener on unmount.

examples/minimal-web-records.tsx is the copy-paste shape (register the pet first, then mount the surface).

Reference: the routes this guide uses

Base path is /api/v1/widget/v1.

method path body returns
POST /pets/{pet_ref} name, species, breed, age_years, weight_kg, all optional 201, the pet profile
PATCH /pets/{pet_ref} any of the same fields; null clears, at least one field 200, the pet profile
DELETE /pets/{pet_ref} 204
DELETE /me 204; 503 when the erasure did not finish (retry)
GET /records/pets/{pet_ref}/consent {pet_ref, granted, version, granted_at}
PUT /records/pets/{pet_ref}/consent {consent_version, idempotency_key} the same consent shape
DELETE /records/pets/{pet_ref}/consent the same consent shape, granted:false
POST /records/requests {pet_ref, idempotency_key} 201, the request wire
GET /records/requests a list of request wires (each with action_needed_label, failure_reason, failure_code, recovery_stage, show_clinic_phone, clinic_name, clinic_email, clinic_phone, held_item_count, converted_to_upload_at, request_group_id beside the fields above)
GET /records/requests/{request_id} one request wire
POST /records/requests/{request_id}/uploads/initiate {content_type, file_size} {upload_id, upload_url, upload_fields, expires_in_seconds, max_content_length}
POST /records/requests/{request_id}/uploads/complete {upload_id} {source_doc_id, request_id, status}
GET /records/pets/{pet_ref}/record the redacted record projection (plus the four depth sections with the depth switch on)
GET /records/pets/{pet_ref}/timeline (depth switch) {withheld, pet_ref, visits, vaccines, weights, medications, disclaimer}
GET /records/pets/{pet_ref}/documents/{doc_id}/contributions (depth switch) {withheld, pet_ref, document, contributions, contribution_count}
GET /records/pets/{pet_ref}/markers (depth switch) {withheld, pet_ref, markers, summary, disclaimer, featured_marker_id}; markers: [] for consent without lab data
POST /records/pets/{pet_ref}/everfur_record/share (depth switch) no body, or {audience} while the share-audience switch is on {share_id, share_url, expires_at} (+ audience, scope with the switch on); share_url is relative to the API origin
POST /records/pets/{pet_ref}/everfur_record/share/email (depth switch) {recipient_email, recipient_kind} {share_id, expires_at} (+ audience, scope with the switch on); never the link
GET /records/pets/{pet_ref}/everfur_record/shares (depth switch) {shares: [{share_id, expires_at, created_at, view_count, last_viewed_at}]} (+ audience with the switch on; no URLs)
DELETE /records/pets/{pet_ref}/everfur_record/shares/{share_id} (depth switch) 204
GET /records/pets/{pet_ref}/everfur_record/pdf-link (depth switch) {download_url, expires_at} (presigned); 404 before a record exists
POST /records/pets/{pet_ref}/everfur_record/facts/edit (depth switch) {entity_type, canonical_key, corrected_fields, idempotency_key?}; corrected_fields is one typed slot, value_num (number), value_text, value_date or value_bool 200; a plain 409 once a reviewer decided the fact
POST /records/pets/{pet_ref}/everfur_record/facts/remove (depth switch) {entity_type, canonical_key, idempotency_key?} 200
GET /records/authorization-signatures (depth switch) {signatures: [{signature_id, preview_url, created_at}]}
GET /records/clinics/recent limit 1 to 25 (query) the clinic search page shape, most recent first
GET /records/request-groups/{request_group_id} (multi-clinic switch) {request_group_id, pet_ref, total, responded, pending, members}
POST /records/clinic-requests/batch {pet_ref, owner_name, authorization_version, clinics: [{clinic_id or clinic_free_text, clinic_email?, signature_id or signature_png_base64, idempotency_key}]} (1 to 10) {request_group_id, pet_ref, results, created_count, failed_count}
POST /records/sandbox/requests/{request_id}/clinic-reply {outcome} (sandbox tenants only) {result, request}
POST /records/sandbox/requests/{request_id}/publish-sample (sandbox tenants only) {result, request}

Every request body on this surface is extra="forbid". An unexpected key is a 422, not a silent drop.

Test mode (sandbox tenants only)

On a sandbox tenant you can take a request through the rest of its life without a clinic or a reviewer: simulate the clinic's reply, then publish a sample record. See 10-TESTING.md for useEverfurRecordsSandbox.

Deployment-dependent delivery

  • Webhooks for a status change (record_request.updated, record.ready) are in preview and off until Everfur enables them for your tenant; see 15-EVENTS-AND-WEBHOOKS.md. Until then, polling is the only mechanism.
  • Clinic requests require the backend clinic adapter and catalog migration; installing a new SDK alone does not enable them. Verify the tenant's effective capabilities before testing outbound contact.

Testing

How to test an Everfur integration without sending test data into a tenant that holds real veterinary records, and how to tell a stream that worked from one that only looked like it did.


Test against a sandbox tenant, not a live one

A sandbox tenant is a separate tenant whose data is test data. It is not a mode you switch on, and it is not a key prefix.

Two independent facts exist, and only one of them is in your hands today:

What it decides Where it lives
Key prefix which credentials a host accepts every deployed host (staging and production) accepts pk_live_ and refuses pk_test_
is_sandbox what a tenant is on the tenant, immutable after creation; livemode: false in the entitlements response

The prefix fence exists in the code and is bidirectional by design, but no deployed environment is configured as a sandbox host, so pk_test_ is refused everywhere with a 401 on the prefix before any lookup. Your test environment is a sandbox tenant on the staging API with an ordinary pk_live_ key.

is_sandbox is immutable after creation, deliberately. A tenant that could be flipped from sandbox to live would carry its test pets, test conversations, and test records across the boundary with it, into a table that also holds real records. Creating a new tenant is cheap. Laundering one is not something the schema permits at all.

Read the tenant's side of this from GET /widget/v1/entitlements, which reports livemode. Anything that must never touch real data should check livemode === false and refuse otherwise, live on every call rather than from a cache. A stale "yes this is a sandbox" is exactly the answer that must never be reused.


Records test mode (sandbox tenants only)

A sandbox tenant never emails a clinic, so a records request would otherwise wait forever for a reply. Test mode moves one of the signed-in user's own requests forward through the same transitions a real reply uses, and sends the same webhooks, without contacting anyone. The hook is on @everfur/sdk/testing/rn for React Native and on @everfur/sdk/testing/web for the web:

import { useEverfurRecordsSandbox } from '@everfur/sdk/testing/rn';
import type { RecordsRequestView } from '@everfur/sdk/records';

export function useAdvanceForTesting(): (request: RecordsRequestView) => Promise<void> {
  const sandbox = useEverfurRecordsSandbox();
  return async (request) => {
    const reply = await sandbox.simulateClinicReply(request.id, 'records'); // or 'no_records' | 'declined'
    if (!reply.ok) return; // not found: not a sandbox tenant, not this user's request, or test mode is off
    const published = await sandbox.publishSampleRecord(request.id);
    if (published.ok && published.value.result === 'not_applicable') {
      // The request is not in a state a sample record can be published from.
    }
  };
}
  • simulateClinicReply(requestId, outcome) calls POST /widget/v1/records/sandbox/requests/{request_id}/clinic-reply with { "outcome": "records" | "no_records" | "declined" }. publishSampleRecord(requestId) calls .../publish-sample with no body. It attaches a synthetic sample document and publishes a sample record for the request's pet.
  • Each settles to { result, request }: applied (the request moved), already_applied (it was already there; nothing is written twice) or not_applicable (the records rules do not allow it from the request's current state), with the request as it now stands.
  • The routes answer only a sandbox tenant's signed-in user, for that user's own request, while Everfur has test mode switched on. Every other case (a live tenant, no session, an unknown or foreign request, test mode off) is the same 404, which the SDK settles to the ordinary not-found error. A live tenant cannot tell test mode exists.
  • The hook lives on the testing entries, not on @everfur/sdk/records, so none of it ships in your production records bundle. Do not call it from a production build.
  • After simulateClinicReply or publishSampleRecord, refresh useEverfurRecords and the request card's step rail advances exactly as it would for a real reply (Records received, Preparing your records, Ready to view), because the rail reads the same request fields. A batch made with EverfurClinicRequestBatch on a sandbox tenant reaches sent without the dispatch worker, so each member request can be advanced the same way and GET /records/request-groups/{id} shows the rollup move.

Testing a webhook handler against every event type

generateTestSignatureHeader signs any envelope, so a handler test can deliver each catalog type with the fields its producer writes (15-EVENTS-AND-WEBHOOKS.md). The typed payload map is the assertion: a fixture that misses a field the type requires fails to compile.

import { constructEvent, generateTestSignatureHeader, type EverfurWebhookPayloadMap } from '@everfur/sdk/server/events';

const secret = 'whsec_test_only';
const object: EverfurWebhookPayloadMap['pet.vaccination.due'] = {
  object: 'pet_vaccination', user_ref: 'member-1', pet_ref: 'rex-42', vaccine: 'rabies', due_at: '2026-10-01', stage: 'due_in_3d',
};
const body = JSON.stringify({
  id: 'evt_test_2', object: 'event', type: 'pet.vaccination.due', api_version: '2026-09-15',
  created: Math.floor(Date.now() / 1000), livemode: false, data: { object },
});
const event = constructEvent(body, generateTestSignatureHeader({ payload: body, secrets: secret }), secret);
if (event.type === 'pet.vaccination.due') console.log(event.data.object.stage); // due_in_3d

Testing without a network: MockTransport

The SDK ships a transport double so your tests can drive the full runtime, including streaming, with no network and no credentials.

import { MockTransport, wire } from '@everfur/sdk/testing';

const transport = new MockTransport()
  .enqueueRequest({ status: 200, body: { conversation_id: '…' } })
  .enqueueStream({
    kind: 'sse',
    blocks: [
      wire.accepted(),
      wire.delta('Chocolate is toxic to dogs. '),
      wire.delta('Call your vet.'),
      wire.done(),
    ],
  });

wire builds real frames rather than approximations of them, so a test that passes against MockTransport is testing the protocol the server actually speaks.

It also records what was requested, so you can assert on the calls your integration made and not only on what it rendered.


The streaming frame protocol

A send returns a sequence of frames, not a single response. There are two levels to this, and conflating them will cost you an afternoon.

What the SDK gives you

The SDK normalises every frame into this model:

Frame Meaning
accepted The server took the message.
heartbeat Connection keepalive. Carries no content.
delta A chunk of answer text. There are many of these.
done The answer is complete.
error Failure, carrying a code and whether it is retryable.
analysisStarted, analysisComplete Analysis lifecycle, where analysis is in play.
unknown A frame this SDK version does not recognise. Counted, never dropped.

What the wire actually carries

These names are the SDK's model, not the field names on the wire. If you parse raw SSE you must match on the real shapes, which for the chat send route are untyped. Captured from a live send:

data: {"status": "reasoning"}
data: {"token": "Hello "}
data: {"token": "there "}
...
data: {"done": true, "conversation_id": "...", "message_id": "...", "follow_up_questions": []}
data: {"metadata": true, "title": "Greeting and Veterinary Advice Overview", "follow_up_questions": []}

Four things follow, and each one is a bug if you assume otherwise:

  • There is no type field on this route. Delta frames carry token and nothing else. Code that branches on type === 'text.delta' matches nothing and renders an empty answer. The SDK accepts type, delta or token, which is why it works either way.
  • {"status": "reasoning"} is the opening frame, and it is what the SDK reports as accepted.
  • A frame arrives AFTER done. The metadata frame carries the conversation title and follow-up questions. A client that closes the stream on done silently loses both.
  • follow_up_questions appears twice, on done and again on metadata. Read whichever you get; do not assume the first is final.

unknown being counted rather than discarded is deliberate: a frame the client does not recognise is evidence of a protocol change, and silently dropping it turns that into a mystery later.

The assertion that actually matters

A stream that died mid-answer looks identical to a short answer if you only read the text. Both give you a string, and both come back with HTTP 200.

So assert on the frame sequence, not the text:

  • at least one delta frame arrived, and
  • a terminal frame arrived (done, or error if you are testing failure).

Zero delta frames with a 200 is a failure. everfur_send_test_message in the MCP server applies exactly this rule and reports failure on an empty stream regardless of status, and it returns a frame histogram so the shape of what arrived is visible rather than inferred. See 12-CLI-AND-MCP.md.


What to test, and what not to bother testing

Worth testing in your integration:

  • The four render states of every surface: loading, empty, error, populated. An Everfur surface can be legitimately empty (no conversations yet) and legitimately withheld (consent not granted), and these are not the same state.
  • Consent branching. has_decision: false means nobody has been asked yet. granted: false with has_decision: true means the user said no. Re-prompting the second case is a dark pattern, and it is easy to write by accident because both are falsy.
  • Identity continuity in anonymous mode. Assert that the same session id is used across a create and a send. A fresh id per request means a fresh user per request, and the symptom is a 404 that names the conversation rather than the identity.
  • Behaviour on an empty entitlement verdict. Your UI should degrade, not blank.

Not worth writing yourself: retry policy, backoff, request coalescing, frame parsing, and the settle-never-throws contract. These are covered by the SDK's own suite, and reimplementing assertions about them in your repo tests the SDK, not your integration.


The trap that will waste an afternoon

An unprovisioned tenant works for about thirty seconds, and then stops.

The SDK compiles in a default granting chat.message.create, so chat renders immediately on mount without waiting for a network round trip. The entitlement poller then replaces that snapshot wholesale with the server verdict, on a cadence of at least 30 seconds.

If the tenant has no grant, the verdict is empty, every gate goes off, and the surface goes dark. No error is raised, because nothing failed.

Consequences for testing:

  • A test that mounts, asserts, and unmounts inside a second passes on a tenant that is not provisioned at all. It is asserting against the compiled-in default.
  • A manual demo that worked at the start of a meeting and was blank by the end was showing you exactly this.

Make at least one test wait past a poll cycle, or assert directly on the entitlement verdict, so a provisioning failure fails your suite instead of your demo.


Routes that 404 by design

Two categories of 404 will show up in tests and neither means what it looks like:

  1. The records surface is dark by default. It sits behind partner_records_enabled, off by default, and the gate returns 404 rather than 403, so it is indistinguishable from a surface that was never built. If your records tests 404 uniformly, ask whether the flag is on for your tenant before debugging your code. The clinic request routes and the multi-clinic batch (POST /records/clinic-requests/batch and GET /records/request-groups/{id}, their own partner.records_multi_clinic_enabled switch) sit behind further switches of the same kind, and so do the record depth routes (GET /records/pets/{pet_ref}/timeline, .../documents/{doc_id}/contributions, partner.records_full_depth_enabled): with that switch off the record read simply omits the four depth keys and RecordDepthView.available reads false, which is not an error.
  2. Six routes have never existed, including photo and video analysis. They are listed in 08-API-REFERENCE.md. A staging entitlement verdict may still claim photo.checkup.execute and video.gait.execute are granted. The verdict is wrong, the route is genuinely absent, and the SDK renders these off regardless.

Errors

Almost every error on the partner plane returns the same envelope. Handle the envelope once and you have handled every failure the application produces. There is exactly one exception, and it is documented at the bottom of this page rather than buried, because code that assumes the envelope is universal breaks on it.


The envelope

{
  "code": "ResourceNotFoundException",
  "message": "Pet profile 'unknown-ref' not found",
  "request_id": "840de30f-dc73-4fa2-b42f-ec92fe9012e8"
}
Field Use it for
code Branching. This is the stable field.
message Your logs. Safe to store, not written for pet owners.
request_id Include in any support request. It is how the failure gets found server-side.

Some errors carry extra keys beyond these three. They are additive and safe to ignore. A domain error cannot overwrite code, message, or request_id even if it tries, so the envelope cannot be forged from inside.

Two shapes that never appear

{"detail": ...}. This is FastAPI's internal error shape and it is what you will find if you read the backend source or a generated OpenAPI document. An exception handler replaces it before the response is sent. Code that branches on detail compiles, passes review, and never matches anything in production.

HTTP 422. FastAPI's default validation status is remapped on this API. Validation failures arrive as 400.


Codes

HTTP code Meaning
400 ValidationException The request body or parameters did not validate.
401 AuthorizationException Credentials missing, malformed, or refused.
402 PaymentRequiredException A tier upgrade is required, or a quota is exhausted.
403 AccessDeniedException Authenticated, but not permitted.
404 ResourceNotFoundException No such resource. See the section below before assuming it is missing.
409 ConflictException The request conflicts with current state.
413 ValidationException Payload too large.
415 ValidationException Unsupported media type.
429 ThrottlingException Rate limited. Read Retry-After where present.
500 InternalFailureException Server fault. Retry, then report with the request_id.
501 NotImplementedException The route exists but the operation is not implemented.
503 ServiceUnavailableException Temporarily unavailable. Retry with backoff.

Note that 400, 413, and 415 all carry ValidationException. The HTTP status is what separates them, so branch on the pair rather than on code alone when the difference matters.


Explaining a code: ask the explainer, do not guess

Before you branch on a code you have not met, or let a coding agent write a retry policy for one, ask the error explainer. The table above is not the only vocabulary: the SDK reports failures with its own codes (EverfurError.code, such as petNotFound or authExpired), three naming families are live at once, and one code arrives on several HTTP statuses. A code explained from the shape of its string produces a confident, wrong retry policy. The explainer answers from Everfur's error registry instead, and when it does not know a code it says so.

It has two front doors with one answer: the CLI command and the MCP tool call the same resolver and return the same document.

CLI MCP tool
Call everfur explain-error <code>; add --json for the document everfur_explain_error with code
Get it npm install -g @everfur/cli the MCP server in 12-CLI-AND-MCP.md
A contract file, when you have one --contract <file>, else EVERFUR_CONTRACT_PUBLIC, else contract-public/contract-public.json or contract-public.json in the working directory the contract_path argument, else EVERFUR_CONTRACT_PUBLIC
No contract file fetches the contract from GET /partners/contract with EVERFUR_DOCS_KEY, on EVERFUR_API_BASE_URL the same
Codes it explains wire codes and the SDK's own codes the same, and from @everfur/mcp 0.3.0 also the codes the MCP tools return (below)

On the hosted MCP endpoint the tool takes code alone.

What it needs. The code exactly as it arrived: codes are case sensitive and never normalised, so authexpired is not authExpired. Beyond that:

  • An SDK code that no wire response carries (petNotFound, sessionRequired) needs nothing else. The SDK's error table ships inside the CLI, so it is answered offline, before any contract is read.
  • A wire code is looked up in the contract's error registry. A contract file is checked against its schema and its digest before any code in it is trusted, so an edited file is refused rather than read.
  • With no contract file and no EVERFUR_DOCS_KEY, a wire code in the SDK's own copy of the contract table is still answered offline. That answer says namingFamily: "unknown" and confirmedOnWire: false, because the SDK's table records neither. Any other code is refused, and the refusal names the missing variable.

What comes back. everfur explain-error <code> --json prints an error-explanation.v1 document; the MCP tool returns the same document as explanation. The fields to act on:

Field Meaning
httpStatus The status the code arrives on. 0 for an SDK code, which has no status of its own.
retryable Whether the failure is retryable.
nextAction What to do next, derived from retryable and httpStatus.
cause Why it happens.
display Copy you may show a person as it is.
confirmedOnWire false means the code exists in source but no live route was traced emitting it: documentation, not a contract to branch on.
source Which registry answered, with the sha256 digest it was verified against.

An unknown code is refused, never explained. The CLI exits with a usage error (unknown_error_code); the MCP tool returns UNKNOWN_ERROR_CODE. Both list any near misses (did you mean) and how many codes they searched, which turns a typo into a fix. When the explainer says a code is unknown, say so. Do not fill it in.

The MCP tools' own codes, from @everfur/mcp 0.3.0. everfur_explain_error also explains the error.code of an everfur_* tool result, such as VERIFICATION_FAILED, PLAN_CHANGED, UNKNOWN_PLAN_STEP and CONTRACT_CREDENTIAL_MISSING. Those answers carry sourceKind: "mcp_tool_code" and httpStatus: 0: they are MCP result codes, and no response from the Everfur API carries one. If a spelling is ever both a tool code and a wire code, the answer is the wire explanation, with the tool code's meaning beside it under mcpToolCode. The CLI command does not explain tool codes.


The four errors that cost the most time

401 on your very first call

Check which key you pasted before checking anything else.

No deployed environment accepts a pk_test_ key: staging and production both take pk_live_ and refuse pk_test_ on the prefix, before any lookup, and the refusal is deliberately indistinguishable from a wrong key. A key also belongs to the deployment that issued it, so a staging sandbox tenant's pk_live_ against api.everfur.com is the same 401. Check the key and the base URL together.

Session minting has the same property by design: every credential failure returns one identical opaque 401. You cannot tell a bad publishable key from a bad secret key from a mismatched pair, because being able to tell would be a probing oracle. Verify the pair against the environment you meant to target rather than trying to narrow it from the response.

404 on a route you are certain exists

Three different causes, all reporting 404:

  1. The pet was never registered. Anything scoped to a pet_ref 404s if that reference is unknown, including calls whose failure looks unrelated. Granting records consent for an unregistered pet returns 404 about the pet, not about consent. Register the pet, and the same call returns 200.
  2. The records surface is dark. The entire records API sits behind partner_records_enabled, default off, enforced at a router-level dependency that returns 404 rather than 403. With the flag off the surface is indistinguishable from one that was never built. That is intentional, and it means you cannot detect it by probing. Ask whether the flag is on for your tenant.
  3. The route genuinely does not exist. Six commonly assumed routes have never been built. They are listed in 08-API-REFERENCE.md.

400 naming body.message

You sent content. The send route accepts message and nothing else. Messages are read back with a content field, which is where the confusion comes from, but the write side does not accept it.

No error at all, and nothing works

The hardest failure on this API is not an error. It is a 200 carrying an empty entitlement verdict.

Every capability resolves off, so the SDK renders every gate off, which is correct behaviour. The integration looks like it is working and does nothing. There is no code to search for, because nothing failed.

It has four distinct causes that are byte-identical from the client. Separating them by hand means checking tenant type, the catalog, the bundle, and the resolved scope path in four different places. everfur_diagnose in the MCP server does exactly that and names which one it is: see 12-CLI-AND-MCP.md.

One variant of this is worth knowing about in advance. The SDK compiles in a default that grants chat.message.create, so chat renders immediately on first mount. The entitlement poller then replaces that snapshot wholesale with the server verdict, on a cadence of at least 30 seconds. An unprovisioned tenant therefore works for about half a minute and then goes dark. If your integration demoed fine and failed later in the same sitting, this is why, and it is not a bug in your code.


The one exception to the envelope

Two routes are refused by API Gateway before the request reaches the application, so the application's exception handler never runs and the envelope is never applied:

POST /widget/v1/photo/analyze
POST /widget/v1/video/analyze

What comes back instead, measured on staging:

HTTP/2 403
x-amzn-errortype: AccessDeniedException

{"Message":"User is not authorized to access this resource with an explicit deny in an identity-based policy"}

Three differences that matter to your error handling:

  • The field is Message, capitalised. There is no message.
  • There is no code, so anything branching on code sees undefined and falls through to whatever your default case is. If that default is "retry", you will retry a permanent deny.
  • There is no request_id, so there is nothing to quote in a support ticket. Use the x-amzn-requestid header instead, which is a different identifier in a different system.

Both routes are documented as non-existent in 08-API-REFERENCE.md, so you should not be calling them. This is here for the case where you already are and cannot work out why the response looks nothing like the rest of the API.

A practical rule: treat a response whose body has no code as a transport or edge failure, not an application error, and do not retry it on the assumption it is transient.


Handling errors in the SDK

The SDK does not throw for wire failures. Operations settle with a tagged result:

import { petRef, useEverfur } from '@everfur/sdk';

const everfur = useEverfur();
const registered = await everfur.registerPet(petRef('rex-42'), { species: 'dog' });

// EverfurResult is a TAGGED UNION. Narrow on `ok` before reading `value`:
// `const { value } = await ...` does not compile, which is the point.
if (!registered.ok) {
  // registered.error: { code, displayMessage, requestId? }
  reportToYourLogs(registered.error.code, registered.error.requestId);
  showToUser(registered.error.displayMessage);
  return;
}

This is deliberate: an unhandled rejection in a chat surface takes down the screen, and a pet owner mid-question is the worst possible moment for that. Both the success and failure paths settle, so there is no path where a caller who forgot a catch loses the app.

Errors carry two message fields, and the split is enforced rather than documented. displayMessage is written for a person and is what you render. The wire message is developer-facing and can name internal resources, so it is defined as a non-enumerable property: it does not appear in JSON.stringify, in a spread, or in a console dump of the object. You can read it deliberately, and you cannot leak it by accident.

To find out what an error.code means and whether a retry can help, pass it to the explainer: see Explaining a code.

CLI and MCP server

Everfur ships a command line tool and an MCP server. They are the same code behind two front doors, so a question answered one way is answered the same way the other.

The MCP server is the one worth setting up. It lets the coding agent you already use write the integration, and it refuses to guess where guessing would produce something that compiles and is wrong.


Install

For the MCP server you do not install anything: npx -y -p @everfur/mcp everfur-mcp in your agent's MCP config fetches it on demand (see Setup below). The executable is named explicitly because the package also ships everfur-mcp-http for a partner's own server, so a bare npx @everfur/mcp cannot choose between them.

For the terminal commands:

npm install -g @everfur/cli

Three binaries land: everfur, everfur-mcp and everfur-mcp-http. Both packages are public on npm, MIT licensed.


Point it at an environment

Credentials come from the environment. No command takes a key as an argument, and no command prints one.

export EVERFUR_API_BASE_URL="https://api-staging.everfur.com/api/v1"
export EVERFUR_PARTNER_KEY="pk_live_…"
export EVERFUR_DOCS_KEY="dk_partner_…"

Test mode is not a separate deployment and there is no pk_test_ key: no deployed Everfur environment accepts one. Your test tenant is a sandbox tenant on the staging API, issued an ordinary pk_live_ key; what makes it a sandbox is the tenant itself, and its entitlements report livemode: false. Production is the same key shapes on https://api.everfur.com/api/v1 for your production tenant, and its keys are issued only after everfur_verify_integration passes on the sandbox tenant.

Variable Needed for
EVERFUR_API_BASE_URL Everything that touches the network. There is no default host.
EVERFUR_PARTNER_KEY Everything that touches the network. Your tenant's publishable key (pk_live_…), the one that ships in your app.
EVERFUR_DOCS_KEY Reading the Everfur contract: the planner, the error explainer and the MCP contract resources. A read-only partner docs key (dk_partner_…), minted with POST /api/v1/partners/docs-keys; it opens GET /partners/contract and no other route.
EVERFUR_PARTNER_SECRET_KEY Minting a session, and only in everfur verify when no EVERFUR_SESSION_TOKEN is set. Server side only. The MCP server never reads it.
EVERFUR_SESSION_TOKEN Acting as an already-minted session: a short-lived token your backend minted for a test user. Over MCP this is the only way everfur_verify_integration verifies.
EVERFUR_ADMIN_TOKEN Everfur-internal administration. Partners do not need this.

Set up the MCP server

Add this one block to your agent's MCP configuration. It is the same block for Claude Code, Cursor and VS Code; only the file it goes in differs:

  • Claude Code: .mcp.json at the project root (or claude mcp add-json everfur '<the everfur entry>' for your user scope).
  • Cursor: .cursor/mcp.json in the project (or ~/.cursor/mcp.json for every project).
  • VS Code: .vscode/mcp.json in the workspace, which wraps the same everfur entry under a servers key instead of mcpServers.
{
  "mcpServers": {
    "everfur": {
      "command": "npx",
      "args": ["-y", "-p", "@everfur/mcp", "everfur-mcp"],
      "env": {
        "EVERFUR_API_BASE_URL": "https://api-staging.everfur.com/api/v1",
        "EVERFUR_PARTNER_KEY": "pk_live_your_sandbox_tenant_key",
        "EVERFUR_DOCS_KEY": "dk_partner_your_docs_key",
        "EVERFUR_SESSION_TOKEN": "a_short_lived_session_your_backend_minted"
      }
    }
  }
}
  • EVERFUR_API_BASE_URL is the staging API while you build; your test tenant is a sandbox tenant on staging. Going live changes it to https://api.everfur.com/api/v1.
  • EVERFUR_PARTNER_KEY is the sandbox tenant's publishable key: a pk_live_ key, livemode: false.
  • EVERFUR_DOCS_KEY is the read-only docs key the server fetches the contract with.
  • EVERFUR_SESSION_TOKEN is a short-lived session your backend minted for a test user. Only everfur_verify_integration reads it, and sessions expire, so mint a fresh one before verifying.
  • The secret key (sk_partner_…) never appears in an MCP configuration. The server never reads it.

Going live changes every value in the block: the production base URL and the production tenant's own publishable key and docs key (the secret key on your backend changes at the same time).

Transport is stdio, so the server runs locally as a child process of your agent. Nothing is exposed to the network and there is no OAuth step. The package also ships everfur-mcp-http, a guarded Streamable HTTP entry point for a partner's own server, and everfur-mcp-hosted, the entry Everfur runs at its own MCP address (see the @everfur/mcp README). The hosted endpoint takes no configuration on your side: you add its URL to your editor and sign in with your partner console account. It is dark until Everfur turns it on and its address is published in the partner console.

Then ask your agent to add Everfur to your app. It has five tools.


The tools

everfur_plan_integration

Produces an integration plan for a specific project. Read-only: it changes nothing, but it does read the network and one credential. The contract it plans from (capabilities, error registry and decision trees) is served only to an authenticated partner: the CLI and the MCP server fetch it from GET /partners/contract on EVERFUR_API_BASE_URL, sending EVERFUR_DOCS_KEY in the x-everfur-docs-key header, validate its digest, and cache it (a current cached copy costs an empty 304). Without EVERFUR_DOCS_KEY the tool refuses with CONTRACT_CREDENTIAL_MISSING, unless a local projection is named with contract_path or EVERFUR_CONTRACT_PUBLIC. A key the API refuses is never answered from the cache.

It refuses underspecified input rather than guessing, and names exactly which facts are missing, why each one matters, and how to supply it. The framework and package manager are detected from your project, never asked. The facts that have to come from a human are:

  1. Which capabilities you are integrating, chosen from the contract's list (chat, records, televet, ...). A name the contract does not list is refused with UNKNOWN_OPTION; it is never free text.
  2. How your users sign in (the identity mode), one of three:
    • partner-user-ref: your backend mints a session for your own stable user id;
    • shopify-app-proxy: a Shopify storefront, where Shopify vouches for the signed-in customer and no partner backend mints;
    • anonymous: no end user is asserted, so nothing owned by a specific person is reachable.
  3. Which environment the generated configuration targets (staging or production).
  4. The language of the backend that mints sessions, asked only for partner-user-ref, because that is the one mode where the plan generates a session route for you.

That is three questions for an anonymous integration and four for partner-user-ref.

identified is not a value. personalized is accepted as an alias for partner-user-ref on both the CLI and the MCP server, because that is the word the MCP schema originally shipped.

Identity mode is the one that justifies the refusal. Every choice produces working code. Picking wrong produces an integration that works in development and is wrong in production, in a way no type error and no test will catch. An agent that guessed here would be confidently wrong, so the tool declines and tells your agent what question to ask you. Everfur does not care how you authenticate people (OAuth, a JWT, Firebase, Cognito, a session cookie, a magic link all work): for partner-user-ref it needs one stable, opaque id per person that is not guessable (no email addresses, no sequential ids, at most 128 characters, no @). Vet visits are the one surface where the person also signs in to an Everfur account, on everfur.com; chat and records never ask them to.

Everything else is either detected from your project (project type, package manager) or read from the fetched contract, so you do not need any Everfur-internal file to get a plan.

everfur_verify_integration

Exercises the real wire with the session in EVERFUR_SESSION_TOKEN (minted by your backend; the tool never reads the secret key and mints nothing itself): polls entitlements and hard-asserts the verdict is not empty, and with surfaces exercises chat, records, televet and theme. Returns PASS, SKIP, FAIL or GATED per step, each with the request_id of the call that produced it, so a failure is traceable rather than merely reported.

Not read-only: on a sandbox tenant the televet surface mints a real handoff. It sends no write to a live tenant.

everfur_diagnose

Separates the four byte-identical causes of an empty entitlement verdict.

This is the tool that saves the most time, because the failure it explains does not look like a failure. An empty verdict returns 200, the SDK correctly renders every gate off, and the integration appears to work while doing nothing. There is no error to search for. Checking it by hand means looking at tenant type, the capability catalog, the bundle, and the resolved scope path in four different places.

It reports honestly when the deployment does not serve the diagnostics route, rather than relaying a bare 404 as though it were an answer.

everfur_explain_error

Explains an error code: the cause, whether it is retryable, and the next action. It needs no project. The SDK's own codes, and the wire codes the SDK's copy of the contract table carries, are answered offline; any other code is looked up in the contract, so it needs EVERFUR_DOCS_KEY or a contract file. It refuses a code the registry does not list, with near misses, rather than explaining it from the shape of the string. Reach for it whenever a response carries an error code: three naming families are live at once and one code is the default for four different HTTP statuses. What it needs and returns, and the CLI twin everfur explain-error, are in 11-ERRORS.md.

everfur_send_test_message

Sends a real message and streams a real veterinary answer.

Sandbox tenants only. It reads the entitlement verdict live on every call, never cached, and refuses unless livemode is false. A stale "yes this is a sandbox" is precisely the answer that must never be reused. The consequence of getting this wrong is a throwaway test message landing in a tenant holding real veterinary records, which deleting it afterwards does not undo.

It returns the answer text and a frame histogram, because a stream that died mid-answer looks identical to a short answer if you only read the text. The histogram is what makes "it worked" checkable instead of felt. A response with zero delta frames is reported as a failure even when the HTTP status was 200.

The session id is derived from the idempotency key you supply, not generated, so a retried tool call lands in the same conversation instead of opening a second one.


Why the surface is this small

There is no generic "call any Everfur endpoint" tool, and that is deliberate.

A tool server is a confused-deputy risk by construction. A generic reader or writer would need an allowlist, and an allowlist is a thing you get wrong quietly. Instead the surface is deny by absence: each of the five tools does one bounded thing, and everything not listed is simply not reachable.

Credentials come from the environment rather than from tool arguments for the same reason. An argument is model-visible: it lands in the transcript, in any bug report pasted from it, and in whatever your harness logs. The environment is not.


The CLI

Same capabilities, for a terminal or CI.

everfur plan                Produce an integration plan for the project in the current directory.
everfur contract validate   Validate a contract-public projection: envelope shape, then digest.
everfur diagnose            Explain why each capability is or is not available to this caller.
everfur sandbox create      Provision a partner secret key and return a claim URL.
everfur verify              Exercise the wire: mint a session, read the verdict, assert it is not empty.
everfur explain-error       Explain an API error code: cause, retryability, and next action.

--json on any command emits the machine document on stdout instead of human text, which is what you want in CI.

everfur sandbox create returns a claim URL, not a secret, and never prints the secret. The CLI contains no randomness and no signing primitive anywhere in its source, enforced by a test that scans for them, on the reasoning that a client which can generate randomness can generate a key. It is the one command needing EVERFUR_ADMIN_TOKEN.

Credentials are read from environment variables only. No .env file is read, and no credential is ever printed, logged, or written into a --json document.

Not in this release: login, and sending a test message. Test messages are MCP only for now.

Everfur on the web

For a partner developer putting Everfur chat on a website. There are two ways to render it, from one codebase and one wire contract, and the first question is which page you are on:

You are on Use What it is
A page whose React tree you own (a Next.js, Remix or Vite app, a member portal) In-page: @everfur/sdk/web React components rendered into your DOM, like the React Native package.
A page you do not fully control (a storefront theme, a CMS page, a tag manager, anywhere third-party scripts run) Frame mode: the loader A 3 KB script that puts the surface in an iframe on sdk.everfur.com. Scripts on the page cannot read what your customer types, and after the handshake they cannot speak to the frame either.

Both speak the same API as the mobile SDKs (X-Everfur-SDK-Platform: web), render the same four states (loading / empty / error / populated) plus the off-state when a capability is disabled, and carry the same non-overridable clinical disclaimer (on chat, the one-line footnote under the composer). Chat, records and vet visits are the web surfaces (records and vet visits on their own subpaths, available when Everfur enables them for your account); photo and video remain React Native for now (their gates render the off-state on the web, never a blank). A vet visit opens IN YOUR PAGE by default, in a dialog the SDK renders: see 16-TELEVET-VISITS.md. Frame mode keeps the hosted visit, explicitly, because the frame bundle does not carry the visit screens.

Prev: 05-SDK-INTEGRATION.md (React Native and Flutter) · Next: 14-SHOPIFY.md (a Shopify storefront) · Errors: 11-ERRORS.md · Agent tooling: 12-CLI-AND-MCP.md


1. In-page: @everfur/sdk/web

Install

npm install @everfur/sdk react react-dom

react and react-dom (18 or newer) are peer dependencies. The ./web subpath is the only one that renders with react-dom; it never resolves in a React Native bundle (the package withholds the react-native export condition for it, and a resolver that ignores conditions hits a guard that throws at import time rather than bundling a browser layer into an app).

Wrap once, drop in the surface

import { EverfurChat, EverfurProvider, petRef } from '@everfur/sdk/web';

export function PetHelp(): React.ReactNode {
  return (
    <EverfurProvider
      config={{
        publishableKey: 'pk_live_…',                   // required; safe in the browser
        apiBaseUrl: 'https://api.everfur.com/api/v1',  // required
      }}
      activePet={petRef('your-stable-pet-id')}         // optional default pet scope
    >
      <div style={{ height: 600, maxWidth: 480 }}>
        <EverfurChat />
      </div>
    </EverfurProvider>
  );
}

Everything said about the provider in 05-SDK-INTEGRATION.md holds here: config is an EverfurConfig; a config that cannot be resolved mounts a disabled runtime and your children still render (every Everfur capability shows its off-state with disabled.level === 'sdk_config'); the imperative handle from useEverfur() has the same verbs (setUser, setActivePet, registerPet, logout).

Sizing. The surface fills the box it is given (height: 100%) and the transcript scrolls inside it. Give its parent a height. Without one the surface grows with the conversation and the page scrolls, which is rarely what a chat panel wants. style and className on EverfurChat are merged onto the surface's container (and onto the gate's off-state), so a host can hang its own layout rules on it.

What the browser adds. Enter sends, Shift+Enter breaks the line, an Enter that commits an IME composition is left to the input method. The transcript follows the conversation to its latest turn: it settles on the top of the newest reply with the question above it, so the follow-ups under a reply (at most three, drawn under the latest settled reply only) never push the answer out of view. Every turn the member starts (a typed send, a suggested prompt, Retry) takes them there, even from further up; otherwise a reader who has scrolled up to re-read keeps their place. Tapping a suggested prompt sends it and returns focus to the composer. A screen-reader user hears "Reading {pet}’s records…" while a turn is awaited and "Everfur answered." once, when a reply settles; the transcript itself is deliberately not a live region, because a streamed reply mutates its text many times a second. Errors are announced as alerts. Every control is at least 44 px and shows a visible focus indicator: while the keyboard drives, the surface draws a solid 2 px ring on the focused control as an inline style, so a CSS reset on your page that removes the browser's ring (:focus { outline: 0 }) does not leave a keyboard user without one; the composer and the history search draw that ring around their pills.

Server rendering. The surface is safe to render on the server (nothing touches window at import time), but under a strict style-src (no 'unsafe-inline') mount it client-only: server-rendered HTML carries its styles as style attributes, which such a policy blocks, and React's hydration does not re-apply them. next/dynamic with ssr: false, or Remix's ClientOnly, is the shape. A page without a strict style-src can render it anywhere.

What the surface carries, and its props

EverfurChat on the web carries what it carries on mobile (05-SDK-INTEGRATION.md), laid out as Everfur's own web chat lays it out rather than as the phone screen: the header with New chat, the history control and the pet switcher (two or more pets from GET /widget/v1/pets), the quick prompts under the empty thread's heading, streamed replies with their citation cards, the footer under every settled message (the time label, Copy, the three thumbs with the reasons sheet behind a thumbs-down, recorded through POST /widget/v1/messages/{message_id}/feedback), the urgency banner with its calm general tier, the saved-conversation history and image attachments. The layout follows the box you give the surface, never the browser window: in a box 800 px or wider the history is a brand-green rail flush right of the thread; narrower, it is a white drawer that opens inside the box. The thread and the composer keep to a centred 768 px column and a reply to a 72-character measure, and in a box 1024 px or wider the conversation's type steps up from 15/22 to 17/28, as it does on everfur.com. Two things differ from mobile, because the browser supplies what a native host has to pass in: there is no onCopy (the Copy control uses navigator.clipboard and is not drawn in a browser without it), and attachments need no picker (the browser's file input is the picker and the page posts the bytes itself).

Prop Type What it does
attachments false | { maxPerMessage?: number } Image attachments on the composer. On by default; attachments={false} removes the control; maxPerMessage caps the slots per message (at most 5, the server's cap).
dictation boolean Voice dictation on the composer through the browser's own speech engine (the Web Speech API; no Everfur service, nothing leaves the page but what the engine sends to its vendor). On by default, drawn only in a browser that has an engine; dictation={false} is the kill switch. In frame mode the loader delegates the microphone to the frame; a custom <iframe> needs allow="microphone".
onPetChange (petRef: PetRef) => void Called after the owner picks another pet in the header switcher (the provider's active pet has already been switched).
onVetPrep (petRef: PetRef | null) => void Where the empty state's Book a video visit chip goes. Absent, the chip appears only while Everfur's own vet visit entry is on for this user and opens it; without either it is not shown.
onFindVet (level: UrgencyDisplayLevel) => void The banner's vet action on an emergency (Nearest emergency vet) or schedule_soon (Find a vet) reply when Everfur's vet visit entry is not available to this user. Absent, no action in that case.
renderVetAction (level: UrgencyDisplayLevel) => ReactNode Your own node in the banner's action slot; wins over both.
vetPrefer 'in-app' | 'redirect' Where every vet entry in chat opens the visit. 'in-app' (the default): your registerEverfurTelevetFlow presenter, else the in-page visit the chat mounts itself. 'redirect' is the explicit opt-in to the hosted visit in a new tab.
onOpenUrl (url: string, kind: 'citation') => void | Promise<void> Your opener for the source page behind a citation card (always https). Absent (the default), a source card is read-only: its title, meta line and excerpt show, and nothing takes the member off your page. Given, the card becomes a real link and a click calls this instead of navigating, so you can show the page in your own viewer. To open a new tab as the Everfur web app does, pass (url) => { window.open(url, '_blank', 'noopener'); }. In frame mode a source card is always read-only: a function cannot cross into the frame.
headingLevel number The heading level of the surface's own title (the empty thread's What can we help {pet} with today?), 1 to 6. Default 2, so the widget never claims your page's <h1>; pass 1 when chat is the page.
import { EverfurChat, petRef } from '@everfur/sdk/web';

export function HelpPanel(): React.ReactNode {
  return (
    <div style={{ height: 600 }}>
      <EverfurChat
        petRef={petRef('your-stable-pet-id')}
        attachments={{ maxPerMessage: 3 }}
        dictation={false}
        onFindVet={(level) => { window.location.assign(`/find-a-vet?urgency=${level}`); }}
      />
    </div>
  );
}

Personalized mode: the same getToken contract

Anonymous mode needs no backend: chat works, scoped to this browser. For per-user history and entitlements your backend mints a session with mintPartnerSession from @everfur/sdk/server and the browser fetches the token from you. The session secret never reaches the browser.

import { EverfurChat, EverfurProvider, userRef } from '@everfur/sdk/web';

async function getToken(): Promise<string> {
  const res = await fetch('/api/everfur/session', { method: 'POST', credentials: 'same-origin' });
  if (!res.ok) throw new Error(`session mint failed: ${res.status}`);
  return res.text();
}

export function SignedInHelp(): React.ReactNode {
  return (
    <EverfurProvider
      config={{ publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.everfur.com/api/v1' }}
      user={{ userRef: userRef('your-stable-user-id'), getToken }}
    >
      <div style={{ height: 600 }}>
        <EverfurChat />
      </div>
    </EverfurProvider>
  );
}

/api/everfur/session is yours: a Next.js Route Handler, a Remix action, an Express route. It must be behind your own login (the SDK calls it with credentials: 'same-origin'), and it must never accept a user_ref from the request body; it derives the user from your session. The route returns the session token as text (or JSON; adapt getToken).

Theming and dark mode

The resolved theme is the same token set as on mobile: the compiled Everfur default, the server branding for your tenant merged over it, your theme prop merged on top FIELD BY FIELD (your value wins for every cosmetic field, except the fields your tenant locked in the console and the attribution, which stay with the server). The surface is light by default, as Everfur's own apps are, even on a dark-mode device: brightness: 'auto' in your theme prop or in your tenant branding opts in to the browser's prefers-color-scheme, and 'dark' pins the dark palette. Reduced motion follows prefers-reduced-motion (the typing dots and the spinner go static); prefers-contrast: more derives a high-contrast palette. The user's text-size setting is read from the root font size and applied to every size inline (capped by accessibility_max_font_scale, default 1.3). A named font_family in your branding is quoted ahead of the system UI font stack, so a font your page does not load still degrades to the platform font, never to the browser's serif. The fields, the paint slots and the dark-variant derivation are described in 05-SDK-INTEGRATION.md; the theme object is the same in frame mode (Everfur.init({ theme })), slots included.

Your branding (colours, logo, font, composer placeholder) is set on Everfur's side by default. When it names a logo, the chat shows it above the conversation (logo_url_dark on the dark palette). A font served BY Everfur from sdk.everfur.com is not available yet: the server's font allow-list is empty (partner_branding/font_allowlist.py), so every family is refused at publish until Everfur licenses a face for redistribution. Use the fonts prop below to name families your own page already loads. Whether "powered by everfur" is shown is part of your plan and is decided by the server: a hide_powered_by in your theme prop has no effect.

Fonts per face. fonts on the theme prop names one family per weight (regular, medium, bold, mono), the way the Everfur app maps weight to a registered face, and the SDK then sets that family with the weight the face declares, so the browser matches it exactly and synthesizes nothing. The SDK bundles no font: your page loads the files with its own @font-face and passes the names. font_urls may name a woff2 per face for the SDK to load itself (new FontFace(...) with display: swap), but only from the Everfur SDK CDN (https://sdk.everfur.com/fonts/…woff2, the allow-list BRAND_FONT_URL); a URL on any other origin is refused and the face is not loaded, because the CDN's /fonts/ path serves only what Everfur licenses for distribution. So: a licensed face of your own is @font-face plus fonts, never font_urls.

import { EverfurChat, EverfurProvider } from '@everfur/sdk/web';
import type { EverfurThemeInput } from '@everfur/sdk/web';

// Your stylesheet declares @font-face for "Azeret Sans" at 400, 500 and 700; the SDK is given the names only.
const theme: EverfurThemeInput = {
  fonts: { regular: 'Azeret Sans', medium: 'Azeret Sans', bold: 'Azeret Sans', mono: 'Azeret Mono' },
};

<EverfurProvider config={{ publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.everfur.com/api/v1' }} theme={theme}>
  <EverfurChat />
</EverfurProvider>;

Styling is applied inline, from the tokens, through the CSSOM (on a client render; see the server-rendering note above). The SDK ships no stylesheet, sets no class names of its own, and injects no <style> element, so a strict style-src on your page does not strip it, and your CSS cannot collide with it. Motion runs on the Web Animations API for the same reason.

Content Security Policy

For the page that hosts the in-page surface:

Directive Needs
connect-src your apiBaseUrl origin (https://api.everfur.com), for the JSON routes and the SSE stream; plus the media bucket's origin for chat attachments (https://everfur-media-prod-495688866294.s3.amazonaws.com, the exact origin POST /widget/v1/uploads/initiate presigns to, never an S3 wildcard), unless attachments={false}
style-src nothing extra for a client render: styles are set through the CSSOM, which style-src does not govern (server-rendered markup needs 'unsafe-inline', or a client-only mount)
img-src https://sdk.everfur.com, when your branding has a logo; blob: for attachment previews
font-src https://sdk.everfur.com, when your branding has a font

Storage

What the SDK persists (small caches, never a token) lives in localStorage under the everfur: prefix. The port never throws: a private window, a blocked storage policy or a quota error all degrade to "not stored", and a store that refuses writes still serves what it holds. Session tokens are held in memory only, on the web as on mobile, and so is the anonymous session id: a page reload starts a new anonymous conversation, and a signed-in page calls your getToken again. logout() sweeps only the SDK's own per-user keys, never a host's.

Testing

MockTransport from @everfur/sdk/testing works unchanged, and useEverfurTestEntitlements from @everfur/sdk/testing/web pushes a simulated server verdict into a live provider so a harness can watch the real gates react (the web twin of ./testing/rn; never a production path). The web suites in this repository render with @testing-library/react under jsdom, and examples/minimal-web.tsx is the copy-paste shape, type-checked on every build.

Records: @everfur/sdk/web/records

Available when Everfur enables records for your account. Until then the surface renders the gate's off-state (a visible card, never a blank) and makes no records request.

Records ship on their own subpath so a chat-only page downloads none of them. The same provider serves both entries; the surface needs a signed-in user (records refuse anonymous callers) and a pet you have registered first (every consent call for an unregistered pet is a 404). The flow, the consent rules and the wire are in 09-GUIDE-RECORDS-AND-CONSENT.md; what differs on the web is the upload seam, which is a browser File:

import { EverfurProvider, petRef, userRef, type EverfurConfig } from '@everfur/sdk/web';
import { EverfurRecordsPage, createWebRecordsUploadTransport } from '@everfur/sdk/web/records';

const config: EverfurConfig = {
  publishableKey: 'pk_live_…',
  apiBaseUrl: 'https://api.everfur.com/api/v1',
  uploadTransport: createWebRecordsUploadTransport(), // the presigned-POST upload; without it the upload control stays disabled
};

export function PetRecords(): React.ReactNode {
  return (
    <EverfurProvider config={config} user={{ userRef: userRef('customer-123'), getToken: mintSession }}>
      <EverfurRecordsPage petRef={petRef('pet-456')} petName="Rex" ownerName="Jane Doe" consentVersion="records-consent-v1" />
    </EverfurProvider>
  );
}

async function mintSession(): Promise<string> {
  return (await fetch('/api/everfur/session', { method: 'POST' })).text();
}
  • EverfurRecordsPage is the Everfur web app's records page, and it is complete on its own: Request records (clinic search, the authorization and a signature), Upload records I already have, every request with its status and its documents, and Visits & records (the full record, vaccinations included) and Timeline. Each opens inside your page: the clinic request, the upload and a request's detail in a right-side sheet over the page, the record and the timeline in place of the page body with a Back control. It never touches history or the URL, and nothing opens a new tab. Pass onNavigate only if you route those screens yourself; the page then hands you every edge instead. ownerName pre-fills the name the authorization is signed with (the member can correct it).
  • Nothing leaves your page by default. A share link is shared with the browser's share sheet (navigator.share), else copied to the clipboard; the record PDF and an uploaded document are saved in place (a download, never a tab), from links the API signs as attachments. Pass onOpenUrl(url, kind) to do it your way; kind is share_link, record_pdf or request_document.
  • Mounting the screens one by one in your own router: EverfurRecordsUpload (upload), EverfurClinicRequest or EverfurClinicRequestBatch (request from one or several clinics; or EverfurClinicPicker then EverfurRecordsConsent), EverfurRecordsRequests and EverfurRecordsRequest (request status, all or one), and EverfurRecordDetail (the full record with vaccinations). Each takes onNavigate and onBack.
  • EverfurRecords is the React Native-shaped flow (the mobile app's one-screen-at-a-time stack), kept for existing embeds; a web app wants the page. Its historyMode is memory (default) or browser. The host seams (petName, onOpenUrl, onCopy, onAsk, location) are in the records guide.
  • consentVersion is the version of the consent text your product shows; the surface records it when the user allows Everfur to hold the pet's records, and without it there is no Allow control.
  • The document is a PDF of at most 25 MiB. The type is read from the file's first bytes, not its name, so a renamed image is refused before any request is made. The bytes go straight from the browser to the presigned upload URL; no object URL is created, and nothing about the record is written to storage.
  • Building your own UI on the hook: useEverfurRecords is the same hook React Native uses, and fileToHandle(file) / pickDocument() turn a File into the handle it uploads.

examples/minimal-web-records.tsx is the copy-paste shape, type-checked on every build.

Content Security Policy for a page that runs records in-page. Three things leave the page for a bucket rather than the API, so each bucket's exact origin goes in your policy beside the API origin (production values shown; staging uses the staging buckets, and the URLs the API returns are authoritative):

Directive Needs, for records
connect-src the media bucket, https://everfur-media-prod-495688866294.s3.amazonaws.com: the document upload is a multipart POST to it. The consent-signature bucket, https://everfur-records-signatures-prod.s3.amazonaws.com: Request records POSTs the drawn signature to it
img-src the consent-signature bucket: the saved signatures offered for reuse are previews from it
frame-src the media bucket, only if your page is itself inside an iframe: there the record PDF and an uploaded document are saved through a hidden frame (a framed page's own navigation answers to its parent's frame-src); a top-level page saves them with no frame-src entry

A page that cannot widen its policy uses frame mode below, where the records frame document carries all of this itself.


2. Frame mode: the loader and the frame

For a page you do not fully own. A 3 KB script (https://sdk.everfur.com/v1/everfur.js) appends an <iframe> to a container you name; the surface renders inside it, on sdk.everfur.com. Configuration and session tokens cross the boundary over postMessage: the loader answers the frame's first message with a private MessageChannel, and everything after that travels on that channel. Your page's other scripts share its origin and window but not the channel, so they can neither read the conversation nor send the frame a command or answer a token request; the frame's script cannot read your page.

The boundary this does NOT cross: a script that runs on your page BEFORE the loader can initialise the frame first. Scripts that run before the loader are inside your page's trust boundary, as they are for any script you load. And the fence below checks the page that embeds the frame directly; a page that frames YOUR page is your own frame-ancestors policy's business.

Frame mode needs the Everfur API's embed-config route (the fence below), which ships with the same release as this SDK version.

Staging has its own host: https://sdk-staging.everfur.com/v1/everfur.js is the same loader built for staging, and it opens a frame that talks to the staging API. Use it with a staging publishable key on a page whose origin is on that tenant's allowlist; the production loader never talks to staging and the staging loader never talks to production, whatever the page says.

The snippet

The copy-paste sources are examples/frame-embed.html (data attributes) and examples/frame-embed-api.html (the JavaScript API) in this repository; a page uses one or the other. The shortest form, anonymous chat with no code:

<div id="everfur-chat" style="height: 560px; max-width: 480px"></div>
<script
  src="https://sdk.everfur.com/v1/everfur.js"
  data-publishable-key="pk_live_…"
  data-container="#everfur-chat"
  async
></script>

The JavaScript API, for a signed-in customer (the stub first, then the script tag):

<script>
  window.Everfur = window.Everfur || function () { (window.Everfur.q = window.Everfur.q || []).push(arguments); };
  Everfur('init', {
    publishableKey: 'pk_live_…',
    container: '#everfur-chat',
    user: {
      userRef: 'customer-123',
      getToken: async () => (await fetch('/api/everfur/session', { method: 'POST' })).text(),
    },
    activePet: 'pet-456',
    trigger: '#ask',            // where keyboard focus returns when the reader presses Escape in the composer
    onReady: () => {},
    onError: (error) => console.warn('everfur', error.code, error.requestId),
  });
</script>
<script src="https://sdk.everfur.com/v1/everfur.js" async></script>

Records in frame mode is the same snippet with surface: 'records' (available when Everfur enables records for your account; the frame shows the sign-in state without a user and the off-state until then):

<div id="everfur-records" style="height: 720px"></div>
<script>
  window.Everfur = window.Everfur || function () { (window.Everfur.q = window.Everfur.q || []).push(arguments); };
  Everfur('init', {
    publishableKey: 'pk_live_…',
    container: '#everfur-records',
    surface: 'records',
    user: { userRef: 'customer-123', getToken: async () => (await fetch('/api/everfur/session', { method: 'POST' })).text() },
    activePet: 'pet-456',                  // required for records: the pet whose records the frame shows (registered first)
    consentVersion: 'records-consent-v1',  // the consent text version your product shows; without it there is no Allow control
  });
</script>
<script src="https://sdk.everfur.com/v1/everfur.js" async></script>

The loader opens records.html beside frame.html on the CDN, of its own build. The frame serves the same records page a component partner mounts (EverfurRecordsPage: request records from a clinic, upload, every request with its status, the full record with vaccinations), scrolling inside the frame's box, so give the container the height you want it to take. The records document's own policy allows the upload host and the consent-signature bucket beside the API (the reasons records has a document of its own), so a page in frame mode needs no connect-src for either; the chat document keeps the narrower policy.

The stub on the first line is the usual pre-load queue: calls made before the script arrives are replayed in order once the page has parsed. A queued call returns no handle; after the script has loaded, Everfur.init({...}) returns one, with setUser(user | null), setActivePet(petRef | null), focus() and destroy(). A host configuration mistake (a container that does not exist, an empty key) is reported on the console as [everfur] ... and never thrown into your page.

Option Data attribute Meaning
publishableKey data-publishable-key required
container data-container an element or a selector; the frame fills its width
surface data-surface chat (default) or records; each opens its own frame document. Records need a user (code) and an activePet
activePet data-pet-ref default pet scope (chat); the pet whose records the frame shows (records)
consentVersion data-consent-version records only: the consent text version recorded when the user allows Everfur to hold the pet's records
height data-height the frame's height (a number of px, or CSS). Omitted: the container's height if it has one when the loader runs, else 560 px. The frame is sized ONCE, at init: a container that is hidden or unsized at that moment keeps the default, so give it its height first or pass height
frameUrl data-frame-url override for a frame you host yourself during development; https only (http on localhost), and the document for the surface (frame.html for chat, records.html for records). Staging needs no override: https://sdk-staging.everfur.com/v1/everfur.js opens the staging frames by itself
trigger data-trigger the element that receives focus on Escape
title data-title the iframe's accessible name (default Everfur)
user, theme, onReady, onError (code only) a user is never configured by attribute: a token has no place in the DOM

The fence: registered embed origins

The frame refuses to initialise unless the page origin that embeds it is one your tenant registered (embed_allowed_origins, set during onboarding: exact origins such as https://shop.example.com; no wildcards, so register each storefront domain). The frame asks the Everfur API about the exact pair (your publishable key, the origin it observed) and renders only on a yes; the list itself never leaves the API, and a request that fails or times out fails closed. On any refusal nothing renders, the loader takes the frame out of the page's layout so your page shows no blank box, and onError receives a code you can act on:

error.code Meaning What to do
originNotAllowed this page's origin is not registered for the key register the origin (or check you are on the right key)
embedConfigUnavailable the API could not be reached from the frame usually a connect-src or network problem on the frame side; retry later
frameUnavailable the frame did not report ready within 20 s (blocked script, offline, wrong frameUrl) check the page's frame-src and script-src; on a slow network onReady can still follow, and the frame is shown again
protocolVersion the loader and the frame are not the same build, or another script initialised the frame first the page is loading a stale or mismatched loader
surfaceMismatch the frame document is not the one for the surface asked (a frameUrl override points at the other document) point frameUrl at records.html for records, frame.html for chat, or drop the override
tokenTimeout your getToken did not answer within 15 s the mint route is slow or failing
any registry code the surface inside the frame settled an error (for example rateLimited) see 11-ERRORS.md; requestId is set when the API returned one

Tokens

The frame never holds a partner secret. When the SDK inside it needs a session token for the current user, it asks the loader, and the loader calls your getToken. A token you hand over with the user (sessionToken) is used once, then refreshes go through getToken; a user with a sessionToken and no getToken therefore works until that token expires and then fails with tokenUnavailable in onError, so supply getToken for anything longer than one token's life. Nothing is persisted by the loader; the frame keeps the session in memory, on its own origin.

Content Security Policy for the host page

Directive Needs
script-src https://sdk.everfur.com
frame-src https://sdk.everfur.com
connect-src your own mint route, if getToken calls one

The frame documents carry their own Content Security Policy (scripts, styles, images and fonts from their origin only; connect-src names the API plus the one media-bucket origin the API presigns uploads to, the exact virtual-hosted bucket origin and never an S3 wildcard, for the chat document's image attachments and the records document's owner upload). The records document names two more exact bucket origins and nothing wider: the consent-signature bucket in connect-src and img-src (the authorization's drawn signature, and the saved signatures offered for reuse), and the media bucket in frame-src (the in-place save below). Your page's style-src, connect-src, img-src and font-src do not apply inside them, and your frame-src stays https://sdk.everfur.com. The loader delegates three permissions to the frame, allow="microphone; clipboard-write; web-share": the microphone for the composer's dictation, and the clipboard and the share sheet so a records share link is copied or shared inside the frame. The frame's sandbox is allow-scripts allow-same-origin allow-downloads: downloads so the record PDF and an uploaded document are saved in place, and never allow-popups, so nothing the frame does opens a new tab. The save runs in a hidden frame of the media bucket inside the records document, never by navigating the records frame itself: your page's frame-src polices the records frame's own navigations, and it names only https://sdk.everfur.com.

The messages

You do not need these to integrate; they are documented so a security review can see the boundary. Every message is { type: 'ef:<name>', v: 1, ... }, parsed strictly on both sides, and a message that does not parse is dropped whole.

Direction Channel Message Carries
frame -> page window ef:ready the frame's build version; posted to * on load (the page origin is not yet known)
page -> frame window, with a MessagePort ef:init publishableKey, user (userRef, optional sessionToken), activePet, theme, surface (chat or records; absent means chat), consentVersion (records)
frame -> page port ef:initialized nothing; the init passed the fence and the surface is listening, so queued commands are applied from now on
page -> frame port ef:setUser, ef:setActivePet, ef:focus the corresponding command
frame -> page port ef:tokenRequest / page -> frame ef:token id, then token or error
frame -> page port (window before the handshake) ef:error code, requestId; never a message string, never an origin
frame -> page port ef:focusTrigger nothing; Escape in the composer

The loader accepts the frame's ef:ready only from the frame window it created, at https://sdk.everfur.com, and only from its own build (both are published together). The frame accepts ef:init only from the window that embeds it, only with a port, and only for an origin the API allows for the key, and only for the surface the document serves (the other one is surfaceMismatch, refused before the fence is asked); after that it reads nothing from the window. There is no resize message: the frame fills the box the page gives it, and a bounded transcript has no natural height.


3. Which is which, at a glance

In-page (@everfur/sdk/web) Frame mode
You need React 18+, a bundler a <script> tag
Isolation from other scripts on the page none: your page's scripts see the DOM a cross-origin iframe and a private channel; scripts that run before the loader are inside the boundary
Styling inline from the theme, in your document the same, inside the frame
Tokens your getToken on the provider your getToken on Everfur.init
Origin fence your CSP registered embed origins, checked by the frame
Best for apps and portals you own storefronts, CMS pages, tag managers

4. What is not on the web yet

  • Photo checkup, gait video. React Native only. On the web their gates render the off-state.
  • Records for every account. The surface ships (in-page and frame mode); Everfur enables it per account, and until then it renders the off-state. The clinic picker and the clinic-release consent inside the flow need the separate clinic-request switch as well; without it records arrive by the owner's own upload.
  • A Shopify app. The app-proxy session bridge (POST /widget/v1/identity/shopify/session: a customer signed in to a storefront gets a personalized session without the merchant running a backend) is built on the API, and the planner's shopify-app-proxy identity mode plans for it; the theme app extension lives in the Everfur Shopify app, and Everfur binds each store to the tenant before it is enabled (14-SHOPIFY.md). A storefront without that binding uses frame mode in anonymous mode, or mints sessions from a backend the merchant runs.

5. Bundle size per entry point

What each web entry weighs, in gzip bytes. Measured is @everfur/sdk 0.5.4, weighed the way the SDK's own gate weighs it (tests/supply-chain/webBundleSize.test.ts): the minified file, gzip level 9, under Node 20 (the version CI runs). The in-page entries are the published package's files, with React and react-dom external: your page provides them. The three frame-mode files are npm run build:cdn at the v0.5.4 tag, with React bundled into the two frame apps. Budget is the line that gate enforces on every change to the SDK: a file that grows past it fails CI, so a larger entry is a deliberate, reviewed change rather than a surprise on every page view.

Entry What it contains When it loads Measured, 0.5.4 (gzip bytes) Budget (gzip bytes)
@everfur/sdk/web EverfurProvider, CapabilityGate, EverfurChat (the consumer app's chat screen), useEverfurChat with your page's bundle 87,209 95,900
@everfur/sdk/web/records EverfurRecordsPage, EverfurRecords, the named records screens, useEverfurRecords with your page's bundle, when you import it 90,791 99,500
@everfur/sdk/web/records/depth record depth: the timeline, the withheld record sections, per-document contributions with your page's bundle, when you import it 25,262 26,700
@everfur/sdk/web/televet VetVisitButton, useVetVisit, EverfurTelevetHost, the .ics calendar writer; none of the visit screens with your page's bundle, when you import it 15,527 16,200
@everfur/sdk/web/televet/booking the in-page visit's screens: the booking funnel, the member's visits with join, reschedule and cancel, the vet thread and the visit summary lazily, as its own chunk, on the first press of a vet entry 70,168 73,600
@everfur/sdk/web/televet/call EverfurVetCall, the call in your page; @daily-co/daily-js is yours and not included lazily, as its own chunk, when the member joins a visit 7,523 8,400
@everfur/sdk/web/notifications useEverfurNotifications: the member's notification preferences (no web push) with your page's bundle, when you import it 10,877 11,100
@everfur/sdk/web/consent EverfurConsentToggle, useEverfurConsent with your page's bundle, when you import it 12,527 12,800
everfur.js frame mode: the loader your page includes; no React on your page, from its <script> tag 2,960 4,000
frame.js frame mode: the chat frame app, React bundled inside the chat iframe on sdk.everfur.com, not in your page's bundle 147,534 156,600
records-frame.js frame mode: the records frame app, React bundled inside the records iframe on sdk.everfur.com, not in your page's bundle 173,145 188,100
  • Lazy means downloaded later. EverfurChat, VetVisitButton and useVetVisit reach @everfur/sdk/web/televet/booking through a dynamic import(), and the booking screens reach @everfur/sdk/web/televet/call the same way. The SDK keeps those specifiers as real import() calls, so your bundler splits each into its own chunk: a page where nobody opens a visit never downloads the booking screens, and a member who never joins never downloads the call (16-TELEVET-VISITS.md).
  • Figures add up; they do not overlap. Each entry is built on its own, with no chunk shared between entries, so each figure carries its own copy of the SDK's shared client code.
  • React Native entries and their figures are in 05-SDK-INTEGRATION.md.

Everfur on a Shopify storefront

For a merchant's developer, agency or theme partner putting Everfur chat on a Shopify store, and for the Everfur operator who turns it on. Nothing here is written by the merchant: the surface is a theme app block of the Everfur Shopify app, the identity is Shopify's own, and the whole path is frame mode (13-WEB-INTEGRATION.md) with Shopify vouching for the signed-in customer.

Prev: 13-WEB-INTEGRATION.md · Next: 15-EVENTS-AND-WEBHOOKS.md · Errors: 11-ERRORS.md · Agent tooling: 12-CLI-AND-MCP.md


1. What the merchant sees

  1. Install the Everfur app on the store.
  2. Theme editor: add the Everfur chat block to any section, paste the tenant's publishable key, set the height. The block renders the chat in an iframe on sdk.everfur.com; the staging app configuration renders it from sdk-staging.everfur.com against the staging API, and the two can never be crossed. There is no pk_test_ key: a sandbox tenant on staging is issued an ordinary pk_live_ key and reports livemode: false.
  3. Signed out, the block shows anonymous chat. Signed in (classic or new customer accounts, on the online store), it shows the customer's own conversation: the customer's identity is shopify:<shop>:<customer id>, minted by the Everfur API from a request Shopify signed, and no token, secret or customer data is in the theme.

The store's origin (https://<the store's domain>) must be on the tenant's embed allowlist, exactly (scheme and host; no wildcards), or the block shows the waiting card and nothing else. That allowlist is set by Everfur for the tenant.

2. How the identity works

sequenceDiagram
    participant Page as Storefront page (the block)
    participant Shopify
    participant API as Everfur API
    participant Frame as Frame (sdk.everfur.com)

    Page->>Page: loader mounts the frame#59; the frame asks the page for a token
    Page->>Shopify: POST /apps/everfur/session (same origin, no cookie forwarded)
    Shopify->>API: POST /api/v1/widget/v1/identity/shopify/session?shop=…&logged_in_customer_id=…&timestamp=…&signature=…
    API->>API: binding for shop → app secret by name → HMAC (constant time) → freshness (60 s) → single-use nonce → throttles
    API-->>Shopify: 201 {session_token, expires_at, capabilities}
    Shopify-->>Page: the same body
    Page->>Frame: the token, once, over the private channel
  • The block's script (assets/everfur-chat.js in the app's theme extension) is the getToken: it POSTs to <proxy path>/session on the store's own origin and returns session_token.
  • Shopify appends shop, logged_in_customer_id, path_prefix, timestamp and signature (an HMAC-SHA256 over the sorted query, keyed with the app's client secret) and strips cookies.
  • The API resolves the shop's binding to a tenant and a key, loads the app secret by name from its own namespace, verifies the signature, requires a signed-in customer, enforces freshness and single use, throttles per shop and per tenant, then mints exactly the token the sk+pk mint would have. Every refusal is one opaque 401 authRejected; 429 rateLimited carries Retry-After; 503 only for a fault on Everfur's side.
  • There is no partner backend on this path. The identity mode is shopify-app-proxy in the contract and in everfur plan --identity shopify-app-proxy; the session-mint question is not asked.

4. Verifying from the outside

export EVERFUR_PARTNER_KEY=pk_live_…          # the tenant's publishable key
everfur verify --origin https://the-store.example.com

The four web checks after the wire exercise: the embed fence for that origin (a 403 names the allowlist), the CORS exposure of X-Request-ID and Retry-After, the tenant's session policy, and the loader of the key's environment. Then, on the storefront, sign in and send one message; the frame's error codes surface in the browser console as [everfur] <code> <request id> and are explained by everfur explain-error <code>.

5. What this deliberately does not do

  • No token in Liquid, no secret in the browser, no cookie on the proxy path.
  • No silent downgrade: a signed-in customer whose mint fails sees the frame's error state, never anonymous chat, so a disabled binding is visible where it is broken.
  • No ScriptTag: the block is a theme app extension, which Shopify's ScriptTag deprecation does not touch.
  • No customer identity in Everfur's commerce boundary: the proxy's destination is the Everfur API.

Events and webhooks

Everfur sends signed events to your server when something happens, and accepts events from your server about your members' pets.

For a partner developer writing the backend half of an integration. Everything here runs on your server and uses @everfur/sdk/server/events, a subpath a React Native bundle can never resolve. It needs Node 18 or later, because it uses Node's crypto. @everfur/sdk/server (the session minter) does not, and still runs on edge runtimes.

Status: preview. Webhook endpoints, delivery and the inbound events API are built behind switches that are off by default, and are turned on per tenant by Everfur. Until your tenant is enabled, no event is sent and sendPartnerEvent settles to a 404. The signature scheme and constructEvent are pinned to the platform signing vectors: the same fixed vectors the platform's signing code is tested against, on a platform branch that is not released yet. Delivery behaviour, event payload fields and the inbound API can still change before they are enabled; sections marked Preview describe the design, not a running service.

Prev: 14-SHOPIFY.md · Records: 09-GUIDE-RECORDS-AND-CONSENT.md · Errors: 11-ERRORS.md


The two directions

Direction What moves How you use it
Everfur to you An event such as record.ready or visit.booked, POSTed to an https endpoint you register Verify it with constructEvent, answer 2xx, act on it
You to Everfur An event such as pet.incident.reported, about a member and pet you already send to Everfur sendPartnerEvent with your secret key

1. Receiving webhooks

The workflow

  1. Create an endpoint. In the partner console's Webhooks tab, or POST /api/v1/partners/webhook-endpoints with {url, description?, enabled_events: [...]}. The URL must be public https on port 443 (no IP addresses, no private ranges, no redirects). A tenant can have up to five endpoints. Subscribe to named types, or to * for every type your plan allows.
  2. Store the signing secret. The create response carries signing_secret (whsec_...) once. It is never shown again; the console shows only its last four characters. Keep it in your secret store, never in source control.
  3. Verify every delivery with constructEvent, against the raw request body.
  4. Deduplicate on event.id. A delivery can arrive more than once (a retry after your answer was lost, or a manual resend, which reuses the same id on purpose).
  5. Answer 2xx quickly, then do the work. Everfur waits 10 seconds (preview: the delivery worker is not built yet); anything else (a non-2xx, a timeout, a TLS error, a redirect) is a failed attempt.
  6. Press Send test on the endpoint to receive a webhook_endpoint.test event and prove steps 3 to 5.

Verify a delivery

import { EverfurWebhookVerificationError, constructEvent, isEverfurWebhookEvent } from '@everfur/sdk/server/events';

// Both secrets while a rotation's grace window is open; only the current one otherwise.
const secrets = [process.env.EVERFUR_WEBHOOK_SECRET, process.env.EVERFUR_WEBHOOK_SECRET_PREVIOUS].filter(
  (s): s is string => typeof s === 'string' && s.length > 0,
);

// Use your database in production; a Set forgets on restart.
const processed = new Set<string>();

// A Next.js route handler. Any framework works if it can give you the unparsed body.
async function POST(request: Request): Promise<Response> {
  const payload = await request.text();
  let event;
  try {
    event = constructEvent(payload, request.headers.get('everfur-signature'), secrets);
  } catch (e) {
    if (e instanceof EverfurWebhookVerificationError) return new Response(null, { status: 400 });
    throw e;
  }

  if (processed.has(event.id)) return new Response(null, { status: 200 });
  processed.add(event.id);

  // A type added after this SDK version shipped: acknowledge it so it is not retried.
  if (!isEverfurWebhookEvent(event)) return new Response(null, { status: 200 });

  switch (event.type) {
    case 'record.ready':
      console.log('record ready for pet', event.data.object.pet_ref, 'request', event.data.object.request_id);
      break;
    case 'visit.booked':
      console.log('visit booked', event.data.object.visit_ref, 'for', event.data.object.scheduled_at);
      break;
    case 'record_request.updated':
      console.log('request', event.data.object.request_id, 'is', event.data.object.simple_status);
      break;
    default:
      break;
  }
  return new Response(null, { status: 200 });
}

Use the raw body. The signature covers the exact bytes Everfur sent. Parsing the JSON and serialising it again changes whitespace and key order, and the signature no longer matches. In Express, mount the webhook route with express.raw({ type: 'application/json' }) and pass req.body (a Buffer); in a Fetch-style handler, pass await request.text(). constructEvent refuses a parsed object with an EverfurConfigError.

What constructEvent throws. An EverfurWebhookVerificationError means refuse the delivery (answer 400); its code says why. An EverfurConfigError means the call itself is wrong (no secret, a parsed body, a negative tolerance) and no delivery could ever pass it.

code Meaning Usual cause
malformed_header The Everfur-Signature header is missing, over 4096 characters, or not t=...,v1=... The route is not receiving Everfur's request, or a proxy dropped the header
timestamp_outside_tolerance The signed time is more than 300 seconds from your clock, either way A replayed delivery, or a server clock that is far off (check NTP)
no_matching_signature No v1 value matches any secret you passed The wrong secret, a rotated secret you have not added, or a body that was re-serialised
malformed_payload The signature is valid but the body is not an event envelope Should not happen with Everfur's deliveries; report it with the Everfur-Delivery-Id header

The tolerance is options.toleranceSeconds (default 300). Do not raise it to paper over clock drift.

The delivery

Preview. The delivery worker is designed and not built yet. The headers, User-Agent and envelope below are the design and can change before deliveries are enabled; Everfur-Signature is the part pinned by the signing vectors.

Each delivery is a POST with content-type: application/json and these headers:

Header Value
Everfur-Signature t=<unix seconds>,v1=<hex>; during a rotation, one v1 per live secret
Everfur-Event-Id The event id, the same as id in the body
Everfur-Delivery-Id This delivery's id (one per event per endpoint). Quote it to Everfur support
User-Agent Everfur-Webhooks/1.0

The body is the event envelope:

{
  "id": "evt_00000000000000000000000000000001",
  "object": "event",
  "type": "record.ready",
  "api_version": "2026-09-15",
  "created": 1758000000,
  "livemode": true,
  "data": { "object": { "request_id": "…", "pet_ref": "…" } }
}

created is when the event happened, not when this attempt was sent. livemode is false on a sandbox (test) tenant. api_version is fixed per endpoint when it is created.

The signature, for other languages

constructEvent is the reference. If your backend is not Node, implement exactly this:

  1. Split the header on ,. Trim each item and split it at the first =. Take the single t value (digits only; two t items is malformed) and every v1 value. Ignore any other key, such as v0: a future scheme is added under a new key.
  2. Refuse the delivery if |now - t| is more than 300 seconds.
  3. For each secret, compute lowercase hex HMAC-SHA256(key = the whole secret string including "whsec_", message = t + "." + raw body bytes).
  4. Accept if any computed value equals any v1 value, compared in constant time. Uppercase hex does not match.

Rotating a secret

POST /api/v1/partners/webhook-endpoints/{endpoint_id}/rotate-secret with {"expire_current_in_hours": 24} (0 to 72, default 24) returns the new signing_secret once. Until the old secret expires, every delivery carries two v1 values, one per secret, so a receiver holding either one verifies. Deploy the new secret alongside the old one (constructEvent accepts a list), then remove the old one after the window closes. 0 expires the old secret immediately, for a leaked secret.

Retries, failures and resend

Preview. Retries, auto-disable, the delivery log, Resend and the events list route below are designed and not built yet. The schedule, limits and route can change before deliveries are enabled.

A failed attempt is retried after roughly 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, then every 10 hours, 12 attempts in all over about three days. After the last attempt the delivery is marked dead. An endpoint that has failed continuously for 72 hours is disabled; re-enable it in the console once it answers again. The console's delivery log shows every attempt (status code, duration, error) and a Resend action, which sends the same event, with the same id, again.

If your endpoint was down, GET /api/v1/partners/events?type=…&created_gte=… with your secret key lists the last 30 days of events, newest first, so you can reconcile: {object: "list", data: [...], has_more}, pages of limit (1 to 100, default 20), the next page with starting_after=<the last evt_ id>. One event is GET /api/v1/partners/events/{id}. A restricted key needs partner_events.event.read. These reads follow the webhooks switch: until it is on for your tenant they answer 404.

The catalog

Every data.object carries an object tag naming its shape, then the fields below. Fields are the ones the platform producers write (emit.REQUIRED_DATA_FIELDS and each producer module); each payload stays open to more, so a field added later does not break your build.

Type Sent when data.object Needs
member.linked A member confirms the link between your account and their Everfur account object: "member", user_ref Televet
member.unlinked That link is removed object: "member", user_ref Televet
visit.booked A visit that started from your integration is booked object: "visit", visit_ref, user_ref, pet_ref (or null), status: "booked", scheduled_at, occurred_at Televet, and the member's opt-in
visit.cancelled That visit is cancelled the visit fields, status: "cancelled" Televet, and the member's opt-in
visit.completed That visit is completed the visit fields, status: "completed" Televet, and the member's opt-in
visit.no_show The member did not attend that visit, or nobody did the visit fields, status: "no_show" Televet, and the member's opt-in
visit.rescheduled The member moved that visit the visit fields with the NEW scheduled_at, status: "booked", previous_scheduled_at Televet, and the member's opt-in
visit.reminder The member's reminder for that visit was released the visit fields, status: "booked", stage: "24h" | "30m" Televet, and the member's opt-in
visit.followup_sent The post-visit check-in was delivered to the member the visit fields, status: "booked" | "completed", sent_at Televet, and the member's opt-in
visit.message_received The vet wrote in that visit's thread. One per message; never the message, a preview or the vet's name the visit fields, message_at Televet, and the member's opt-in
visit.ended That visit's call ended, before the note is signed. status is still booked until visit.completed arrives the visit fields, ended_at Televet, and the member's opt-in
record_request.updated A records request you created reaches a new simple state, or a reviewer amends a published record object: "record_request", request_id, user_ref, pet_ref, simple_status, simple_status_detail (or null), action_needed_label (or null), can_retry_send, can_update_clinic_email, can_revoke, can_convert_to_upload, record_updated_at (or null) Records
record.ready A requested record can be read through the records API object: "record", request_id, user_ref, pet_ref Records
pet.vaccination.due A pet's rabies or DHPP due date reaches 14 days out, 3 days out, or is overdue object: "pet_vaccination", user_ref, pet_ref, vaccine: "rabies" | "dhpp", due_at (YYYY-MM-DD), stage: "due_in_14d" | "due_in_3d" | "overdue" Records, and the pet's records consent
chat.follow_up_due A follow-up check-in for a watched case is due object: "chat_follow_up", user_ref, pet_ref, case_ref, conversation_id (or null), due_at Chat
chat.urgency.flagged An assistant reply carried an emergency urgency verdict object: "chat_urgency", user_ref, conversation_id, message_id, urgency_level Chat
chat.reminder_due A medication reminder, or one gentle check-in on an unresolved concern, is waiting in that pet's chat. One per pet, kind and due day; never the medicine, the symptom or the sentence object: "chat_reminder", user_ref, pet_ref, conversation_id (or null), kind: "medication_due" | "open_concern", due_at Chat
record.activation A requested record was read and found something. Counts only; never a value, a drug or a diagnosis object: "record_activation", request_id, user_ref, pet_ref, weights, vaccines, medications, lab_results, diagnoses Records
webhook_endpoint.test You press Send test object: "webhook_endpoint", endpoint_id nothing

Events carry opaque ids only: never an Everfur account id, an email address, a clinician, a diagnosis or record content. Visit events are sent only for visits that started from your integration, and only when the member ticked the option to share visit status with you; they are never sent for other visits the same person books with Everfur. visit_ref is the same on every event for one visit; a follow-up visit gets its own. record.ready tells you to read the record through the records API (09-GUIDE-RECORDS-AND-CONSENT.md); it does not contain it. record_request.updated carries simple_status (the seven-value records status model: requested, awaiting_clinic, received, parsing, published, failed, revoked), never the raw request status, and action_needed_label is the consumer app's own sentence for a state that needs the member, rendered verbatim. The chat events, the vaccination reminder and the visit notifications are the same moments the consumer app notifies its users about. Everfur sends those notifications to your members itself, by push through your own provider and by email, once notifications are enabled for your tenant (17-NOTIFICATIONS.md); the webhook is the server-side copy for your own handling, and a receiver that also pushes from its handler will notify the member twice.

The TypeScript types (EverfurWebhookEvent, EverfurWebhookPayloadMap) name these fields and leave each payload open to more, so a field added later does not break your build.

Testing your handler

generateTestSignatureHeader signs a body exactly as Everfur does, for a unit test with a test secret:

import { constructEvent, generateTestSignatureHeader } from '@everfur/sdk/server/events';

const secret = 'whsec_test_only';
const body = JSON.stringify({
  id: 'evt_test_1',
  object: 'event',
  type: 'webhook_endpoint.test',
  api_version: '2026-09-15',
  created: Math.floor(Date.now() / 1000),
  livemode: false,
  data: { object: { object: 'webhook_endpoint', endpoint_id: 'whe_test_1' } },
});
const header = generateTestSignatureHeader({ payload: body, secrets: secret });
const event = constructEvent(body, header, secret);
console.log(event.type); // webhook_endpoint.test

2. Sending events to Everfur

The route is served and switched off by default: Everfur turns it on per tenant. Until your tenant is enabled, every call settles to a 404.

Your backend tells Everfur about things that happened to a member's pet in your service. Everfur keeps them as partner-reported facts, keyed on your own user_ref and pet_ref: they never create or match an Everfur account, and they never carry an owner's email, phone or name.

import { sendPartnerEvent } from '@everfur/sdk/server/events';

const everfur = {
  apiBaseUrl: 'https://api.everfur.com/api/v1',
  partnerSecretKey: process.env.EVERFUR_SECRET_KEY ?? '',
};

const result = await sendPartnerEvent(
  everfur,
  {
    type: 'pet.incident.reported',
    userRef: 'member-123',
    petRef: 'pet-456',
    occurredAt: new Date(),
    data: {
      incident_id: 'inc-1',
      category: 'injury',
      severity: 'low',
      location_type: 'daycare',
      description: 'Small scrape on left paw',
    },
  },
  'incident-inc-1', // your idempotency key: reuse it on every retry of this event
);

if (result.ok) {
  console.log(result.value.id, result.value.status); // ievt_..., applied
} else {
  console.error(result.error.code, result.error.requestId);
}

The request

sendPartnerEvent sends POST {apiBaseUrl}/partners/events, which is POST /api/v1/partners/events.

Part Rule
x-everfur-partner-secret-key Your sk_partner_ secret key, or an rk_partner_ restricted key with partner_events.inbound.create. Never a publishable key, and never from a browser or an app
Idempotency-Key Required. 1 to 255 visible ASCII characters: no spaces, no control or non-ASCII characters
Body {type, schema_version, user_ref, pet_ref, occurred_at, data} and no other field, at most 32 KB
type One of the seven types below
schema_version 1 for every type (the SDK sends it)
user_ref, pet_ref Your opaque ids: 1 to 128 printable characters, no @, no surrounding spaces. A pet.* event needs pet_ref; a member.* event must not have one
occurred_at When it happened, with an explicit offset. No older than 30 days and no more than 5 minutes ahead of Everfur's clock

The SDK throws EverfurConfigError before sending anything it can tell will be refused: a malformed key, a missing or unexpected petRef, an occurredAt string without an offset (Z or +05:30), or a body over 32 KB. The 30-day window depends on Everfur's clock, so it is checked by Everfur and answered as a 400.

The types and their data

Every schema is closed: a field that is not listed is refused, not dropped. The TypeScript types (PartnerInboundEvent, PartnerInboundEventDataMap) name exactly these fields, so a missing or unknown one fails your build.

Type data (required fields in bold)
pet.profile.updated name (1 to 64), species (1 to 64), breed (1 to 128), sex (male, female, unknown), date_of_birth (YYYY-MM-DD), weight_kg (over 0, under 500), neutered. An absent field is unchanged
pet.incident.reported incident_id (1 to 128), category (injury, illness, behavior, escape, other), severity (low, medium, high), location_type (daycare, boarding, grooming, walk, training, home, other), description (1 to 2000), resolved (default false)
pet.behavior_note.added note_id (1 to 128), context (daycare, boarding, grooming, walk, training, other), note (1 to 2000), sentiment (positive, neutral, concern)
pet.service_score.recorded score_id (1 to 128), service (daycare, boarding, grooming, walk, training), score (integer 1 to 5), comment (up to 2000)
pet.deleted reason (owner_request, deceased, rehomed, other)
member.status_changed status (active, paused, cancelled, lapsed), plan (up to 64), effective_on (YYYY-MM-DD)
member.deleted reason (owner_request, account_closed, other)

Lengths are in characters, after Everfur trims surrounding spaces.

  • Order: for the same thing (the same incident_id, note_id or score_id), the event with the later occurredAt wins, not the one that arrived last. An event older than the one already recorded answers status: "superseded".
  • pet.deleted erases what you sent about that pet, and member.deleted erases what you sent about that member.

Idempotency and retries

Every request carries an Idempotency-Key. Sending the same key with the same event again answers the original 201 receipt and records nothing new; the same key with a different body is a 409. The SDK turns one event into the same bytes every time, so a retry with the same key replays.

Pass your own key, derived from an id in your system. If you omit it, the SDK generates one, but that key is only returned on a successful receipt (result.value.idempotencyKey). After a timeout or a network failure it is lost, and resending gets a new key, so an event that did reach Everfur can be recorded twice.

The receipt

A 201 answers:

{
  "id": "ievt_0123456789abcdef0123456789abcdef",
  "object": "event",
  "direction": "inbound",
  "type": "pet.incident.reported",
  "schema_version": 1,
  "user_ref": "member-123",
  "pet_ref": "pet-456",
  "occurred_at": "2026-09-16T10:00:00+00:00",
  "received_at": "2026-09-16T10:00:01.123456+00:00",
  "status": "applied",
  "livemode": true
}

result.value carries the same fields in camelCase (id, type, schemaVersion, userRef, petRef, occurredAt, receivedAt, status, livemode) plus the idempotencyKey it was sent with. petRef is null on a member event, and livemode is false on a sandbox tenant.

Failures

Every failure settles to result.ok === false with result.error.httpStatus and Everfur's request id on result.error.requestId; see 11-ERRORS.md. result.error.code is the SDK's normalized code; the code as sent is on result.error.raw.

Status Code as sent result.error.code What to do
400 event_payload_invalid validationFailed Fix the event. The message names the first field at fault. Also returned, with nothing stored, when an incident_id, note_id or score_id already names an event about a different member or pet: those ids are unique per type in your account
400 event_type_unknown validationFailed Use one of the seven types
400 idempotency_key_required, idempotency_key_invalid validationFailed Send a key of 1 to 255 visible ASCII characters
401 authRejected authRejected Check the secret key. Every credential failure answers the same way
403 insufficientCapability accessDenied A restricted key without partner_events.inbound.create
404 ResourceNotFoundException resourceNotFound Inbound events are not enabled for your tenant yet
409 idempotency_key_reused idempotencyConflict You reused a key for a different event. Use a new key
413 payload_too_large validationFailed The body is over 32 KB
429 rateLimited rateLimited Over your per-minute limit. Resend with the same key after the wait below
5xx InternalFailureException internalError Resend with the same key

Only rateLimited and internalError are retryable; retrying any other failure fails the same way. A 429 carries a Retry-After header of at most 60 seconds, but result.error does not expose response headers, so wait up to 60 seconds before resending.

Vet visits

Status: draft for owner approval. Vet visits are not yet enabled for any partner. The SDK surface below ships dark: VetVisitButton renders nothing until Everfur grants the televet capability to your tenant.

For a partner developer who wants a "see a vet" entry point in their own app or site. The visit runs inside your app. Booking, the confirmation and the video call are Everfur screens your app renders: a full-screen modal on React Native, a dialog in your own page on the web. Nothing opens the system browser or a new tab unless you ask for it: the hosted Everfur page is an explicit opt-in (prefer="redirect", section 3c).

Prev: 15-EVENTS-AND-WEBHOOKS.md · Web basics: 13-WEB-INTEGRATION.md · Errors: 11-ERRORS.md


1. What happens when the user taps the button

  1. Every entry point resolves ONE destination, in this order: VetVisitButton, useVetVisit().open(), useVetVisit().openVisit() and every vet entry inside EverfurChat (the header, the urgency pill, the vet-prep chip, the check-in) all take it.
    • the button's own onStartInApp, when you pass one;
    • a presenter you registered with registerEverfurTelevetFlow, when you want the visit in your own navigator or router (section 4a);
    • otherwise the in-app visit Everfur provides, rendered by EverfurTelevetHost. VetVisitButton and EverfurChat mount that host themselves.
  2. Nothing is minted and nothing leaves your app. The booking screens load on the first press, through a dynamic import of @everfur/sdk/televet/booking (or @everfur/sdk/web/televet/booking), so a page where nobody opens a visit never downloads them.
  3. Joining a booked visit mints the room credential in your app (POST /widget/v1/televet/consults/{id}/join), shows "This consultation is recorded." with Continue as the only way into the room, then runs the call. The call surface (@everfur/sdk/televet/call, @everfur/sdk/web/televet/call) is loaded only then, and needs the Daily module you register (sections 3a and 4).

Visits started from a partner are advice visits. In this version the vet cannot write a prescription during a visit that started from your app. Your tenant pays for the visit: there is no purchase step in your app.

1a. What the member can do in the app

  • Book. A vet entry opens the booking when the member has nothing ahead. On both platforms the booking is the Everfur app's eight steps: who the visit is for, where the pet will be during the call, what's going on, the description, a short emergency check, the vet, the time, and the review, then the confirmation. The emergency check lists seven signs; "Yes, at least one" shows the ways out (below) and holds the booking until the member says they have handled it, which the booking then records as acknowledged.
  • Come back to a visit. When the member has a visit still ahead, the same entry lands on their visits instead: upcoming and past, with the way to book another. So a visit booked in your app can always be reached from your app, even though a booking made in the app sends no reminder event you could route.
  • Join. On React Native a visit opens its own screen (the time, the vet, and a Join that arms 15 minutes before and says why when it is not armed); on the web the Join sits on the visit's row, as in the consumer web app. Joining shows the recording notice before the room. On the web nothing is sent until the member presses Continue, as in the consumer web app, so Not now leaves no trace on the visit.
  • Move or cancel a scheduled visit. Moving shows the new time on its button ("Move to ...") and moves only when it is pressed. Cancelling shows the served cancellation terms first, asks "What changed?" (marked "Optional") with four answers, and shows the server's receipt. The picked answer is sent as the reason; on both platforms nothing picked sends the app's reviewed fixed reason, so a reason always goes on the wire.
  • Message the vet and read the visit summary (preview, off until Everfur turns it on for you). See 1b.
  • Not yet in the app: the rating. The partner plane serves no route for it. A summary or messages intent on a reminder still opens the member's visits (a visitRef cannot be resolved to one visit on this plane), from where the member reaches the thread and the summary.

When the member says yes on the emergency check, or the server flags the description at the review, the warning offers the ways out: ASPCA Animal Poison Control (dialled from its own number), the pet's own clinic when you pass primaryVet ({ detail, dial }), and your own emergency-vet locator when you pass onFindEmergencyVet. The SDK has no clinic directory and never navigates your page or opens a browser for it.

Both props are EverfurTelevet props, so they reach the warning when you mount EverfurTelevet yourself (your own screen, or a presenter registered with registerEverfurTelevetFlow, section 4a). The in-app visit Everfur provides by default (EverfurTelevetHost) does not take them yet, so on that path the warning shows no exits.

1b. Vet messages and the visit summary (preview)

The Everfur app's vet thread and visit summary, in your app. They sit behind a server switch that is OFF by default, and while it is off the SDK draws nothing of them: the visits screen asks the thread list once, and only when that list answers does it draw anything below. Nothing is ever drawn on a guess, not even for one render.

When Everfur has turned it on for your tenant:

  • Messages. The visits screen shows a "Messages" card: one row per pet the member has a thread about, the newest message as its line, and a "New" pill (web: "New reply") while a vet message is unread. A row opens the thread.
  • The thread. The member's messages and the vet's, in the order they were written, under day labels, with a composer ("Message {vet}…") and a note under it. Sending is text only. A message that did not send keeps the member's words in the field and says "Not sent". If Everfur requires the vet to write first, the composer goes away and a line says so. Opening the thread marks it read.
  • Running late? The screen the member is on before the call (React Native: the visit screen with Join; web: the next visit's card) shows "Running late? Message {vet}", which opens the thread.
  • The visit summary. A finished visit's row opens the vet's approved summary: what the vet saw, the pet's plan in the vet's words, the medications they wrote, the vet's name and credentials, "Message {vet}" and "Book a follow-up". Until the vet approves it, the screen says "Summary on its way".

{vet} is the clinician's name as the server sends it, and {pet} the pet's name from your roster; the SDK never invents either. A thread is addressed by your own pet_ref, so a pet the member no longer has in your roster has no thread in the list.

The screens are exported by name (TelevetVetThreadScreen, TelevetVisitSummaryScreen) with their hooks (useVetThreads, useVetThread, useVisitSummary) from @everfur/sdk/televet/booking and @everfur/sdk/web/televet/booking, for a host that mounts them in its own navigator or router. useVetThreads reports availability: draw an entry to these screens only while it is lit.

The routes are GET /widget/v1/televet/messages/threads, GET and POST /widget/v1/televet/messages/threads/{pet_ref}/messages, PUT /widget/v1/televet/messages/threads/{pet_ref}/read and GET /widget/v1/televet/consults/{consult_id}/summary, all on the booking routes' session and meters.

2. Requirements

  • A signed session. The booking and join routes refuse a publishable-key or anonymous caller. Mint sessions on your backend with mintPartnerSession from @everfur/sdk/server and return the token from EverfurUser.getToken (see 05-SDK-INTEGRATION.md and 13-WEB-INTEGRATION.md).
  • The televet capability. Everfur turns vet visits on for your tenant. Until then the button renders nothing and useVetVisit().available is false, so it is safe to ship the button before launch. The same holds when the provider has no user.
  • In-app booking switched on for your environment. Until Everfur arms it, every booking route answers 404 and the in-app visit shows its paused state ("Booking is not available right now.") inside your app, with Close as the way out. It never falls back to a browser on its own.
  • For the call: the Daily module, registered once (sections 3a and 4).
  • The member's US state, as usState (vetUsState on EverfurChat), recommended. The SDK never reads a device location. The booking asks where the pet will be during the visit on every booking, and offers your state first, already picked; without one the member picks the state from the list.

3. On the web

Vet visits have their own entry, @everfur/sdk/web/televet, so a page that only shows chat never downloads them. Mount it under the EverfurProvider from @everfur/sdk/web.

import { EverfurProvider, petRef, userRef } from '@everfur/sdk/web';
import { VetVisitButton } from '@everfur/sdk/web/televet';

async function getToken(): Promise<string> {
  const res = await fetch('/api/everfur/session', { method: 'POST', credentials: 'same-origin' });
  if (!res.ok) throw new Error(`session mint failed: ${res.status}`);
  return res.text();
}

export function PetProfile(): React.ReactNode {
  return (
    <EverfurProvider
      config={{ publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.everfur.com/api/v1' }}
      user={{ userRef: userRef('your-stable-user-id'), getToken }}
    >
      <VetVisitButton
        petRef={petRef('your-stable-pet-id')}
        usState="NJ"
        onError={(reason) => console.warn('vet visit did not open:', reason)}
      />
    </EverfurProvider>
  );
}

A click opens a modal <dialog> rendered in the SDK's own DOM subtree (Escape asks the screen first, so a half-built booking gets its discard question and a live call is never hung up). There is no route to add and no page to navigate to.

Your own control. Use the hook and mount EverfurTelevetHost once, anywhere inside the provider, so a click has somewhere to open:

import { EverfurTelevetHost, useVetVisit } from '@everfur/sdk/web/televet';

export function TalkToAVetLink(): React.ReactNode {
  const visit = useVetVisit();
  return (
    <>
      <EverfurTelevetHost />
      {visit.available ? (
        <button type="button" onClick={() => void visit.open()}>
          Vet visit
        </button>
      ) : null}
    </>
  );
}

Add to Calendar. The SDK takes no calendar dependency, so the booked confirmation offers Add to Calendar only when you give the host a calendar writer. Pass onAddToCalendar to EverfurTelevetHost. Without it the confirmation has no calendar control. VetVisitButton and EverfurChat mount hosts that take no props, and the first host mounted is the one that renders, so mount yours first, above them.

downloadTelevetCalendarFile is a ready writer. It does what the consumer web app does: it hands the member a one-shot .ics file (RFC 5545, times in UTC) to import into their own calendar. A started download is not yet an entry in a calendar, so it resolves 'handed-off' rather than true, and the confirmation keeps Add to Calendar enabled so the member can press it again, as the consumer web app's link can be.

import type { ReactNode } from 'react';
import { EverfurTelevetHost, downloadTelevetCalendarFile } from '@everfur/sdk/web/televet';

export function VetVisitsRoot({ children }: { readonly children: ReactNode }): ReactNode {
  return (
    <>
      <EverfurTelevetHost onAddToCalendar={downloadTelevetCalendarFile} />
      {children}
    </>
  );
}

The file carries no alarm, as in the consumer app: a file the member has imported cannot be withdrawn if the visit is later moved or cancelled, and a reminder in it would outlive the visit. Its UID is the entry's key, so every file for one visit names the same event. It has no link back to the visit, because the SDK cannot name your route. televetCalendarFile(event) returns the same file as { href, filename } (a data: URL), or null when the entry cannot be built, if you would rather render the download link yourself.

A page with a calendar of its own passes its own writer instead. It is called with the visit's entry (key, title, startDate, endDate, alarmOffsetMinutes) and resolves true once the entry is written, which is the only result that makes the confirmation say the visit was added; false when it could not be written, which shows the confirmation's did-not-save line; or 'handed-off' when it gave the member something to add themselves, which keeps the control available:

import type { ReactNode } from 'react';
import { EverfurTelevetHost, type TelevetCalendarEvent } from '@everfur/sdk/web/televet';

declare function addToYourCalendar(event: TelevetCalendarEvent): Promise<boolean>;

export function VetVisitsRootWithCalendar({ children }: { readonly children: ReactNode }): ReactNode {
  return (
    <>
      <EverfurTelevetHost onAddToCalendar={addToYourCalendar} />
      {children}
    </>
  );
}

Booking photos work the same way. The intake step offers its attachment row only when the host is given a picker (onPickMedia), and the SDK opens no file dialog of its own. The picker is called with the remaining headroom and resolves the picks (uri, filename, mimeType, fileSize), or null when the member cancelled. Photos reach the vet only while your plane serves booking media; otherwise the row tells the member nothing was sent. Mount the host that carries it first, as above:

import type { ReactNode } from 'react';
import { EverfurTelevetHost, type TelevetIntakeMediaItem } from '@everfur/sdk/web/televet';

function pickPhotos(limit: number): Promise<readonly TelevetIntakeMediaItem[] | null> {
  return new Promise((resolve) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = 'image/*,video/*';
    input.multiple = true;
    input.onchange = () => {
      const files = Array.from(input.files ?? []).slice(0, limit);
      resolve(files.map((f) => ({ uri: URL.createObjectURL(f), filename: f.name, mimeType: f.type, fileSize: f.size })));
    };
    input.click();
  });
}

export function VetVisitsRootWithPhotos({ children }: { readonly children: ReactNode }): ReactNode {
  return (
    <>
      <EverfurTelevetHost onPickMedia={pickPhotos} />
      {children}
    </>
  );
}

3a. The call in your page

The call runs on @daily-co/daily-js, an optional peer the SDK never imports. Install it and hand it in, once, in your app entry, so your bundler resolves and code-splits it:

// docs:no-compile - @daily-co/daily-js is an OPTIONAL peer this package never imports, so it is deliberately
// not installed here. That is the whole point of this snippet: the specifier lives in YOUR entry file.
import { registerEverfurDaily } from '@everfur/sdk/web/televet/call';

registerEverfurDaily(() => import('@daily-co/daily-js'));

With it registered, joining a visit in the in-page experience shows the recording notice first, calls POST /consults/{id}/join only when the member presses Continue (that request mints the room credential and records the member's arrival for the vet; Not now sends nothing), and runs the call in the dialog. Without it, the visit says it cannot be joined, with Leave, before any notice or request, and your console says what to install; the member is never sent elsewhere. In the dialog, Escape is Leave everywhere except in the room, where it never hangs up a call, and while the join request travels. That wait holds Escape and Not now for at most 30 seconds, even when your getToken or an injected controller has not answered by then; after that both are Leave, and a late answer opens nothing.

EverfurVetCall from @everfur/sdk/web/televet/call is also usable on its own, with a roomUrl and token you minted through TelevetBookingController.joinConsult (@everfur/sdk/web/televet/booking). What it does and refuses:

  • It shows "This consultation is recorded." FIRST, every mount, with no prop to skip it. No frame is built and nothing joins until Continue; Not now leaves. The only place it does not ask is inside the SDK's own join screen, which asked the same notice before the credential was minted.
  • It releases the camera and the microphone on unmount, from the effect cleanup.
  • roomUrl must be an https URL and token must be non-empty; anything else is refused before the Daily module is loaded, with onUnavailable('roomUnusable', …).
  • The token is handed to Daily's join() as a parameter and never put in a URL, in the DOM, on the console or in a telemetry property.
  • The call is Daily's Prebuilt frame and nothing else, as in the consumer web app's owner room. In the room, Daily's own tray carries mute, camera, devices and Leave. In the room the frame is 16:9 at the width you give it, and its height is capped at calc(100dvh - 8rem) so that tray stays above the fold in a wide container; where the cap binds, the frame is wider than 16:9 and Daily lays itself out inside it.
  • Before the room, the frame is sized for Daily's own pre-join screen, which lays itself out by the frame's shape. Narrower than 480 px (a phone), the frame is portrait (3:4) and at least 500 px tall, so Daily stacks its pre-join instead of clipping Join and the camera and microphone toggles. From 480 px it stays 16:9 under the same cap, with a floor of 45% of its width (at least 270 px), which only binds where the cap would cut Daily's toggle row (a phone in landscape). Where a floor binds, the frame is taller than the cap and your page scrolls, so do not clip the call's container. The frame measures its own width, not the viewport, so give it the width you want the call to have. From the moment the member is in the room it is the 16:9 box above.
  • Until Daily reports the member in the room (its frame is loading, or it is showing its own pre-join screen, which has no Leave), a Leave sits under the frame. A Leave waits up to 5 seconds for Daily to let go, then calls onLeave regardless and releases the Daily instance, so the next join on the page works without a reload.
  • EverfurVetCallLobby is exported for a host that draws its own wait screen. It states the visit length only when you pass consultMinutes: pass the join response's callLimits.consultMinutes from the VetCallCredential that joinConsult returned. It never guesses one.

Not verified against a real call. The surface is exercised against a fake Daily module in this repository's tests. A real visit needs a Daily room, a minted token, a camera and a second participant.

3b. Your own route (optional)

To show the visit on a route of yours instead of the SDK's dialog, register a presenter once and mount EverfurTelevet there. The presenter receives the request (the pet, the state and, for a visit the member already has, visitRef and intent); televetScreenForRequest turns it into the screen to open on:

// docs:no-compile - `router` and `useRouteState` are your router's, and the SDK has no type for them.
import { registerEverfurTelevetFlow } from '@everfur/sdk/web/televet';
import { EverfurTelevet, televetScreenForRequest } from '@everfur/sdk/web/televet/booking';

registerEverfurTelevetFlow((request) => router.push('/vet', { state: request }));

export function VetRoute(): React.ReactNode {
  const request = useRouteState();
  return <EverfurTelevet initialScreen={televetScreenForRequest(request)} onExit={() => router.back()} historyMode="browser" />;
}

3c. The hosted visit, explicitly

prefer="redirect" on VetVisitButton (or prefer: 'redirect' on useVetVisit, vetPrefer="redirect" on EverfurChat) is the one way to the hosted Everfur page: the SDK opens a blank tab inside the click, mints a single-use handoff, and points the tab at it. If the mint fails the tab is closed; if the browser refuses the tab, nothing is requested and onError receives popupBlocked. Do not await anything before calling open() on this path, or the browser no longer treats the tab as opened by the user.

4. On React Native

import { EverfurProvider, userRef } from '@everfur/sdk';
import { VetVisitButton } from '@everfur/sdk/televet';

async function getToken(): Promise<string> {
  const res = await fetch('https://your-backend.example/everfur/session', { method: 'POST' });
  if (!res.ok) throw new Error(`session mint failed: ${res.status}`);
  return res.text();
}

export function PetScreen(): React.ReactNode {
  return (
    <EverfurProvider
      config={{ publishableKey: 'pk_live_…', apiBaseUrl: 'https://api.everfur.com/api/v1' }}
      user={{ userRef: userRef('your-stable-user-id'), getToken }}
    >
      <VetVisitButton usState="NJ" onError={(reason) => console.warn('vet visit did not open:', reason)} />
    </EverfurProvider>
  );
}

A press opens a full-screen Modal with the booking flow. Android's back button reaches the flow's own handlers (a pushed screen pops, the root asks before discarding a half-built booking, a live call is never hung up). An app that opens visits only through useVetVisit (a tapped notification, its own control) mounts <EverfurTelevetHost /> once, at its root, inside the provider.

The call needs the native Daily modules. Install @daily-co/react-native-daily-js and @daily-co/react-native-webrtc (on Expo also @daily-co/config-plugin-rn-daily-js in app.json plugins, then npx expo prebuild), rebuild the native app, and register the module once in your entry:

// docs:no-compile - the Daily native modules are OPTIONAL peers this package never imports.
import { registerEverfurDaily } from '@everfur/sdk/televet/call';

registerEverfurDaily(() => require('@daily-co/react-native-daily-js'));

Camera and microphone permissions (NSCameraUsageDescription and NSMicrophoneUsageDescription on iOS, CAMERA and RECORD_AUDIO on Android) are asked by Daily's join, at the moment the member continues into the call, never before. Expo Go cannot load the modules at all: use a development build. Without them the visit says it cannot be joined, with Leave.

openUrl is only for prefer="redirect", the explicit opt-in to the hosted visit, where it replaces Linking.openURL (for example openUrl={WebBrowser.openBrowserAsync}).

4a. Your own navigator (optional)

@everfur/sdk/televet/booking is the Everfur consumer app's own booking screens, ported: the pet picker, the intake description, the matched vet, the day strip and time grid, the review with its consent ticks, the confirmation, and the join. EverfurTelevetModal renders it by default; to put it in your own navigator instead, register a presenter and mount EverfurTelevet:

// docs:no-compile - `navigationRef` is your navigator's, and the SDK has no type for it.
import { registerEverfurTelevetFlow } from '@everfur/sdk/televet';

registerEverfurTelevetFlow((request) => navigationRef.navigate('EverfurVisit', request));
// docs:no-compile - `navigation` and `route` are your navigator's, and the SDK has no type for them.
import { EverfurTelevet, televetScreenForRequest } from '@everfur/sdk/televet/booking';

export function EverfurVisitScreen({ navigation, route }): React.ReactNode {
  return (
    <EverfurTelevet
      petRef={route.params?.petRef ?? null}
      usState={route.params?.usState ?? null}
      initialScreen={televetScreenForRequest(route.params ?? {})}
      onExit={() => navigation.goBack()}
    />
  );
}

A presenter may return false to decline a request it does not handle (for example one carrying a visitRef), and the SDK's own modal opens it instead. A presenter that throws is reported to the console and the modal opens it too. onStartInApp still wins for the one button it is passed to.

What it needs from you. The SDK takes no location, picker or calendar dependency, so these are host seams and each absence is a deliberate state rather than a dead control:

Prop Without it
usState The "where" step offers no state first; the member picks one from the list.
onPickMedia The intake step has no attachment row.
onAddToCalendar The confirmation has no Add to calendar control.
canJoinInApp The confirmation does not promise an in-app join. The SDK's own modal sets it once registerEverfurDaily has run; pass true in your own screen only after registering it.

A dark booking plane is a state, not a way out. Where in-app booking is not switched on, the flow shows its paused card inside the booking chrome, whose Close is the way out; it never sends the member to a browser on its own. To offer the hosted visit there instead, say so explicitly: renderUnavailable={() => <VetVisitButton prefer="redirect" />}.

An unfunded plane is the same state. The televet entitlement read answers whether YOUR televet allowance is on, never whether the member has a plan: your members have no Everfur membership to buy, because you pay. While the allowance is off (not set yet, suspended, set to 0 or used up), and when a booking is refused at Confirm with a 402, the flow shows the same paused card, or your renderUnavailable, and Close leaves without asking to discard anything the member did not enter.

A sandbox tenant with no allowance opens the visit simulator instead. When your entitlements report livemode: false and the televet entitlement read answers that the allowance is off, the in-app visit every vet entry opens (and your own mount of EverfurTelevet or EverfurTelevetBooking) shows the test-mode simulator where the paused card would be, marked as test mode, and your renderUnavailable is not used there. It only ever calls the test-mode routes, never a hold or a booking; see "Test mode" in section 10. Nothing else changes:

Tenant Allowance read The vet entry opens
live (livemode: true, or no livemode) allowance off the paused card, or your renderUnavailable
sandbox (livemode: false) allowance off the visit simulator
sandbox (livemode: false) allowance on the real booking flow, with real vets and real visits
either not served (404) the paused card, or your renderUnavailable

A 402 at Confirm always shows the paused card, on a sandbox tenant too.

5. The pet reference

petRef is optional. Pass your own stable id for the pet the user is looking at (1 to 128 characters). After sign-in, Everfur shows the user the pet details you registered with registerPet and asks them to confirm before anything is saved to their Everfur account. open(petRef) on the hook overrides the prop for one call.

6. When the visit does not open

onError(reason, error) and useVetVisit().errorReason carry one closed reason. error is the typed EverfurError when the API answered, and null for a failure on the device. On the default in-app path the only reason is unavailable, when no destination exists at all (no presenter and no EverfurTelevetHost mounted); every other reason below belongs to prefer="redirect", the hosted handoff.

Reason Cause What to do
sessionRequired The API saw no signed session for this user (the token your getToken returned was not a session token). Without a user at all the button renders nothing and open() settles unavailable instead. Mint a session on your backend and return it from getToken.
authRejected The session token was refused after one refresh. Check your session mint.
accessDenied Your key or plan does not include vet visits. Ask Everfur.
unavailable Vet visits are off for your tenant or for Everfur right now (the route answers 404). Hide the entry point; do not retry in a loop.
rateLimited This user or your tenant opened too many visits recently. Let the user try again later.
sdkUpdateRequired The API refused this SDK version. Upgrade @everfur/sdk.
invalidRequest The pet reference was empty or longer than 128 characters. Fix the id you pass.
network No response arrived. Let the user try again.
serverError Everfur failed, or answered with something that is not a visit URL. Let the user try again later.
popupBlocked Web: the browser refused the new tab. Ask the user to allow popups for your site.
openFailed React Native: the URL could not be opened. Check openUrl, or that a browser is available.
unknown Anything else. Report it with error.requestId.

cancelled is not reported to onError: it means the user closed the tab before the visit loaded, or the signed-in user changed while the handoff was being minted, so the SDK dropped it rather than open it for the previous user.

7. What the SDK never does

  • It never leaves your app on its own. No system browser and no new tab unless you pass prefer="redirect".
  • It never retries a handoff or a join. Each mint is a new single-use credential (a join also stamps the member's arrival for the vet), and the rate limit is a budget, not a burst. One tap is one request.
  • It never retries a televet read into a closed window. A televet 429 names its wait in retry_after_seconds; a wait longer than the SDK's retry budget settles on the first 429, and the visits list shows the card it shows for any other failed read, never the server's own sentence, as consumer web does.
  • It never joins a recorded room before the recording notice is acknowledged.
  • It never logs the handoff URL or the room token. The handoff URL is a credential for 120 seconds. It is not written to React state, telemetry or logs. Treat it the same way if you use openUrl.
  • It never opens anything but an https Everfur visit URL (loopback http is accepted for local development).

8. Return to your app, the state check and the Chat entry (preview)

Three optional settings on useVetVisit and VetVisitButton, on both @everfur/sdk/televet and @everfur/sdk/web/televet. All three are dark: each is switched off on the Everfur side until Everfur enables it for your tenant, and a visit started without them behaves exactly as above. returnTo only matters on prefer="redirect": the in-app visit never leaves your app, so there is nothing to return from.

Setting What it does While it is off for your tenant
returnTo After the hosted flow the user can go back to a destination you registered the handoff carries no flow id and there is no way back to your app
usState The entry is shown only where a visit can be booked available stays false
entry: 'chat' The entry inside your Chat, for the chat's pet available stays false

Return to your app: returnTo, the flow id and parseVetVisitReturn

  1. Register your return destinations with Everfur in advance, each under a short key (lower-case letters, digits, _ and -, up to 40 characters). A destination is an https:// URL or your app's own reverse-DNS scheme (com.yourcompany.app://vet/done). Everfur never redirects to a URL you pass at run time, so a handoff names only the key.
  2. Pass the key: <VetVisitButton returnTo="vet-done" onOpened={(flowId) => ...} />, or useVetVisit({ returnTo: 'vet-done' }), whose open() then settles to { opened: true, flowId }. Keep the flowId (22 URL-safe characters) with your own state for this user.
  3. When the user comes back, Everfur opens your destination with one added query parameter, everfur_flow=<flowId>. Read it with parseVetVisitReturn(url), which returns { flowId } or null, and match it to the id you kept. parseVetVisitReturn is on both @everfur/sdk/web/televet and @everfur/sdk/televet.
import { parseVetVisitReturn } from '@everfur/sdk/web/televet';

const returned = parseVetVisitReturn(window.location.href);
if (returned !== null) {
  // returned.flowId is the id open() gave you. It says nothing about the visit, the pet or the account.
}

The flow id is opaque and carries nothing else. A return is not proof that a visit was booked: the user may have stopped at any step, and a user who declines to connect your app is not returned. A flow stays usable for 24 hours after the handoff. Visit outcomes come only through the events in section 9.

The state check: usState

Pass the user's US state (for example usState="CA"). The SDK asks POST /widget/v1/televet/availability with { "state": "CA" }, and available (and the button) waits for the answer, which is only { "available": true | false }: nothing about the account, the plan or the price, and every reason a state is unavailable looks the same. An injected controller answers this through its optional checkAvailability(state); a controller without it is treated as unavailable wherever usState is set.

The Chat entry: entry: 'chat'

entry="chat" places the same button and the same in-app visit inside your Chat for the pet the chat is about (petRef is required). It is shown only while Everfur has the Chat entry switched on (GET /widget/v1/televet/chat-entry, answered through a controller's optional chatEntryOpen()). It opens only when the user chooses it: nothing opens it from the conversation, and nothing about the visit is written back into the chat.

9. What you learn about the visit

Nothing, by default. Nine visit event types travel through the existing events and webhooks delivery and reconciliation APIs:

Event Sent when
visit.booked the visit is booked
visit.cancelled it is cancelled
visit.completed it is completed (the note is signed)
visit.no_show nobody attended
visit.rescheduled the member moved it; carries previous_scheduled_at
visit.reminder a reminder was released; carries stage: "24h" | "30m"
visit.followup_sent the post-visit check-in was delivered
visit.message_received the vet wrote in the visit thread; carries message_at, never the message
visit.ended the call ended before the note is signed; carries ended_at. status is still booked

These remain behind the partner Televet/event gates and are sent only for users who explicitly opt in to status sharing when connecting your app. Everfur's hosted confirmation records that choice; your integration does not need to collect a second status-sharing consent. Three of them (visit.completed, visit.rescheduled, visit.ended) carry no notification copy, so they never become a push or an email; see 17-NOTIFICATIONS.md.

Opening a visit the member already has

A booking lands on the booking funnel, which is the wrong screen for a member who already has a visit. Pass visitRef (the same visit_ref the events above carry) and the request names that visit instead. It opens IN YOUR APP like any other request: your presenter receives visitRef and intent, and the SDK's own modal opens on the member's visits. With prefer: 'redirect' it is the hosted handoff for that visit:

import { useVetVisit } from '@everfur/sdk/televet'; // the web entry is @everfur/sdk/web/televet

export function useOpenTheRecap(): (visitRef: string) => Promise<void> {
  const visit = useVetVisit();
  // 'visit' (the default) | 'join' | 'summary' | 'messages'
  return async (visitRef) => {
    await visit.openVisit(visitRef, 'summary');
  };
}

visit opens the visit itself, join its call, summary the recap and messages the thread; an absent intent means visit. VetVisitButton takes the same pair as visitRef and intent props. On the redirect path Everfur resolves the reference at the mint against THIS member's own visits, so a reference that is not theirs settles invalidRequest and opens nothing; the same answer covers a malformed reference, so minting handoffs tells you nothing about which visits exist.

A visitRef is not a consult id, and the partner plane does not map one to the other yet, so the in-app visit screen for an existing visit is the member's visits, each with its own join, rather than a direct join of the one the reminder named.

A tapped Everfur push already carries both, so nothing has to be translated in between:

import { parseEverfurNotification } from '@everfur/sdk/notifications';
import { useVetVisit } from '@everfur/sdk/televet';

export function useOpenTappedVisit(): (response: unknown) => Promise<void> {
  const visit = useVetVisit();
  return async (response) => {
    const everfur = parseEverfurNotification(response);
    if (everfur?.target.kind !== 'visit') return;
    // A null intent means the visit is over without happening: there is nothing left to open.
    if (everfur.target.intent === null) await visit.open();
    else await visit.openVisit(everfur.target.visitRef, everfur.target.intent);
  };
}

Events contain operational identifiers, status and timestamps, never medical details. The owner's records-sharing choice for the treating vet is separate: it does not authorize disclosing records or AI chat history to your app. Do not interpret an absent event as a failed visit; sharing can be off or revoked.

On the redirect path the SDK opens the hosted visit and, with returnTo, lets the user come back to your app (section 8); the return carries only the flow id and there is no device-side visit-status endpoint. Reconcile events on your backend with its server credential; never put that secret in a React or React Native app. The hosted checkout return is back to Everfur booking, not a callback to your app. Confirm the exact backend/frontend/SDK release and test-tenant gates before a staging replay; these documented source capabilities are not proof that a particular environment is enabled.

10. Testing

MockTransport knows the handoff route, so a test can script it:

import { MockTransport } from '@everfur/sdk/testing';

const transport = new MockTransport();
transport.enqueueRequest({
  status: 201,
  body: { url: 'https://everfur.com/partner/visit#h=test-token', expires_at: '2026-09-16T10:02:00+00:00' },
});

To test your own UI without a provider session, pass a controller to VetVisitButton or useVetVisit whose createHandoff settles to the result you want.

Test mode: simulated visits and their webhooks (sandbox tenants only)

A sandbox tenant cannot book a real visit, so your webhook handler would otherwise never see a visit.* event before you go live. Test mode creates a simulated visit for one of the signed-in user's registered pets and moves it through the partner-visible statuses. There is no payment, no vet and no appointment. The hook is on @everfur/sdk/testing/rn for React Native and on @everfur/sdk/testing/web for the web:

import { useEverfurVetVisitSandbox, type PetRef } from '@everfur/sdk/testing/rn';

export function useSimulateVisitForTesting(): (pet: PetRef) => Promise<void> {
  const sandbox = useEverfurVetVisitSandbox();
  return async (pet) => {
    const created = await sandbox.createVisit(pet); // status 'booked', sends visit.booked
    if (!created.ok) return; // not found: not a sandbox tenant, not this user's pet, or test mode is off
    const moved = await sandbox.advanceVisit(created.value.visit.visitRef, 'completed'); // or 'cancelled' | 'no_show'
    if (moved.ok && moved.value.result === 'not_applicable') {
      // The visit already ended with another status.
    }
  };
}
  • createVisit(petRef) calls POST /widget/v1/televet/sandbox/visits with { "pet_ref": "..." }. The pet must be one of the signed-in user's registered pets. advanceVisit(visitRef, to) calls POST /widget/v1/televet/sandbox/visits/{visit_ref}/advance with { "to": "booked" | "cancelled" | "completed" | "no_show" }.
  • simulateVisitEvent(visitRef, event, stage?) raises one of the visit events that is NOT a status change, so you can test what a tap on each one opens without waiting for a real appointment. It calls POST /widget/v1/televet/sandbox/visits/{visit_ref}/simulate with { "event": "rescheduled" | "reminder" | "followup_sent" | "message_received" | "ended" }. stage is "24h" or "30m" and is required for reminder and refused for every other event. The simulated visit has to be in a status the real producer acts from, or the answer is not_applicable and nothing is written: followup_sent needs a completed visit, the others a scheduled one, and message_received works from either.
import { useEverfurVetVisitSandbox } from '@everfur/sdk/testing/rn';

export function useSimulateVisitEvents(): (visitRef: string) => Promise<void> {
  const sandbox = useEverfurVetVisitSandbox();
  return async (visitRef) => {
    await sandbox.simulateVisitEvent(visitRef, 'reminder', '30m'); // visit.reminder, stage 24h or 30m
    await sandbox.simulateVisitEvent(visitRef, 'message_received'); // visit.message_received
  };
}
  • Each settles to { result, visit }: applied (the visit was created or moved), already_applied (it was already there; nothing is written or sent twice) or not_applicable (the visit rules do not allow the move from its current status, for example cancelling a completed visit), with visit as it now stands: visitRef, petRef, status and scheduledAt.
  • Moves follow the same rules as a real visit, and each one sends the same visit.* event a real visit sends (section 9), with the same data fields, visit_ref equal to visitRef, and livemode: false. Events are sent only while your tenant has webhooks and Televet enabled and Everfur has visit status events switched on. A simulated visit is not tied to an Everfur account, so there is no status-sharing step in test mode.
  • The routes answer only a sandbox tenant's signed-in user, for that user's own pets and visits, while Everfur has test mode switched on. Every other case (a live tenant, no session, an unknown pet, an unknown or foreign visit, test mode off) is the same 404, which the SDK settles to the ordinary not-found error. A live tenant cannot tell test mode exists.
  • Each call is sent once and never retried, so a failed createVisit never creates two visits.
  • The hook lives on the testing entries, not on @everfur/sdk/televet or @everfur/sdk/web/televet, so none of it ships in your production bundle. Do not call it from a production build.

Your vet entry opens it for you. On a sandbox tenant with no televet allowance you do not need the hook to try it: the normal vet entry opens the same simulator in place of the paused card (the table in section 4). It creates the simulated visit for the pet the entry was opened for (a household of several pets is asked which pet first), then offers the status moves and the notification events above, showing the server's own values (visitRef, status, scheduledAt and applied, already_applied or not_applicable). It sends the same three requests as the hook and nothing else, so it can never book a real visit. The simulator screen ships in the booking flow (@everfur/sdk/televet/booking, @everfur/sdk/web/televet/booking), which your app loads when a vet entry opens; the hook itself stays on the testing entries.

Notifications

Status: built behind a switch. Push and email to your members ship dark: every route below answers 404 until Everfur turns partner.notifications_enabled on for your client, and the push and email features and the email_mode of your tenant are settings on the Everfur side (partner-toggleable by design; set through Everfur until the partner console exposes them). Until then the hook settles to unavailable and nothing is sent.

For a partner developer whose members should hear about the things the Everfur app would tell them: a records request moving, a record ready, a visit booked or due, a follow-up check-in, a vaccination due. Your members get every notification Everfur's own users get, on the same consent, through three channels. Everfur sends two of them itself.

Prev: 16-TELEVET-VISITS.md · Webhooks: 15-EVENTS-AND-WEBHOOKS.md · Errors: 11-ERRORS.md


1. The three channels

Channel Who sends To what What you wire
Webhook Everfur, to your server The endpoint you registered (15-EVENTS-AND-WEBHOOKS.md) The receiver. Unchanged by this chapter: it is for server-side handling (your own records, your own analytics, your own channels)
Push Everfur, through your own push provider Every active device your app registered for the member The token: obtain it with your notification library and pass it to useEverfurNotifications; route a tap with parseEverfurNotification
Email Everfur, from its own sending identity, with your logo and display name from the partner console The address your backend supplied with consent One server call, setPartnerMemberNotificationProfile

The three are independent. A webhook delivery reaches your server whether or not a push went out, so a receiver that pushes to the member from its own handler will notify twice once push is on for your tenant; the tenant's push switch is the remedy.

Which events push and email. The event types that carry notification copy in the Everfur app: record_request.updated, record.ready, record.activation, pet.vaccination.due, visit.booked, visit.cancelled, visit.no_show, visit.reminder, visit.followup_sent, visit.message_received, chat.follow_up_due and chat.reminder_due. The title and body of a push or an email are Everfur's own notification copy, the same the Everfur app shows; the payload beneath it carries ids only (section 5).

Two groups do NOT reach a device, for two different reasons:

  • No copy in the Everfur app, so nothing is sent although a routing target exists: visit.completed (the recap is its own flow), visit.rescheduled (the reminders are simply re-queued for the new time) and visit.ended. Treat them as webhook facts for your own bookkeeping.
  • Webhook only by design, with no routing target at all: the member events (member.linked, member.unlinked), the urgency verdict (chat.urgency.flagged, an in-app affordance, not a push) and webhook_endpoint.test. EVERFUR_WEBHOOK_ONLY_EVENT_TYPES from @everfur/sdk/server/events is this list, so your backend can branch on it rather than restate it.

Email policy. Your tenant's email_mode is fallback by default: a member is emailed only when they have no active device. always emails beside every push.

Every event is written by the code path that already checks the member's consent for it: records events by the pet's records consent, visit events by the option the member ticked to share visit status with you, follow-up check-ins by the member's own chat. A channel adds no reach the event did not already have.

On top of that, the member holds two switches, pushEnabled and emailEnabled (setPreferences below), every Everfur email carries a one-click unsubscribe, and erasing the member (eraseUserData) removes the profile, the devices and every pending delivery.

Email consent is yours to attest. Everfur stores an address only with the stamp of the consent you collected (given_at, method: 'partner_attested', the version of your consent text). A body without it is refused (422 consent_required). Everfur trusts the integrator's attestation for transactional mail, as the payment and messaging platforms you already integrate do; a double opt-in step is a later option.

3. Push on React Native

What you provide

The SDK never imports a push library, never asks the OS for permission and never fetches a token. Your app does that with what it already uses, and passes the token string in:

Your app Token source provider Reaches iOS Reaches Android
Expo (managed or bare with expo-notifications) Notifications.getExpoPushTokenAsync(), an ExponentPushToken[...] expo yes yes
Bare React Native with Firebase Messaging messaging().getToken(), an FCM registration token fcm yes, through Firebase yes
APNs directly not in this version: provider: 'apns' is refused with provider_unsupported

The credential Everfur sends with lives on your tenant and is set by Everfur, never through the SDK: an Expo project needs nothing, unless you enabled push security in your Expo account (then Everfur needs your Expo access token); a bare app using FCM supplies its Firebase service-account JSON to Everfur. A delivery to a provider your tenant has no credential for is skipped and shows as credentials_missing on the events API.

// docs:no-compile
// Your code, with your library. Ask for permission where it fits your product, then read the token.
import * as Notifications from 'expo-notifications';

async function readExpoPushToken(): Promise<string | null> {
  const { status } = await Notifications.getPermissionsAsync();
  if (status !== 'granted') return null;
  const token = await Notifications.getExpoPushTokenAsync({ projectId: 'your-eas-project-id' });
  return token.data; // ExponentPushToken[...]
}

Register it

useEverfurNotifications runs under your EverfurProvider, next to the surfaces. Give it the token (null until you have one) and it registers the device for the signed-in user, registers again when the token changes, and unregisters before the bearer drops on sign-out: logout(), setUser(null) and a switch to another user all run the unregistration first, under the leaving user's session, without ever delaying the sign-out by more than three seconds.

import { useEverfurNotifications } from '@everfur/sdk/notifications';
import { Platform } from 'react-native';

/** Mount once, high in the tree, inside <EverfurProvider>. `pushToken` is what your library gave you, or null. */
export function EverfurNotificationsBridge({ pushToken }: { readonly pushToken: string | null }): null {
  const notifications = useEverfurNotifications({
    token: pushToken,
    provider: 'expo',
    platform: Platform.OS === 'ios' ? 'ios' : 'android',
  });
  if (notifications.status === 'failed' && notifications.error !== null) {
    // Developer-facing: error.reasonCode names provider_unsupported, token_invalid or device_cipher_unavailable.
    console.warn('everfur notifications', notifications.error.code, notifications.error.reasonCode);
  }
  return null;
}

status reads signed_out (no user on the provider), no_token, registering, registered, failed (with the error), unavailable (the tenant is not enabled: the routes answer 404) or unregistered (after unregister()). A retryable failure is retried on the next foreground; register() retries on demand. The device id Everfur assigns is kept per user in the SDK's non-secret storage (config.storage) and swept on logout; the token itself is never stored by the SDK.

The same routes are available without the hook, bound to the runtime under your provider (createNotificationsClient(runtime) builds the same client for a host that constructs its own runtime from @everfur/sdk/core):

import { useEverfurNotificationsClient } from '@everfur/sdk/notifications';

export function useMemberNotificationSwitches() {
  const client = useEverfurNotificationsClient();
  return {
    mute: () => client.setPreferences({ pushEnabled: false, emailEnabled: false }),
    unmute: () => client.setPreferences({ pushEnabled: true, emailEnabled: true }),
  };
}

registerDevice, unregisterDevice and setPreferences settle to an EverfurResult; nothing throws. A publishable-key (anonymous) provider is refused by the platform with sessionRequired: notifications need a signed session (05-SDK-INTEGRATION.md).

Route a tap

A push from Everfur carries data.everfur, a small block of ids: which event, and what to open. Hand the object your library delivered (an expo-notifications Notification or NotificationResponse, a Firebase RemoteMessage, or just its data map) to parseEverfurNotification and route on target.kind. It is pure and total: anything that is not a block Everfur would send, including a payload someone else crafted, is null, and your app falls back to whatever it does for an unknown notification.

target.kind Sent for What to open Fields
record_request record_request.updated, record.ready That records request requestId, petRef (or null)
pet pet.vaccination.due That pet's records petRef
visit visit.* That vet visit visitRef, petRef (or null)
case chat.follow_up_due The conversation the check-in landed in caseRef, conversationId (or null), petRef (or null)
import { parseEverfurNotification } from '@everfur/sdk/notifications';

/** Call this from your library's "notification tapped" listener, and from the launch notification on cold start. */
export function routeEverfurNotification(
  payload: unknown,
  navigate: (screen: string, params: Record<string, string | null>) => void,
): boolean {
  const parsed = parseEverfurNotification(payload);
  if (parsed === null) return false; // not from Everfur (or not one it would send): your default handling
  switch (parsed.target.kind) {
    case 'record_request':
      navigate('RecordsRequest', { requestId: parsed.target.requestId, petRef: parsed.target.petRef });
      return true;
    case 'pet':
      navigate('PetRecords', { petRef: parsed.target.petRef });
      return true;
    case 'visit':
      navigate('VetVisit', { visitRef: parsed.target.visitRef, petRef: parsed.target.petRef });
      return true;
    case 'case':
      navigate('Chat', { conversationId: parsed.target.conversationId, petRef: parsed.target.petRef });
      return true;
  }
}

The block is never a deep link (everfur:// does not appear in it), never a name and never a line of clinical text. parsed.eventId is the same evt_ id the webhook envelope for that event carries, so a tap and a delivery can be matched.

4. Email from your backend

The address travels with its consent, from your server, under your secret key. The device SDK has no email verb by design.

import { setPartnerMemberNotificationProfile } from '@everfur/sdk/server';

const client = {
  apiBaseUrl: process.env.EVERFUR_API_BASE_URL ?? 'https://api.everfur.com/api/v1',
  partnerSecretKey: process.env.EVERFUR_PARTNER_SECRET_KEY ?? '',
};

export async function shareMemberEmailWithEverfur(userRef: string, email: string, consentGivenAt: Date) {
  const result = await setPartnerMemberNotificationProfile(client, {
    userRef,
    email,
    consent: { givenAt: consentGivenAt, method: 'partner_attested', version: 'your-consent-text-version' },
  });
  if (!result.ok) return result.error; // reasonCode: consent_required, email_invalid or consent_invalid
  return result.value; // the masked profile: emailMasked, consent, pushEnabled, emailEnabled, activeDevices
}

The answer is the masked profile (o***@example.com); Everfur never echoes the address. The route creates the member if your app has not brought them to Everfur yet, the way their first session would, and a new stamp clears an earlier unsubscribe. It answers 404 until both the server pet API and notifications are on for your client, 403 for a restricted key without pets.profile.update, 429 against your inbound events budget and a retryable 503 while the member's erasure is in progress.

5. What a push carries

{
  "everfur": {
    "v": 1,
    "event_id": "evt_0123456789abcdef0123456789abcdef",
    "event_type": "record.ready",
    "target": { "kind": "record_request", "request_id": "...", "pet_ref": "your-pet-id" }
  }
}

Exactly those four keys, v 1, an evt_ id, one of the four kinds and only the fields in the routing table, each a short opaque string. Everfur applies that allow-list before anything is sent; parseEverfurNotification applies it again on the way in. Through FCM the block arrives as a JSON string (FCM data maps are string to string); the parser reads both.

6. On the web

There is no web push in this version. @everfur/sdk/web/notifications ships the same useEverfurNotifications, client and parser; on a page, pass token: null and use setPreferences for the member's switches. Email goes to the address your backend supplied, whichever surface the member uses.

7. Errors

code reasonCode Meaning
validationFailed provider_unsupported provider: 'apns': APNs direct is not available in this version
validationFailed token_invalid The token is not that provider's shape (ExponentPushToken[...], an FCM registration token)
serviceUnavailable device_cipher_unavailable Everfur could not seal the token at rest; nothing was stored; retry later (the hook does, on the next foreground)
resourceNotFound Notifications are not enabled for your tenant (404); the hook reads unavailable
rateLimited More than ten registrations a minute for one member
sessionRequired A publishable-key caller: notifications need a signed session

explainNotificationsReason(error) returns a developer-facing sentence for each reason (and for credentials_missing, the reason a delivery is skipped on the events API); it is for your logs and your developer screens, never for a member. error.displayMessage stays the copy a person may see.

8. Testing

MockTransport (10-TESTING.md) knows the three routes (POST /widget/v1/me/devices, DELETE /widget/v1/me/devices/{device_id}, PUT /widget/v1/me/notification-preferences), so a test scripts their answers like any other. On a sandbox tenant the Records and vet visit test modes (10-TESTING.md) produce the events that push, so a registered device on a sandbox build receives real deliveries with livemode: false on the matching webhook.