@fytkit/sdk
Vanilla TypeScript SDK for the fytkit public API. Build a custom trainee-facing app on top of your fytkit programs, workouts, recipes, and Mux-protected video.
v0.1.x is a pre-1.0 release line. The shape of every exported type is documented in
etc/sdk.api.md; breaking changes are recorded inCHANGELOG.md.
Install
pnpm add @fytkit/sdk @orpc/client
# or
npm install @fytkit/sdk @orpc/client
Pin to an exact version while the SDK is pre-1.0:
pnpm add @fytkit/[email protected] @orpc/client
Full install + version-pinning guidance: docs/developer/installation.md. Semver policy: docs/developer/concepts/versioning.md.
30-second quickstart
import { createFytkitClient } from "@fytkit/sdk";
const fytkit = createFytkitClient({
clientId: "fk_app_xxxxxxxxxxxxxxxxx", // from /dashboard/developer
});
// Sign a trainee in (your own auth UI calls this)
const session = await fytkit.auth.signIn({
email: "[email protected]",
password: "their-password",
});
// Fetch their workouts
const { items } = await fytkit.workouts.list({ limit: 20 });
// Subscribe to auth state (e.g. to redirect on sign-out)
const off = fytkit.auth.onAuthStateChange((state) => {
if (state.type === "signed_out") window.location.assign("/sign-in");
});
// Sign out — revokes the server-side session and clears local storage
await fytkit.auth.signOut();
off();
Joining a studio and launching
A Fytkit Coach style app starts from a studio's join code (its QR code or join link) and reads the studio's launch settings once per launch:
import { createFytkitClient, resolveJoinCode, resolvePaymentMode } from "@fytkit/sdk";
// Once, when the member scans or types the code: keep the client id.
const studio = await resolveJoinCode(scannedCode);
const fytkit = createFytkitClient({ clientId: studio.clientId });
// Every launch: revalidate the stored settings with their ETag.
const stored = await loadStoredBootstrap(); // { data, etag } | null, your storage
const result = await fytkit.app.bootstrap({ etag: stored?.etag });
if (result.status === "ok") await saveStoredBootstrap({ data: result.data, etag: result.etag });
const settings = result.status === "ok" ? result.data : stored?.data;
// settings.studio (name, logoUrl), settings.theme (palette, variants),
// settings.featureOverrides, settings.minAppVersion, settings.links,
// settings.realtime, settings.paymentPolicy
// "Subscribe" (a link out to the studio's checkout) only where the store allows it.
const { mode } = resolvePaymentMode(settings.paymentPolicy, {
platform: "ios",
storefront: storefrontCountryCode, // StoreKit's Storefront.current?.countryCode
});
app.bootstrap resolves to { status: "unchanged" } (a 304, no body) while
the etag you sent is current. It replaced appConfig.get in 4.0.0:
app.bootstrap, with the
field-by-field migration. resolveJoinCode
needs no client. resolvePaymentMode is plan info ("What your plan includes")
for every storefront the policy doesn't list:
Subscribe or what your plan includes.
Trainer preview
A trainer can't sign in to a member app: staff accounts are refused there
(first_party_account). Instead, "Preview in app" on App → Theme in the
dashboard shows a QR code for a link such as
https://www.trainerlane.com/app-link/preview?client=<clientId>&token=<token>. TrainerLane
Coach redeems it for a read-only preview of the studio with no member
(unreleased; ships in the next minor):
import { createFytkitClient, createMemoryStorageAdapter, refusalData } from "@fytkit/sdk";
// Start from the link's `client` and `token`. Use a dedicated client: the
// phone may be bound to no studio, or to another one, and a preview never
// shares a client with a signed-in member.
const url = new URL(scannedLink);
const preview = createFytkitClient({
clientId: url.searchParams.get("client") ?? "",
storage: createMemoryStorageAdapter(),
});
const session = await preview.auth.startPreview({ token: url.searchParams.get("token") ?? "" });
// session: { kind: "preview", accessToken, expiresAt }: show a Preview banner.
// Studio content works as for a member on any plan: app.bootstrap, programs,
// workouts, exercises, articles, recipes, nutrition, media, plans.
const programs = await preview.programs.list({});
// Anything a member owns (profile, progress, logs, check-ins, habits,
// messages, health) and every write is refused with `preview_mode`:
// show the screen's empty state.
try {
await preview.progress.getStreak();
} catch (err) {
if (!refusalData(err, "preview_mode")) throw err;
showEmptyState();
}
// End it (signOut does the same while previewing).
await preview.auth.endPreview();
- Once, within 15 minutes. A QR token redeems once, within 15 minutes of
being shown. An unknown, expired, used or other studio's token rejects with
preview_token_invalid(not_found): ask the trainer for a new code. A studio's own app (not Fytkit Coach) getspreview_unavailable. - 60 minutes, memory only. The preview lasts 60 minutes, is never
refreshed, and never reaches
storage, so a reload ends it. It ends atexpiresAt, onendPrevieworsignOut, on any 401 (the studio turned Coach off, say), and when a member signs in on the same client. Each end but the last emitssigned_out;onPreviewChangereports every start and end. - Not alongside a member.
startPreviewrejects locally withmember_session_active(conflict) while a member is signed in on that client.
React: usePreviewSession, useStartPreview and useEndPreview in
@fytkit/react (unreleased). Every method:
auth.startPreview.
Tracking a workout
Log a workout ad-hoc (no program enrollment needed), record sets, complete it, then read back progress:
// Start an ad-hoc log against any workout — or pass { sessionId, enrollmentId }
// instead to log a scheduled program session.
const { data: log } = await fytkit.sessionLogs.create({ workoutId });
await fytkit.sessionLogs.addSet({
id: log.id,
exerciseId,
setNumber: 1,
reps: 8,
weightKg: "80.00",
});
await fytkit.sessionLogs.update({ id: log.id, completedAt: new Date().toISOString() });
// Workout-by-workout history for that workout
const { data: history } = await fytkit.sessionLogs.list({ workoutId });
// Stored progress: streak, PR history, aggregate summary
const { data: streak } = await fytkit.progress.getStreak({});
const { data: prs } = await fytkit.progression.listPrHistory({ exerciseId });
const { data: summary } = await fytkit.progress.getSummary({ period: "12w" });
Two-factor sign-in
auth.signIn returns a discriminated result. When the trainee has 2FA
enabled, complete the challenge with whichever method they choose:
const result = await fytkit.auth.signIn({ email, password });
if (result.kind === "two_factor_required") {
const { challenge } = result; // challenge.availableMethods: "totp" | "backup_code" | "email_otp"
// TOTP from their authenticator app…
const session = await fytkit.auth.verifyTotp({ challenge, code: "123456" });
// …or a one-time backup code:
// await fytkit.auth.verifyBackupCode({ challenge, code });
// …or an emailed code:
// await fytkit.auth.requestEmailOtp({ challenge });
// await fytkit.auth.verifyEmailOtp({ challenge, code });
}
Each verify method caches the issued session exactly like a plain sign-in
(signed_in fires on onAuthStateChange).
2FA enrollment & account security
All of these require a signed-in session:
// Enroll: render totpUri as a QR code; show backupCodes exactly once.
const { totpUri, backupCodes } = await fytkit.auth.enableTotp({ password });
await fytkit.auth.verifyTotpSetup({ code: "123456" }); // SDK swaps the rotated token for you
const status = await fytkit.auth.getSecurityStatus(); // { twoFactorEnabled, totpEnabled, hasBackupCodes }
await fytkit.auth.regenerateBackupCodes({ password });
await fytkit.auth.disableTwoFactor({ password });
// Password change (verifies the current password server-side). With
// revokeOtherSessions every other session of the account ends, in any app,
// and the SDK adopts the rotated token transparently.
await fytkit.auth.changePassword({ currentPassword, newPassword, revokeOtherSessions: true });
// Email change — a confirmation link is emailed to the new address;
// nothing changes until the trainee opens it.
await fytkit.auth.changeEmail({ newEmail });
// Resend the sign-up verification email to the trainee's own address (there
// is no email input). Resolves to { status: "sent" } or, when the address is
// already verified and nothing is sent, { status: "already_verified" }.
// Throttled per account (one a minute, five an hour): over the limit it
// rejects with { kind: "rate_limited", retryAfter } (milliseconds).
const { status } = await fytkit.auth.resendVerification();
// Devices signed in through this app (plus the calling one); the trainee's
// sessions elsewhere are never listed. revokeId is an opaque id (never the
// bearer token).
const sessions = await fytkit.auth.listSessions();
await fytkit.auth.revokeSession({ revokeId: sessions.find((s) => !s.isCurrent)!.revokeId });
await fytkit.auth.revokeOtherSessions(); // "sign out of your other devices" on this app
// Permanently close the account (re-verifies email + password). On success the
// SDK clears the session and emits signed_out.
await fytkit.auth.closeAccount({ confirmEmail, password });
A wrong password or one-time code is a { kind: "server", status: 400 } error
with code: "invalid_password" / "invalid_code"; a change that needs a
fresh sign-in is { kind: "forbidden", code: "reauth_required" }. None of
these sign the trainee out — only a 401 on an authenticated call means the
session is gone. A wrong password on signIn (for example, signing in again
after reauth_required) is a plain auth_required and leaves the current
session alone. An account that also runs or works at a trainer on Fytkit can't
sign in, sign up or complete two-factor through an app, and an existing app
session can't change its password or email, resend its verification email or close it:
{ kind: "forbidden", code: "first_party_account" }; point it to Fytkit's own
sign-in. Enrolling twice in a program is { kind: "conflict" } with the
existing enrollmentId.
Refusals
When the server says no for a reason your app can act on (a coaching feature
the plan lacks, a check-in already sent, a messaging limit, a studio that is
full), the error carries refusal: { reason, data } beside its usual fields.
Branch on refusal.reason, never on message text; refusalData reads a
known reason's data typed:
import { refusalData } from "@fytkit/sdk";
try {
await fytkit.messages.send({ body, clientRequestId });
} catch (err) {
const plan = refusalData(err, "entitlement_required");
if (plan) return showUpgrade(plan.entitlement);
const daily = refusalData(err, "daily_limit");
if (daily) return keepDraftUntil(daily.retryAt);
throw err;
}
Every reason and its data: docs/developer/reference/errors.md.
Live chat
subscribeToMessages keeps a conversation screen current over Supabase
Realtime: it fetches a token (messages.realtimeToken) and fytkit's public
connection settings (app.bootstrap), joins the trainee's own private
channel (and joins it afresh after Realtime closes it), renews the token
before it expires, and reports when to poll instead. Events carry no message content: onChange means "read the
conversation and the unread count again".
The SDK has no dependency on supabase-js. Add @supabase/supabase-js to
your app (npm install @supabase/supabase-js@^2.97.0) and pass a factory
that creates a client for the subscription alone. It must be 2.87.1 or
later: the helper sets the trainee's token on the socket by hand, and
earlier 2.x clients swap it for the anon key on the next heartbeat, which
leaves the subscription in fallback. Keep the auth options below: the
client carries the trainee's token only, and must not keep or refresh a
session, or read a sign-in from your page's URL that would replace it.
import { createClient } from "@supabase/supabase-js";
import { MESSAGES_FALLBACK_POLL_MS, subscribeToMessages } from "@fytkit/sdk";
const unsubscribe = subscribeToMessages(fytkit, {
realtime: ({ url, anonKey }) =>
createClient(url, anonKey, {
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
}),
onChange: () => refreshConversation(), // messages.list + messages.unreadCount
onStatus: (status) => {
// "live": stop polling. "connecting" / "fallback": poll the conversation
// every MESSAGES_FALLBACK_POLL_MS.conversation (10 s) and the count every
// MESSAGES_FALLBACK_POLL_MS.unreadCount (60 s). "refused": no trainee to
// listen for (signed out); the subscription has stopped.
setPolling(status === "connecting" || status === "fallback");
},
});
// On leaving the screen or signing out:
unsubscribe();
To show "Sam is writing…" while someone on the trainee's coaching team
writes a reply, pass onCoachTyping. It reports { kind: "typing", names }
(display names, as the conversation shows them on their messages) and
{ kind: "idle" }. It is live only: a name drops out by itself 8 seconds
after its last sign (COACH_TYPING_EXPIRY_MS), so a lost "stopped" never
leaves it showing, it goes idle when the channel is lost, and while you
poll it never shows.
subscribeToMessages(fytkit, {
realtime,
onChange: () => refreshConversation(),
onCoachTyping: (typing) =>
setWritingLine(typing.kind === "typing" ? `${firstName(typing.names[0])} is writing…` : null),
});
Polling spends your app's per-minute request limit, which all its users
share, so stop as soon as the status is live. The full guide:
messages.realtimeToken
and the recipe Chat screen with live updates.
Notification preferences
Per-member toggles stored server-side (defaults apply until first write):
const { data: prefs } = await fytkit.preferences.getNotifications();
await fytkit.preferences.updateNotifications({ streakAlerts: false }); // partial patch
coachMessages, checkInReminders and coachFeedback are the push toggles
(on until the member turns them off); "Program assigned" pushes under
newPrograms.
Push notifications
Fytkit Coach registers the device's Expo push token for the signed-in member.
The token is bound to the session it was registered on, so signing out,
revoking that session or closing the account removes it on the server; call
unregister first anyway, so the phone stops getting pushes at once.
await fytkit.push.register({ token: expoPushToken, platform: "ios" }); // or "android"
await fytkit.push.unregister({ token: expoPushToken }); // idempotent
A studio's own developer app is refused with { kind: "forbidden" }: only
Fytkit Coach receives pushes. Every push's data is a PushNotificationData
({ v: 1, clientId, kind, href }, no message text); ignore one whose
clientId isn't the bound studio. The contract:
docs/developer/guides/push-notifications.md.
What's included in this preview
resolveJoinCode(a studio's join code to its client id, name, logo and app theme, with no client) andapp.bootstrap(the launch read, revalidated with its ETag), withresolvePaymentMode("Subscribe" link-out or "What your plan includes", per storefront)auth.signUp/signIn/signOut/getSession/onAuthStateChangeauth.signInWithIdToken(Sign in with Apple or Google, Fytkit Coach only: pass the raw nonce; Apple gets its SHA-256 hex, Google the raw nonce)- Trainer preview:
auth.startPreview/getPreview/onPreviewChange/endPreview(a read-only, member-less preview of the studio from a "Preview in app" QR code; see Trainer preview) auth.requestPasswordReset/confirmPasswordReset/confirmEmail/resendVerification- Two-factor:
auth.verifyTotp/verifyBackupCode/requestEmailOtp/verifyEmailOtp(sign-in),enableTotp/verifyTotpSetup/disableTwoFactor/regenerateBackupCodes/getSecurityStatus(management) - Account security:
auth.changePassword/changeEmail/confirmEmailChange/listSessions/revokeSession/revokeOtherSessions/closeAccount - Data namespaces:
workouts,programs,recipes,nutrition,nutritionLog,profile,preferences,push,articles,enrollments,sessionLogs,progression,progress,bodyMetrics,bodyMeasurements,progressPhotos,checkIns,messages,timeline,health,habits,media,subscription - Workout tracking: program session logs and ad-hoc workout logs (
sessionLogs.createwithworkoutId), set logging, per-workout history (sessionLogs.list({ workoutId })) - Progress:
progress.getSummary/getAdherence/getStreak(stored streaks) /getVolume(last week's hard sets per muscle group),progression.getPrSummary/listPrHistory(stored PR history) /getE1rmTrend(estimated 1-rep max per session, per lift) /getHistory,bodyMetrics.list/record/update/delete - Coach feedback on logged sessions:
sessionLogs.getFeedback/markFeedbackSeen/unseenFeedbackCount - Check-ins:
checkIns.current/submit/update/list/get/markReplySeen/requestPhotoUpload(weekly or fortnightly check-ins a coach reviews; needs a plan withcoaching_checkins) - Messages:
messages.list/send/requestAttachmentUpload/markRead/delete/unreadCount/realtimeToken(the trainee's conversation with their coaches; starting one needs a plan withcoaching_messages, answering a coach does not;requestAttachmentUploadreserves a photo to send with a message) - Live chat:
subscribeToMessages(joins the trainee's private Realtime channel withmessages.realtimeToken, renews the token, and says when to poll instead; see Live chat) - Automatic header injection (
X-TrainerLane-Client-Id,Authorization: Bearer …) - Proactive refresh ~60 s before token expiry (retried with backoff when offline) + reactive 401 retry; only a server-rejected refresh signs out
- Pluggable token storage (
localStoragedefault, in-memory + cookie for SSR)
Storage trade-off
Access tokens land in localStorage by default, namespaced by clientId. Any script running on your origin can read them — ship a strict CSP and treat the SDK like any other auth-bearing dependency. A token is a server session that lasts up to 7 days; signing out, closing the account or revoking the developer app ends it server-side.
Use createMemoryStorageAdapter() for SSR + tests.
Coming next
@fytkit/react— hooks +<FytkitVideo>examples/sdk-react— runnable sample appdocs/sdk/quickstart.md— full guide