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_typemust bepartner. 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 (compiledDefaultDecisionsinsrc/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 thanapiBaseUrl(a staging tenant's key against production, or the reverse), or ask_partner_pasted intopublishableKey. The SDK reports that last case as the config failuresecret-key-as-publishable-key. - Config never resolves:
apiBaseUrlis absent. The SDK ships no host default. It falls back to theEVERFUR_API_BASE_URLenvironment variable, andbabel-preset-expoinlines onlyEXPO_PUBLIC_*variables, so an unprefixed variable is present locally and absent in a release bundle. PassapiBaseUrlinconfiginstead. 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
- 05-SDK-INTEGRATION.md for every widget, every prop,
getTokenretry behavior, and testing.
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 agetTokenthat proxies to the partner's backend (for the minted session) plusactivePet. - 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_hintsand 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.historyCursoris null at the beginning of history;isLoadingHistoryexposes 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,NeutralandNot helpful, recorded at once throughPOST /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, ornot_helpfulwith notes). - Citations. The sources a reply cites (
citationson 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 theonOpenUrlseam above, which decides where a tapped source opens. - Timestamps. Every settled row shows its time in the reader's locale (
ChatMessage.createdAt: the wirecreated_aton 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 youronFindVetis the route,Nearest emergency vet),schedule_soon(Vet care suggested soon),home_care(Monitor at home) and the calmgeneraltier (Good to know: the server classified the reply as informational, with the app'sJust 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 reachesonFindVetandrenderVetActionasUrgencyDisplayLevel.
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 thepkand auser-ref. General (non-per-user) answers, no session minted. Simplest to stand up; good for testing. - Personalized,
partner-user-ref(recommended for production): pass auserwith agetToken. 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
userRefcase-consistent. It is an opaque, case-sensitive identity.Bobandbobare 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
getTokenagain, 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). getTokenmust 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 answers200with the token as atext/plainbody, andgetTokenreturnsres.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_hintsand re-skins the widgets, so they look native to your product with zero client work. Your ownthemeprop 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_byin athemehas no effect. On React Native, a brandingfont_familyapplies 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.tsxin the SDK repository. It is type-checked bynpm run docs:checkon 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 acheck:no-secretsgate 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,VetVisitButtonanduseVetVisitreach@everfur/sdk/televet/bookingthrough a dynamicimport()on the first press of a vet entry, and the booking screens reach@everfur/sdk/televet/callthe 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/sdkand the framework-free./clientand./coreentries 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:
- 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.
- 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 ondetailnever 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.
capabilitiesis 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.livemodeisfalsewhen the tenant holds test data only,trueotherwise. It mirrors the immutableis_sandboxfact on the tenant. Tooling that must never touch real records reads this.revisiondoubles 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
Consent is recorded per scope, and optionally per pet.
GET /widget/v1/consent
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.
PUT /widget/v1/consent
| 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.
GET /widget/v1/records/pets/{pet_ref}/consent
Returns pet_ref, granted, version, granted_at.
PUT /widget/v1/records/pets/{pet_ref}/consent
| 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.
DELETE /widget/v1/records/pets/{pet_ref}/consent
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.
Register the pet first, or every consent call returns 404
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.
}
updatePetneeds 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_refsettlesupdatePetanddeletePetto the not-found error. eraseUserDatais 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: calllogout()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,PetsRepositorycarries 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.
- The records surface is enabled for your tenant. It is gated at the router, off by default, and the
whole sub-router returns
404when off. This is an Everfur-side switch, not something you can set. - Your tenant is granted both
records.upload.createandrecords.record.read. The SDK requires both for the coarserecordscapability (src/client/entitlements/resolve.ts); either one missing renders the disabled surface instead of the records UI. - The pet is a dog or a cat.
map_speciesacceptsdog,canine,k9,puppy,cat,feline,kitten, case-insensitive and trimmed. Anything else gets422 {"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. - The document is a PDF, at most 25 MiB (26214400 bytes).
application/pdfis the only acceptedcontent_typeat 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). - 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 auseron 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.
1. Grant consent
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" }
An absent consent row is 200 granted:false, never a 404
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 linkcontrol 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 exposesuploadContentTypes;acceptsPhotos(uploadContentTypes)is the "photos live" test.- Web: pass
uploadContentTypestofileToHandle(file, types),pickDocument(document, types)andrecordsDocumentAccept(types). The type is read from the file's first bytes, never fromFile.type. - React Native: the upload screen adds
Take a photowhen you setcameraon theEverfurConfig(the capture must reportsizeBytes, because initiate needs the byte count), andChoose from photoswhen you passonPickPhotos(same contract asonPickDocument, resolvingimage/*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 agenerated_at. The pet is valid and you own it, there is simply nothing published yet. This is a200, not a404. 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 Recordson the web,Rexon React Native;Health recordswhen you pass nopetName): theAt a glancemarker summary ring and the lab trend carousel (each reading opens theWhere this comes fromsheet), the record's sections withView full medical recordandView full timeline, the in-progress card (Rex's records are on the way, withAnother clinicandUpload a documentbeside 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 getsRequest from a clinicandUpload a document, and aYour record requestsrow into the request list.Request from a clinicis hidden when the tenant is known not to haverecords.clinic.create. - Request: one request, with the
Where it standstracker (for a clinic requestRequest sent,Clinic notified,Waiting for records…,Records received, ...Added to Rex's records; an upload starts atRequest created),Request detailsfor 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, andCancel requestwith its inline confirm. - Visits & records (the full record): the section stack (
Needs attention, theWhat's on filecoverage card,Sources on file, identity, upcoming care, then each entity type), the owner's per-row fact editor, and theSend Rex's recordsheet (Who it's forwithVet/Groomer/Boarding,Send toandSend,Create link and shareon the web,Share linkon React Native,Download PDF, the active links withTurn off). See "Share a record with an audience, by email" below. - Document: the print-like
Rex's health recordpreview with theDownload PDFcontrol. - 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, andRequest 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, passonOpenUrland, forrecord_pdf, download it withexpo-file-systemand hand the local file toexpo-sharing, then delete it. On the web a link is shared withnavigator.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: withoutonCopythe share sheet renders the link as selectable text and hidesCopy link.onAsk(prompt, entityRef): the app'sAsk Everfurpills (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. WithoutonAskno 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, aRecordsDocumentPicker: open your own picker limited to PDF, and resolvenullwhen the user cancels, aFileHandlewith the localuri,mimeType: 'application/pdf'andsizeBytes, 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.nameis optional, shown in the rows locally, and never sent. Withexpo-document-picker, for example, that isassetsfromgetDocumentAsync({ 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 asuploadQueue.An upload transport.
createRnRecordsUploadTransport()(from@everfur/sdk) posts the picked file from its localuristraight to the presigned upload with React Native's ownXMLHttpRequestandFormData, with no native dependency. It never uses the globalfetch: Expo installsexpo/fetchthere, and it cannot post a local file part. Set it asuploadTransportonEverfurConfig, 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.
- Call
searchClinics({ query })orrecentClinics()to select a clinic, or collect its name for the free-text path. - Call
getAuthorizationCopy(). Present the returned scope and authorization templates with the pet, clinic and owner details. Use the returnedauthorizationVersion; do not hardcode legal copy. - Obtain the owner's explicit signature. Supply its PNG as
signaturePngBase64, or stage it throughinitiateSignatureUpload(size)→ multipart POST to the returned policy →completeSignatureUpload(id). Supply the resultingsignatureId. Never generate a signature without the owner's action. - Call
createClinicRequestwithpetRef,ownerName,authorizationVersion, a stableidempotencyKey, exactly one ofclinicId/clinicFreeText, and exactly one ofsignatureId/signaturePngBase64.clinicEmailis optional; server anti-relay checks still apply. - 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 forretryRequest,updateClinicEmail,revokeRequest, orconvertToUploadwhen 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 noWhat's includedlist and keeps its old helper line, because saying "we include the right pages" would be false.On.
GET /widget/v1/records/featuresaddsshare_audiences, one entry per audience in the order the sheet offers them, each with the contentsectionsits link carries and, for a groomer or a kennel, the onlyentity_typesit keeps (nullmeans 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 linkmint sends{"audience": ...}too, and the sheet draws the leadPick who it's for; we include the right pages.with aWhat's includedlist 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. SetuploadTransport: createWebRecordsUploadTransport()on the config; the prebuilt surface then renders a real file input (accept="application/pdf"), and a hook-based UI turns aFileinto the handle withfileToHandle(file)(or asks for one withpickDocument()). 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,sessionStorageor 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; theupload_urlin the initiate response is authoritative).Request recordsdoes 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 inconnect-srcandimg-src. A page that is itself inside an iframe also needs the media bucket inframe-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 })opensrecords.htmlbeside 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)callsPOST /widget/v1/records/sandbox/requests/{request_id}/clinic-replywith{ "outcome": "records" | "no_records" | "declined" }.publishSampleRecord(requestId)calls.../publish-samplewith 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) ornot_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
simulateClinicReplyorpublishSampleRecord, refreshuseEverfurRecordsand 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 withEverfurClinicRequestBatchon a sandbox tenant reachessentwithout the dispatch worker, so each member request can be advanced the same way andGET /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
typefield on this route. Delta frames carrytokenand nothing else. Code that branches ontype === 'text.delta'matches nothing and renders an empty answer. The SDK acceptstype,deltaortoken, which is why it works either way. {"status": "reasoning"}is the opening frame, and it is what the SDK reports asaccepted.- A frame arrives AFTER
done. Themetadataframe carries the conversation title and follow-up questions. A client that closes the stream ondonesilently loses both. follow_up_questionsappears twice, ondoneand again onmetadata. 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
deltaframe arrived, and - a terminal frame arrived (
done, orerrorif 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: falsemeans nobody has been asked yet.granted: falsewithhas_decision: truemeans 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:
- 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/batchandGET /records/request-groups/{id}, their ownpartner.records_multi_clinic_enabledswitch) 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 andRecordDepthView.availablereads false, which is not an error. - 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.executeandvideo.gait.executeare 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 saysnamingFamily: "unknown"andconfirmedOnWire: 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:
- The pet was never registered. Anything scoped to a
pet_ref404s 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. - 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. - 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 nomessage. - There is no
code, so anything branching oncodeseesundefinedand 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 thex-amzn-requestidheader 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.jsonat the project root (orclaude mcp add-json everfur '<the everfur entry>'for your user scope). - Cursor:
.cursor/mcp.jsonin the project (or~/.cursor/mcp.jsonfor every project). - VS Code:
.vscode/mcp.jsonin the workspace, which wraps the sameeverfurentry under aserverskey instead ofmcpServers.
{
"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_URLis the staging API while you build; your test tenant is a sandbox tenant on staging. Going live changes it tohttps://api.everfur.com/api/v1.EVERFUR_PARTNER_KEYis the sandbox tenant's publishable key: apk_live_key,livemode: false.EVERFUR_DOCS_KEYis the read-only docs key the server fetches the contract with.EVERFUR_SESSION_TOKENis a short-lived session your backend minted for a test user. Onlyeverfur_verify_integrationreads 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:
- Which capabilities you are integrating, chosen from the contract's list (
chat,records,televet, ...). A name the contract does not list is refused withUNKNOWN_OPTION; it is never free text. - 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.
- Which environment the generated configuration targets (
stagingorproduction). - 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();
}
EverfurRecordsPageis 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, andVisits & records(the full record, vaccinations included) andTimeline. 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 toucheshistoryor the URL, and nothing opens a new tab. PassonNavigateonly if you route those screens yourself; the page then hands you every edge instead.ownerNamepre-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. PassonOpenUrl(url, kind)to do it your way;kindisshare_link,record_pdforrequest_document. - Mounting the screens one by one in your own router:
EverfurRecordsUpload(upload),EverfurClinicRequestorEverfurClinicRequestBatch(request from one or several clinics; orEverfurClinicPickerthenEverfurRecordsConsent),EverfurRecordsRequestsandEverfurRecordsRequest(request status, all or one), andEverfurRecordDetail(the full record with vaccinations). Each takesonNavigateandonBack. EverfurRecordsis 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. ItshistoryModeismemory(default) orbrowser. The host seams (petName,onOpenUrl,onCopy,onAsk,location) are in the records guide.consentVersionis 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:
useEverfurRecordsis the same hook React Native uses, andfileToHandle(file)/pickDocument()turn aFileinto 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'sshopify-app-proxyidentity 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,VetVisitButtonanduseVetVisitreach@everfur/sdk/web/televet/bookingthrough a dynamicimport(), and the booking screens reach@everfur/sdk/web/televet/callthe same way. The SDK keeps those specifiers as realimport()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
- Install the Everfur app on the store.
- 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 fromsdk-staging.everfur.comagainst the staging API, and the two can never be crossed. There is nopk_test_key: a sandbox tenant on staging is issued an ordinarypk_live_key and reportslivemode: false. - 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=…×tamp=…&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.jsin the app's theme extension) is thegetToken: it POSTs to<proxy path>/sessionon the store's own origin and returnssession_token. - Shopify appends
shop,logged_in_customer_id,path_prefix,timestampandsignature(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 rateLimitedcarriesRetry-After;503only for a fault on Everfur's side. - There is no partner backend on this path. The identity mode is
shopify-app-proxyin the contract and ineverfur 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
sendPartnerEventsettles to a404. The signature scheme andconstructEventare 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
- Create an endpoint. In the partner console's Webhooks tab, or
POST /api/v1/partners/webhook-endpointswith{url, description?, enabled_events: [...]}. The URL must be publichttpson 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. - 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. - Verify every delivery with
constructEvent, against the raw request body. - 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). - 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.
- Press Send test on the endpoint to receive a
webhook_endpoint.testevent 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-Agentand envelope below are the design and can change before deliveries are enabled;Everfur-Signatureis 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:
- Split the header on
,. Trim each item and split it at the first=. Take the singletvalue (digits only; twotitems is malformed) and everyv1value. Ignore any other key, such asv0: a future scheme is added under a new key. - Refuse the delivery if
|now - t|is more than 300 seconds. - For each secret, compute lowercase hex
HMAC-SHA256(key = the whole secret string including "whsec_", message = t + "." + raw body bytes). - Accept if any computed value equals any
v1value, 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_idorscore_id), the event with the lateroccurredAtwins, not the one that arrived last. An event older than the one already recorded answersstatus: "superseded". pet.deletederases what you sent about that pet, andmember.deletederases 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:
VetVisitButtonrenders nothing until Everfur grants thetelevetcapability 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
- Every entry point resolves ONE destination, in this order:
VetVisitButton,useVetVisit().open(),useVetVisit().openVisit()and every vet entry insideEverfurChat(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.VetVisitButtonandEverfurChatmount that host themselves.
- the button's own
- 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. - 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
summaryormessagesintent on a reminder still opens the member's visits (avisitRefcannot 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
mintPartnerSessionfrom@everfur/sdk/serverand return the token fromEverfurUser.getToken(see 05-SDK-INTEGRATION.md and 13-WEB-INTEGRATION.md). - The
televetcapability. Everfur turns vet visits on for your tenant. Until then the button renders nothing anduseVetVisit().availableisfalse, so it is safe to ship the button before launch. The same holds when the provider has nouser. - 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(vetUsStateonEverfurChat), 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.
roomUrlmust be an https URL andtokenmust be non-empty; anything else is refused before the Daily module is loaded, withonUnavailable('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
onLeaveregardless and releases the Daily instance, so the next join on the page works without a reload. EverfurVetCallLobbyis exported for a host that draws its own wait screen. It states the visit length only when you passconsultMinutes: pass the join response'scallLimits.consultMinutesfrom theVetCallCredentialthatjoinConsultreturned. 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
- 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 anhttps://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. - Pass the key:
<VetVisitButton returnTo="vet-done" onOpened={(flowId) => ...} />, oruseVetVisit({ returnTo: 'vet-done' }), whoseopen()then settles to{ opened: true, flowId }. Keep theflowId(22 URL-safe characters) with your own state for this user. - When the user comes back, Everfur opens your destination with one added query parameter,
everfur_flow=<flowId>. Read it withparseVetVisitReturn(url), which returns{ flowId }ornull, and match it to the id you kept.parseVetVisitReturnis on both@everfur/sdk/web/televetand@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)callsPOST /widget/v1/televet/sandbox/visitswith{ "pet_ref": "..." }. The pet must be one of the signed-in user's registered pets.advanceVisit(visitRef, to)callsPOST /widget/v1/televet/sandbox/visits/{visit_ref}/advancewith{ "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 callsPOST /widget/v1/televet/sandbox/visits/{visit_ref}/simulatewith{ "event": "rescheduled" | "reminder" | "followup_sent" | "message_received" | "ended" }.stageis"24h"or"30m"and is required forreminderand refused for every other event. The simulated visit has to be in a status the real producer acts from, or the answer isnot_applicableand nothing is written:followup_sentneeds a completed visit, the others a scheduled one, andmessage_receivedworks 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) ornot_applicable(the visit rules do not allow the move from its current status, for example cancelling a completed visit), withvisitas it now stands:visitRef,petRef,statusandscheduledAt. - 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 samedatafields,visit_refequal tovisitRef, andlivemode: 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
createVisitnever creates two visits. - The hook lives on the testing entries, not on
@everfur/sdk/televetor@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
404until Everfur turnspartner.notifications_enabledon for your client, and thepushandemail_modeof 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 tounavailableand 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 |
| 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) andvisit.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) andwebhook_endpoint.test.EVERFUR_WEBHOOK_ONLY_EVENT_TYPESfrom@everfur/sdk/server/eventsis 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.
2. Consent: the same as the app
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.