The official TypeScript SDK for the Affinity API.
Status: The
1.5.0release uses the forward-only2026-08-11Affinity API contract. Use Test mode until Affinity approves Live access.
The SDK provides a small, resource-oriented interface for software platforms connecting healthcare practices to Affinity's compounder network. It is intended for trusted server-side runtimes, including Node.js, Bun, AWS Lambda, and standards-based worker environments.
bun add @affinity-health/sdkimport { Affinity } from "@affinity-health/sdk";
const affinity = new Affinity(process.env.AFFINITY_API_KEY!);
const access = await affinity.account.retrieveAccess();
if (access.livemode) throw new Error("Use a test-mode key during sandbox development");
const practices = await affinity.practices.list();
const practice = practices.data[0];
if (!practice) throw new Error("Create a practice before browsing its catalog");
const catalog = await affinity.catalog.list({
practiceId: practice.id,
query: "semaglutide",
limit: 10,
});
const compounders = await affinity.compounders.list();
const item = catalog.data[0];
if (item) {
console.log(item.pricing.medicationSubtotalCents);
}
const actingAffinity = affinity.withActor({ id: authenticatedUser.id, type: "user" });
const orders = await actingAffinity.orders.list({ practiceId: practice.id });
console.log(`${compounders.data.length} compounders are available to this account`);Cursor-backed list methods return a typed first page when awaited. Each page has data, hasMore,
object, and url. The default page size is 25, and the maximum is 100.
Those methods are also async iterables. Iteration requests each next page with startingAfter:
for await (const practice of affinity.practices.list({ limit: 100 })) {
await synchronizePractice(practice);
}
for await (const compounder of affinity.compounders.list({ limit: 100 })) {
await synchronizeCompounder(compounder);
}Use autoPagingEach(...) when a callback is more convenient. Return false to stop iteration:
await affinity.users.list().autoPagingEach(async (user) => {
await synchronizeUser(user);
if (shouldStop(user)) return false;
});Pass startingAfter or endingBefore to request one page directly. Automatic iteration supports
forward traversal only and rejects endingBefore.
catalog.listShippingOptions(...) returns the complete eligible choice array directly. That
bounded decision set contains at most 50 options and does not use cursors. Each option's
amountCents is the final customer shipping price; vendor cost, Affinity margin, and internal fee
breakdowns are not part of the public contract.
Affinity supports three prescribing integrations: Affinity Hosted for a redirect-based workflow, Affinity Elements for an embedded composer, and the server-side SDK for platforms that build their own prescribing UI. The server-side SDK creates a complete unsigned draft; only the mapped provider can review and sign it in the one-time Affinity signing session. A service key cannot sign a prescription or receive the provider's Affinity signing PIN.
The API key belongs only in your backend. Never create an Affinity client in browser or mobile
code.
Copy .env.example to .env.local. Add a Test-mode API key, then run:
devOpen http://affinity-sdk.localhost:5191. The TanStack Start example keeps the API key on the
server. It creates a synthetic practice and an automatically verified provider mapping with NPI
1234567893.
Patient and order requests require traceable actor context. Create an immutable request-scoped
client with withActor(...), using your authenticated user's stable opaque ID. Use type: "system"
only for an automated process with no human user. Affinity records the actor for authorization and
audit attribution; do not use an email address or expose a provider's Affinity signing PIN.
Verify the signature against the exact raw request body before parsing or logging it. The verifier
returns the exhaustive, dated AffinityWebhookEvent union, so checking event.type narrows both
the event and its data.object.
import { AffinityWebhookVerificationError, verifyAffinityWebhook } from "@affinity-health/sdk";
export async function handleAffinityWebhook(request: Request) {
try {
const event = await verifyAffinityWebhook({
body: await request.arrayBuffer(),
secret: process.env.AFFINITY_WEBHOOK_SECRET!,
signature: request.headers.get("affinity-signature"),
});
switch (event.type) {
case "order.submitted":
console.log(event.data.object.id, event.data.object.status);
break;
case "webhook_endpoint.test":
console.log(event.data.object.id);
break;
}
return new Response(null, { status: 204 });
} catch (error) {
if (error instanceof AffinityWebhookVerificationError) {
return Response.json({ error: error.code }, { status: 400 });
}
throw error;
}
}The current event types and order statuses are exported as
affinityWebhookEventTypes and affinityOrderStatuses. During a signing-secret rotation, pass
both active secrets as an array; a request is accepted when either secret verifies. Do not call
request.json() before signature verification.
Use your own stable customer identifier as externalId; do not use email as identity or
authorization. Email is optional and can change. Affinity independently verifies the mapped
provider and determines prescribing authority.
In Test mode, use 1234567893 as the National Provider Identifier (NPI) for a synthetic provider.
Affinity immediately returns a verified provider mapping. Affinity rejects this NPI in Live mode.
The test provider must still review and sign prescriptions with the normal Affinity signing PIN.
const user = await affinity.users.create(
{
externalId: "customer_123",
email: "clinician@example.com",
name: "Jordan Lee",
metadata: {},
},
{ idempotencyKey: "customer_123" },
);
const practice = (await affinity.practices.list()).data[0];
if (!practice) throw new Error("Create a practice before granting access");
const role = (await affinity.roles.list(practice.id)).data[0];
if (!role) throw new Error("Create a practice role before granting access");
const membership = await affinity.memberships.create(
practice.id,
{
roleId: role.id,
termsVersion: "2026-07-26",
userId: user.id,
},
{ idempotencyKey: `membership:${practice.id}:${user.id}` },
);
const providerMapping = await affinity.providerMappings.create(
{
attestations: {
authorizedProviderRelationship: true,
providerDataAccuracy: true,
},
credentials: "MD",
externalId: "provider_4821",
name: "Jordan Lee",
npi: "1234567893",
practiceId: practice.id,
userId: user.id,
},
{ idempotencyKey: `provider:${practice.id}:${user.id}` },
);
// Save providerMapping.id with this provider in your database. It is a durable identity mapping,
// not an API key and not a browser credential.
if (providerMapping.status !== "verified") {
throw new Error("Complete Affinity provider verification before creating a clinical session");
}
// Revocation immediately invalidates delegated component and hosted access for this mapping.
// await affinity.providerMappings.revoke(providerMapping.id, {
// idempotencyKey: `provider-revoke:${providerMapping.id}`,
// });
const consent = {
authorizedProviderAccess: true as const,
minimumNecessaryPhi: true as const,
recordedAt: new Date().toISOString(),
};
const componentSession = await affinity.componentSessions.create(
{
allowedOrigin: "https://platform.example.com",
components: {
prescriptionComposer: {
enabled: true,
features: {
changePatient: false,
createDraft: true,
sign: true,
viewHistory: true,
},
},
},
consent,
context: {
patientExternalId: "patient_991",
patientSelection: "fixed",
},
membershipId: membership.id,
practiceId: practice.id,
providerMappingId: providerMapping.id,
userId: user.id,
},
{ idempotencyKey: `component:${crypto.randomUUID()}` },
);
// Return this one-time, 10-minute client secret only to the authenticated browser.
// The Affinity Elements SDK sends it to the origin-checked Affinity iframe.
console.log(componentSession.clientSecret);
const hostedSession = await affinity.hostedSessions.create(
{
consent,
flow: "provider_verification",
membershipId: membership.id,
practiceId: practice.id,
providerMappingId: providerMapping.id,
returnUrl: "https://platform.example.com/affinity/return",
userId: user.id,
},
{ idempotencyKey: `hosted:${crypto.randomUUID()}` },
);
// Open this single-use, 15-minute URL in a redirect, popup, or new tab.
console.log(hostedSession.url);API keys and all provisioning calls stay on the platform backend. Component origins and hosted return URLs must exactly match the Test or Live allowlist. Never put a component secret in a URL, log, analytics event, or persistent storage.
Use the request-scoped actor client when your backend creates orders from its own UI. One
orders.create(...) call can group multiple patients into a checkout, but Affinity creates one
independent order per patient. Each order contains 1–20 unsigned prescription drafts and must be
reviewed and signed separately by the authenticated provider represented by the provider mapping.
const actingAffinity = affinity.withActor({ id: authenticatedUser.id, type: "user" });
const [semaglutide] = (await actingAffinity.catalog.list({ query: "semaglutide", limit: 10 })).data;
const [vitaminB12] = (await actingAffinity.catalog.list({ query: "vitamin b12", limit: 10 })).data;
if (!semaglutide || !vitaminB12) throw new Error("Required formulations are unavailable");
const orderBatch = await actingAffinity.orders.create(
{
practiceId: practice.id,
providerMappingId: providerMapping.id,
patientOrders: [
{
patientId: patient.id,
prescriptions: [
{
clinical: {
currentMedications: [],
diagnoses: [{ code: "E66.9", display: "Obesity, unspecified" }],
observations: [],
},
daysSupply: 30,
dispensing: { dispenseUponAcceptance: true, substitutionPermitted: false },
directions: "Inject 0.25 mL subcutaneously once weekly",
medicationId: semaglutide.id,
quantity: 1,
quantityUnit: "mL",
refills: 0,
structuredSig: {
dose: "0.25",
doseUnit: "mL",
frequency: "once weekly",
prn: false,
route: "subcutaneous",
},
},
{
clinical: {
currentMedications: [],
diagnoses: [{ code: "E53.8", display: "Other specified vitamin B deficiency" }],
observations: [],
},
daysSupply: 30,
dispensing: { dispenseUponAcceptance: true, substitutionPermitted: false },
directions: "Inject 1 mL intramuscularly once weekly",
medicationId: vitaminB12.id,
quantity: 4,
quantityUnit: "mL",
refills: 0,
structuredSig: {
dose: "1",
doseUnit: "mL",
frequency: "once weekly",
prn: false,
route: "intramuscular",
},
},
],
},
],
},
{ idempotencyKey: `order-batch:${encounter.id}` },
);
const [order] = orderBatch.orders;
if (!order || order.status !== "requires_provider_signature") {
throw new Error("Unexpected order batch response");
}
const signingSession = await affinity.orderSigningSessions.create(
{
consent,
membershipId: membership.id,
orderId: order.id,
practiceId: practice.id,
providerMappingId: providerMapping.id,
returnUrl: `https://platform.example.com/encounters/${encounter.id}`,
userId: user.id,
},
{ idempotencyKey: `order-signing:${order.id}` },
);
// Redirect or open a popup for the authenticated provider. The URL is single-use and expires.
console.log(signingSession.url);Existing 1.x callers may still pass the former single-patient shape with patientId and
prescriptions; the SDK converts it to a one-item batch and returns that patient order. New code
should use patientOrders and handle the batch response.
The signing session is server-bound to the platform, Test or Live mode, practice, patient, provider, and complete order. The provider reviews every prescription, enters their PIN only inside Affinity, and selects shipping for each prescription before Affinity transmits them.
Create each patient inside its owning practice. Use your stable patient identifier for
externalId.
const actingAffinity = affinity.withActor({ id: authenticatedUser.id, type: "user" });
const patient = await actingAffinity.patients.create(
practice.id,
{
address: {
city: "Los Angeles",
country: "US",
line1: "100 Test Avenue",
postalCode: "90001",
state: "CA",
},
dateOfBirth: "1990-01-01",
externalId: "patient_991",
name: { first: "Demo", last: "Patient" },
phone: "+13135550100",
},
{ idempotencyKey: "patient:patient_991" },
);
// A new patient is intentionally not assumed to have no allergies. Record the result of an
// explicit review before a provider reviews or signs the order.
await actingAffinity.patients.replaceAllergies(
practice.id,
patient.id,
{ allergies: [], reviewStatus: "no_known" },
{ idempotencyKey: `patient-allergies:${patient.id}:no-known` },
);Use reviewStatus: "recorded" with one or more structured allergy entries when the patient has an
allergy or intolerance. Do not infer no_known from an empty import, a missing field, or a newly
created patient. Platform keys may create unsigned drafts while the status is not_reviewed, but
clinical review, signing, and release remain blocked until this explicit step is complete.
const allergyRecord = await actingAffinity.patients.retrieveAllergies(practice.id, patient.id);
console.log(allergyRecord.reviewStatus, allergyRecord.allergies);The client surface is organized around these resources:
account— inspect the authenticated organization and API accesscatalog— search products available through the Affinity networkcompounders— list the compounders available to the authenticated account and modeusers— provision platform-owned user records by stable external IDpractices— create and manage customer practicespatients— create and manage patients and their explicit allergy review inside one practiceroles— list and manage custom practice rolesmemberships— create consent-bound practice role grantsproviderMappings— connect, inspect, and revoke platform identities mapped to independently verified Affinity providerscomponentSessions— create short-lived, one-time, origin-bound Affinity Elements secretshostedSessions— create short-lived, single-use Affinity Hosted workflow URLsorders— create one-patient multi-prescription orders, then list, inspect, cancel, and read eventsorderSigningSessions— create provider-bound Hosted review and signing URLs for complete orderswebhooks— manage endpoints and inspect or replay events
Generated transport classes remain available as an escape hatch, while the Affinity client is the
recommended entry point.
The compounder list is account-scoped. It includes only generally available compounders and approved invite-only relationships for the current mode; it is not Affinity's complete internal partner directory.
API failures use RFC 9457 problem details. The SDK parses them and throws an AffinityError; callers
never need to read or cast response.json(). Branch on the stable lowercase code, and include
requestId when contacting Affinity support. The broader category is useful for shared handling.
import { AffinityError, AffinityRateLimitError } from "@affinity-health/sdk";
try {
await affinity.catalog.list();
} catch (error) {
if (!(error instanceof AffinityError)) throw error;
console.error(error.code, error.requestId, error.message);
if (error instanceof AffinityRateLimitError) {
// The SDK has exhausted its configured bounded retries.
}
}AffinityAuthenticationError, AffinityPermissionError, AffinityInvalidRequestError,
AffinityIdempotencyError, AffinityRateLimitError, AffinityApiError, and
AffinityConnectionError support instanceof narrowing. Every AffinityError also has
statusCode, retryable, the parsed problem, and the original response as an escape hatch.
TanStack Query works with the same Promise API without an adapter or another dependency:
const catalog = useQuery({
queryKey: ["catalog"],
queryFn: () => affinity.catalog.list(),
retry: (_count, error) => error instanceof AffinityError && error.retryable,
});The SDK is server-only because it uses a service API key. If an error crosses from a backend to a
browser, return error.toJSON() instead of serializing the Error, stack, or raw response. Display
the public message for actionable request errors, use a generic message for API and connection
failures, and log the requestId for support. Effect and Result libraries can wrap these Promise
methods in applications that use them, but they are intentionally not SDK dependencies.
Each clinical order belongs to one practice. A platform can list orders across its practices or use
practiceId to scope the operational view and catalog pricing. Commercial billing is outside the
platform API surface.
Affinity API keys are service-account credentials. Use this SDK only in a trusted backend and load keys from server-side secret storage. Do not bundle a key into browser or mobile code.
Requests involving patient, prescription, or fulfillment data may contain protected health information. Integrators are responsible for their own authorization, logging, retention, infrastructure, and compliance controls.
This SDK is generated from Affinity's curated public API document at
/v1/openapi.json, with a maintained resource
facade layered over the generated transport. Releases will be validated against the same contract
before publication to npm.
Regenerate and validate the checked-in client with:
bun run generate
bun run check
bun run pack:dry-run