npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@praxium/sdk

v7.0.141

Published

Official TypeScript SDK for the Praxium platform API

Downloads

1,719

Readme

@praxium/sdk

Official TypeScript SDK for the Praxium platform API. Build tenant websites that display practice data (team, services, FAQ, opening hours) with full locale support.

Learn more on the Praxium developer portal.

Quick Start

npm install @praxium/sdk

Locale selection

Pass nl, en, or ro. The SDK sends the selected locale through Accept-Language and exposes one TypeScript response type per method.

import { createPraxiumClient } from "@praxium/sdk";

const client = createPraxiumClient({
  baseUrl: process.env.PRAXIUM_API_URL!,
  apiKey: process.env.PRAXIUM_API_KEY!,
  locale: "nl",
});

const locations = await client.getLocations();
const location = client.location(locations[0].slug);

const hours = await location.getOpeningHours();
const team = await location.getTeamMembers();
const faq = await location.getFaq();
// FAQ questions are plain strings. FAQ answers contain sanitized rich-text HTML.

Locale-resolved fields such as FAQ text and team custom fields are strings. Fields whose normal API schema is a locale map retain that documented shape.

All methods return data directly or throw a typed PraxiumError on failure (see Error Handling).

Public rich text

FAQ answers, feature text, insurance descriptions, and policy descriptions are strings that contain sanitized rich-text HTML. Render them with a safe allowlist-based rich-text renderer. Do not insert them with raw HTML APIs.

Location parking information, accessibility information, and the global opening-hours note can contain sanitized rich-text HTML or visible plain text, depending on the field configuration. A safe rich-text renderer supports both representations without HTML sniffing. Per-day opening-hours notes and service and service-variant descriptions are always plain text.

Custom Fields

Custom field identifiers are unique inside a field set. Use a qualified selector when your integration depends on a specific field. Fetch the full field when its representation matters:

import {
  getCustomField,
  type CustomField,
  type CustomFieldValueFormat,
} from "@praxium/sdk";

const biography = getCustomField(member, {
  setIdentifier: "staff_profile",
  fieldIdentifier: "public_biography",
});

function unreachableValueFormat(value: never): never {
  throw new Error(`Unsupported custom-field value format: ${value}`);
}

function getBiographyPresentation(field: CustomField | undefined): {
  value: string;
  valueFormat: CustomFieldValueFormat;
} | null {
  if (field?.type !== "LONG_TEXT" || typeof field.value !== "string") {
    return null;
  }

  switch (field.valueFormat) {
    case "RICH_TEXT":
      return { value: field.value, valueFormat: field.valueFormat };
    case "PLAIN_TEXT":
      return { value: field.value, valueFormat: field.valueFormat };
    default:
      return unreachableValueFormat(field.valueFormat);
  }
}

LONG_TEXT fields always include valueFormat: RICH_TEXT contains sanitized HTML and PLAIN_TEXT contains visible text. Branch on this explicit discriminator; never detect rich text by sniffing for HTML tags. Your website must still use a safe rich-text renderer for RICH_TEXT values.

getCustomFieldValue remains a value-only convenience helper. It intentionally discards metadata such as valueFormat, so use getCustomField whenever your application needs to decide how a value is rendered.

More than one resource carries custom fields — team members (getTeamMembers()) and the organization itself (getOrganization()). The same helpers read both:

const organization = await client.getOrganization();

const registrationNumber = getCustomFieldValue<string>(organization, {
  setIdentifier: "organization_profile",
  fieldIdentifier: "registration_number",
});

Identifiers like the ones above are configured per organization, not fixed by the platform — an administrator creates the sets and fields, and can add more at any time. Read them from the response rather than assuming a list, and never hardcode one deep inside shared code.

A field appears in the response only when an administrator has assigned it to the API access profile your key belongs to. An unassigned field is absent, and the helpers return null — so treat every custom field as optional.

Each returned custom field includes definitionId and its owning set identity (id, identifier, locale-resolved label, and valueMode). This lets integrations distinguish fields that intentionally share an identifier across sets.

String-only lookup remains available when an identifier is unique:

const referenceCode = getCustomFieldValue<string>(member, "reference_code");

If multiple sets expose the same identifier, string-only lookup throws AmbiguousCustomFieldError instead of choosing by response order:

import { AmbiguousCustomFieldError } from "@praxium/sdk";

try {
  getCustomFieldValue(member, "public_biography");
} catch (error) {
  if (error instanceof AmbiguousCustomFieldError) {
    console.error(error.candidates);
  }
}

Contact Form

Submit a contact form on behalf of a website visitor for a specific location:

const result = await client.location("amsterdam").submitContactForm({
  name: "Jan de Vries",
  email: "[email protected]",
  phone: "+31612345678",
  subject: "Appointment request",
  message: "I would like to book an appointment.",
  acceptTerms: true, // required — must reflect the visitor's explicit consent
});
// → { success: true, emailStatus: 'sent' }

Locations

Practices expose each location under a stable slug. List the locations with getLocations(), then create a location-scoped client with location(slug):

const locations = await client.getLocations();
// → [{ slug: 'amsterdam', name: 'Amsterdam', kind: 'PHYSICAL',
//      phone: '...', email: '...', fullAddress: '...',
//      openingHours: { schedule: [...], globalNote: null } }, ...]

const amsterdam = client.location("amsterdam");

const hours = await amsterdam.getOpeningHours();
const contact = await amsterdam.getContactDetails();
const team = await amsterdam.getTeamMembers();
const services = await amsterdam.getBookableServices();

Location-specific content requires a location slug. An unknown slug — or one outside your API key's location scope — throws PraxiumNotFoundError (HTTP 404).

Configuration

Your website needs two environment variables to connect to the Praxium platform, plus an optional third for webhook-based cache revalidation:

Required:

| Variable | Purpose | Example | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | | PRAXIUM_API_URL | Your tenant's admin portal URL. The SDK sends API requests to this host. | https://mypractice.admin.praxium.nl | | PRAXIUM_API_KEY | HMAC API key for authentication. Generated in the admin portal under API Profiles. The tenant slug AND the API profile slug are both embedded in the key — no need to configure them separately. | praxium_v1_mypractice_public-team_17..._abc... |

Optional (only if using ISR revalidation webhooks):

| Variable | Purpose | Example | | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | | PRAXIUM_WEBHOOK_SECRET | Shared secret for webhook signature verification. Only needed if you want the platform to notify your site when data changes (team, FAQ, opening hours). See Webhooks. | (32+ character random string) |

# .env.local
PRAXIUM_API_URL="https://mypractice.admin.praxium.nl"
PRAXIUM_API_KEY="praxium_v1_mypractice_public-team_1234567890_abcdef..."
PRAXIUM_WEBHOOK_SECRET="your-webhook-secret-from-admin-portal"  # optional

How tenant routing works: Each tenant has its own admin subdomain ({slug}.admin.praxium.nl). The platform identifies your tenant from both the hostname AND the API key's embedded slug, cross-validating them for security. You don't need to configure the tenant slug separately — it's derived from your API key automatically.

Available Methods

| Method | Description | | ---------------------------------------- | ---------------------------------------------------------- | | getLocations() | Practice locations with address, contact, and weekly hours | | getOrganization() | Organization name and its granted custom fields | | getInsuranceInfo() | Accepted insurance providers | | getFeatures() | Practice features and amenities | | getPaymentMethods() | Accepted payment methods | | getPolicyInfo() | Practice policies | | location(slug).getOpeningHours() | Weekly opening schedule | | location(slug).getTeamMembers() | Staff members with photos and custom fields | | location(slug).getContactDetails() | Contact information | | location(slug).getLocation() | Location name, address and map coordinates | | location(slug).getSocialLinks() | Social media URLs | | location(slug).getFaq() | FAQ grouped by category | | location(slug).getServiceVariants() | Service pricing and variants | | location(slug).getBookableServices() | Services available for online booking | | location(slug).submitContactForm(body) | Submit a contact form |

Error Handling

All methods throw typed errors that you can catch individually:

import { PraxiumNotFoundError, PraxiumAuthError } from "@praxium/sdk";

try {
  const team = await client.location("amsterdam").getTeamMembers();
} catch (error) {
  if (error instanceof PraxiumNotFoundError) {
    // 404 — resource not found
  } else if (error instanceof PraxiumAuthError) {
    // 401 — invalid or expired API key
  }
}

| Error Class | HTTP Status | When | | ------------------------ | ----------- | ------------------------- | | PraxiumAuthError | 401 | Invalid API key | | PraxiumForbiddenError | 403 | Key doesn't match tenant | | PraxiumNotFoundError | 404 | Resource not found | | PraxiumValidationError | 400 | Invalid request data | | PraxiumRateLimitError | 429 | Too many requests | | PraxiumError | Other | Base class for all errors |

AmbiguousCustomFieldError is a local selection error rather than an HTTP error. It is thrown when a string-only custom-field lookup matches more than one set.

Webhooks

When public data changes, the platform sends an HMAC-signed CloudEvent to your registered endpoint. You can use the event type to react to location and staff lifecycle changes, API profile updates, FAQ updates, and other subscribed events.

Common use cases:

| Use Case | What you do on a subscribed resource event | | -------------------- | ----------------------------------------------- | | ISR revalidation | Call revalidatePath() to refresh cached pages | | Search index | Re-index changed entities in your search engine | | CDN cache | Purge cached API responses or assets |

Prerequisites:

  1. Register your webhook endpoint URL in the admin portal (Settings → Webhooks)
  2. Set PRAXIUM_WEBHOOK_SECRET to the shared secret from the admin portal

All webhook handlers include HMAC-SHA256 signature verification, timestamp-based replay protection (5-minute window), and timing-safe comparison.

Public event catalog

Subscriptions select explicit resource actions. The current public catalog is:

| Resource | Actions | | ------------------------------------------------------- | ------------------------------- | | location | created, updated, deleted | | organization | updated | | faq, faq-category | created, updated, deleted | | service, service-category | created, updated, deleted | | skill, insurance-info, feature-item | created, updated, deleted | | payment-method, policy-info, staff, api-profile | created, updated, deleted |

Event types combine the resource and action without a version suffix, for example location.updated and staff.deleted. The exported WebhookEventType, WebhookResourceType, WebhookAction, and corresponding readonly constants are generated from Praxium's committed public event catalog.

Public organization identity changes use organization.updated; transactional email settings do not emit this event.

Wire format

Praxium sends the complete event as a structured JSON CloudEvent. The CloudEvents JSON Event Format requires this envelope to use application/cloudevents+json.

POST /your-endpoint

X-Praxium-Signature: t=1784023200,sha256=<64-character-hex-digest>
Content-Type: application/cloudevents+json

{
  "specversion": "1.0",
  "id": "019f60d2-3c47-7bb1-816f-458b53f520b5",
  "type": "service.updated",
  "source": "urn:praxium:tenant:019f5b62-21fa-7b40-88ee-9be2d71da2a1",
  "subject": "service/019f5c7e-87f6-7449-9138-b0bc38d5bc65",
  "time": "2026-07-14T12:00:00.000Z",
  "data": {
    "resource": {
      "type": "service",
      "id": "019f5c7e-87f6-7449-9138-b0bc38d5bc65"
    }
  }
}

id identifies this event occurrence and remains stable across delivery retries. subject and data.resource identify the changed resource, so consumers can route the event or fetch that resource.

Next.js ISR Revalidation

The SDK provides a ready-made handler for Next.js on-demand revalidation. It verifies the webhook signature and calls revalidatePath() for the affected pages or layouts.

The handler routes only an exact configured CloudEvent type. A structurally valid future or unsubscribed type returns 200 with { revalidated: false, paths: [] } and never widens into other cache targets. Missing or invalid required CloudEvent attributes return 400. The pathMap accepts only known event names and supports string paths plus typed page/layout targets.

Each website owns this dependency map because the same resource can appear on different pages in different tenants. Map page-scoped resources to every page that consumes them. Use a layout target only when the resource is genuinely consumed by that layout; Next.js also invalidates every nested page beneath it.

A typed target names a route pattern, not a rendered URL. Next.js matches type against the page.tsx / layout.tsx file, so dynamic segments stay in brackets: { pattern: "/blog/[slug]", type: "page" } and { pattern: "/[locale]", type: "layout" } are correct, while { pattern: "/blog/hello", type: "layout" } matches no route file and silently invalidates nothingrevalidatePath() neither returns a result nor throws, so the handler still logs 200 and the stale page is only discovered by reading its cache headers. A route with no dynamic segment is already its own pattern ({ pattern: "/blog", type: "layout" } is fine).

Pick the target shape by intent:

| Target | Meaning | | --- | --- | | "/blog/hello" (string) | invalidate exactly this one rendered URL | | { pattern: "/blog/[slug]", type: "page" } | invalidate every URL produced by that page file | | { pattern: "/blog/[slug]", type: "layout" } | invalidate that layout, the page at its own segment, and every page nested beneath it |

// app/api/revalidate/route.ts
import { expandRevalidationResourceMap } from "@praxium/sdk/webhooks";
import { createRevalidationHandler } from "@praxium/sdk/webhooks/next";
import { revalidatePath } from "next/cache";

// One pattern covers every locale: it matches app/[locale]/layout.tsx itself,
// plus every page nested beneath it. Listing "/nl" and "/en" here would match
// no layout file and invalidate nothing.
const locationLayouts = [{ pattern: "/[locale]", type: "layout" as const }];
const faqPages = ["/nl/faq", "/en/faq"];
const teamPages = ["/nl/team", "/en/team"];
const pricingPages = ["/nl", "/en", "/nl/tarieven", "/en/rates"];

export const POST = createRevalidationHandler({
  secret: process.env.PRAXIUM_WEBHOOK_SECRET!,
  revalidatePath,
  pathMap: expandRevalidationResourceMap({
    location: locationLayouts,
    faq: faqPages,
    service: pricingPages,
    staff: teamPages,
  }),
});

Object targets call revalidatePath(path, type); string targets preserve the original revalidatePath(path) behavior. An explicit empty target array acknowledges a subscribed event without invalidating anything. The revalidatePath option is injectable for explicit framework wiring and tests. If omitted, the SDK loads it from next/cache. The canonical request media type is application/cloudevents+json.

expandRevalidationResourceMap() expands each declared resource through the actions in the generated webhook contract. Known undeclared resources are acknowledged with no targets; unknown wire events still fail closed. Sites therefore declare each cache dependency once without maintaining an event list.

The Next.js adapter requires the consuming application to provide Next.js ≥14.

Custom Webhook Handler

For non-Next.js use cases or custom logic, use processWebhook() to verify the signature and extract CloudEvent metadata:

// Example: invalidate a Redis cache on a subscribed resource event
import {
  processWebhook,
  WEBHOOK_SIGNATURE_HEADER,
  WebhookErrorCode,
} from "@praxium/sdk/webhooks";

export async function POST(request: Request) {
  const body = await request.text();

  const result = await processWebhook({
    body,
    signature: request.headers.get(WEBHOOK_SIGNATURE_HEADER)!,
    secret: process.env.PRAXIUM_WEBHOOK_SECRET!,
  });

  if (!result.valid) {
    const status =
      result.code === WebhookErrorCode.INVALID_JSON ||
      result.code === WebhookErrorCode.INVALID_CLOUD_EVENT
        ? 400
        : 401;
    return new Response(result.error, { status });
  }

  console.log({
    occurrenceId: result.eventId,
    subject: result.subject,
    resource: result.resource,
  });

  await redis.del(`cache:${result.eventType}`);

  return new Response("OK");
}

eventId identifies the event occurrence and remains stable across delivery retries. subject and resource identify the changed resource; resource.id is not an occurrence ID.

Contributing

npm run generate   # Regenerate client from OpenAPI spec
npm run build      # Build dist/
npm run test       # Run tests
npm run test:coverage # Run tests with enforced coverage thresholds
npm run test:package  # Verify built public package exports
npm run typecheck  # Type-check without emitting

License

MIT