@sherlockhealth/sdk
v2.0.0
Published
Typed TypeScript SDK for the OpenDoc protocol API. Use from any Node, browser, or edge runtime.
Readme
@sherlockhealth/sdk
Typed TypeScript SDK for the OpenDoc protocol API. Use it from any Node, browser, edge runtime (Bun, Deno, Cloudflare Workers).
Note on the package name. This SDK is published as
@sherlockhealth/sdk. The older@opendoc/sdkname on the public npm registry is an unrelated third-party package — not this SDK. Do not install it.
Install
npm install @sherlockhealth/sdk # or pnpm add / yarn addInside this monorepo, consume it as the workspace package instead:
# in your app's package.json dependencies:
# "@sherlockhealth/sdk": "workspace:*"
pnpm add @sherlockhealth/sdk --workspaceQuickstart
1. Browse the catalog (no auth)
import { OpenDocClient } from '@sherlockhealth/sdk';
const client = new OpenDocClient({ baseUrl: 'https://api.opendoc.com' });
const { data } = await client.search({ q: 'knee MRI', geo: 'us-OH', limit: 10 });
for (const m of data) {
console.log(`${m.providerName} @ ${m.scpName}: $${m.cashPriceCents / 100} (${m.confidence})`);
}
// Get the §VIII Sure Price ceiling guarantee.
const { data: quote } = await client.getSurePrice({ hsoSlug: 'knee-mri' });
if (quote) console.log(`Guaranteed max: $${quote.surePriceCents / 100}`);2. Patient grants you a scoped agent token
The patient creates a token via opendoc.com → Settings → Agent Tokens. They paste the raw token (hk_agent_...) into your app config. The token has explicit:
- Permissions (search, book, pay, cancel, ...)
- Data tier (1=basic / 2=clinical / 3=full)
- Spending caps (single transaction max, monthly cap)
- Expiry
- Ecosystem ID (so OpenDoc can mass-revoke if your ecosystem is compromised)
3. Drive a transaction (S0-S8)
const client = new OpenDocClient({
baseUrl: 'https://api.opendoc.com',
agentToken: 'hk_agent_...',
});
// S0 → S1
const { data: intent } = await client.declareIntent({
providerHsoId: 'uuid',
availabilitySlotId: 'uuid',
});
// S1 → S2 (price locked, HSO Instance immutable from here)
await client.authorize(intent.transactionId);
// S2 → S3
await client.acceptTerms(intent.transactionId);
// S3 → S4 atomic commit
await client.commit(intent.transactionId);Prefer one call? client.createBooking({ providerHsoId, availabilitySlotId })
runs the compact S0→S4 path (booking + payment intent).
Preview before committing money: client.simulate(transactionId, 'commit')
returns the exact signed price-lock (priceLock.jws, verifiable offline
against /.well-known/opendoc-protocol/jwks.json) and the max obligation the
real transition would carry — zero side effects. Note the JWS claims are
snake_case (max_obligation_cents, valid_until); the consequence mirror
is camelCase.
Idempotency — automatic
Every side-effecting method (createBooking, declareIntent, authorize,
acceptTerms, commit, cancelBooking) sends an auto-generated UUID
Idempotency-Key HTTP header, so a network-failure retry can never
double-book or double-charge. Replays of an already-committed submit return
the original response with replayed: true.
// default: SDK generates a fresh UUID per call
await client.commit(transactionId);
// bring your own key (e.g. derived from your own job id)
await client.commit(transactionId, 'my-stable-key');
// suppress entirely (the call also becomes non-retryable)
await client.commit(transactionId, false);Set autoIdempotency: false in ClientOptions to disable generation
globally.
Retries — safe by default, configurable off
The SDK retries GETs and idempotency-keyed writes only — never unkeyed
writes — on 408, 429, 5xx, and network errors. Max 3 attempts, exponential
backoff with jitter; a Retry-After header on 429s is honored when present
and the SDK falls back to backoff when it is absent.
const client = new OpenDocClient({
baseUrl: 'https://api.opendoc.com',
retry: false, // opt out entirely
// or tune it:
// retry: { maxAttempts: 5, baseDelayMs: 500, maxDelayMs: 8000 },
});Auto-pagination
Offset-paginated lists have for await iterators driven by each response's
own pagination object (stops on hasMore: false):
for await (const offer of client.iterateOffers({ specialty: 'ORTHOPEDIC_SURGERY' })) {
console.log(offer.hsoTitle, offer.cashPriceCents);
}
// also: iterateProviders, iterateHsos, iterateServices, iterateNpi, iterateMyBookings(iterateMyBookings mirrors a real server quirk: /me/bookings reports no
total and hasMore is a limit heuristic, so a page-aligned result set may
cost one trailing empty request.)
4. Subscribe to events
Webhooks (server-to-server)
const { data: sub } = await client.createEventSubscription({
callbackUrl: 'https://your-app.example.com/webhooks/opendoc',
eventTypes: ['transaction.state_changed', 'transaction.funds_released'],
});
// Save sub.signingSecret — it's shown ONCE.Verify signatures on incoming webhooks. OpenDoc signs the timestamped scheme
x-opendoc-signature: t=<unix>,v1=<hmac_sha256_hex(secret, t + '.' + rawBody)>
(replay-resistant; default tolerance 300s). The legacy bare-hex digest still
rides in x-opendoc-signature-legacy for one deprecation cycle and this
verifier accepts both. Delivery is at-least-once — dedupe by the
x-opendoc-event-id header (also eventId in the body).
import { verifyWebhookSignature } from '@sherlockhealth/sdk/webhooks';
app.post('/webhooks/opendoc', express.raw({ type: 'application/json' }), async (req, res) => {
const ok = await verifyWebhookSignature({
rawBody: req.body.toString('utf-8'),
signatureHeader: req.headers['x-opendoc-signature'],
signingSecret: process.env.OPENDOC_WEBHOOK_SECRET!,
toleranceSeconds: 300,
});
if (!ok) return res.status(401).send('bad signature');
const event = JSON.parse(req.body.toString('utf-8'));
// handle event.eventType...
res.sendStatus(200);
});Or use the bundled middleware:
import { webhookSignatureMiddleware } from '@sherlockhealth/sdk/webhooks';
app.post(
'/webhooks/opendoc',
express.raw({ type: 'application/json' }),
webhookSignatureMiddleware(process.env.OPENDOC_WEBHOOK_SECRET!),
(req, res) => { /* signature already verified */ }
);SSE (browser)
const stream = client.openEventStream();
stream.addEventListener('transaction.state_changed', (e) => {
console.log(JSON.parse(e.data));
});Errors
All errors throw OpenDocError with a stable machine-readable code:
import { OpenDocError } from '@sherlockhealth/sdk';
try {
await client.commit(transactionId);
} catch (e) {
if (e instanceof OpenDocError) {
if (e.code === 'payment_required') {
// exceeded spending limit
} else if (e.code === 'rate_limited') {
// back off
} else if (e.code === 'forbidden') {
// missing consent or permission
console.error(e.message);
}
}
}What this SDK enforces vs what the server enforces
The SDK is a transport. All compliance — HIPAA audit, AKS, consent gating, spending caps, concentration ceiling, the clinical-safety guard on generated prose — lives on the server. The SDK can't be tricked into bypassing them.
The SDK does validate types, surface errors clearly, auto-generate idempotency keys, and retry safely by default (see above — retries are scoped to reads and keyed writes only, and can be turned off). It does not:
- Cache responses (let the caller cache as needed)
- Retry unkeyed writes, ever (an ambiguous failure on an unkeyed write is surfaced to you, not replayed)
- Hide PHI (the server does that)
Coverage highlights
Beyond search/transactions/patient-self, the client covers:
- Attenuated delegation —
mintChildAgentToken()mints a strictly-weaker child token for a specialist sub-agent;getMyAgentToken()returns the calling token's live budget (meta.effectiveRemainingCents— check it before promising to transact). - Clinical routing —
clinicalRoute({ q, state })andgetCareLaneCarePlan({ q })for symptom → specialist routing with deterministic safety intercepts. - The 9M-NPI directory —
listNpi()/getNpiProfile(npi)— the public fallback pivot when a provider isn't onboarded yet;listSpecialties(). - Price Registry —
getRegistryPrices()/getRegistrySummary(): committed (escrow-backed, all-in) prices and labeled estimates in separate fields, never mixed.getPriceBenchmark({ cpt })returns the attributed Medicare PFS anchor ({ data: null }when no row is seeded — render nothing). - Crawl surface —
getProviderSitemapFeed(). - Card on file —
getPaymentMethod()(display fields only; saving or removing a card is a first-person browser act the SDK does not wrap).
Capability discovery
const proto = await client.protocol();
console.log(proto.version);
console.log(proto.tools.length); // every tool the server exposes
console.log(proto.capabilities); // mcp, events, sure_price, etc.
console.log(proto.permissions, proto.consents, proto.error_shape.codes);Compatibility
- Node 19+ (uses WebCrypto for webhook verification — Node ≥ 19 has it built in)
- Bun, Deno, Cloudflare Workers
- Browsers (modern; Web Crypto required)
For Node 16-18 use the crypto module directly via webhookSignatureMiddleware which falls through to Buffer-based handling.
