@praxium/sdk
v7.0.141
Published
Official TypeScript SDK for the Praxium platform API
Downloads
1,719
Maintainers
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 — Installation and usage examples
- Configuration — Environment variables and tenant routing
- Available Methods — All API endpoints
- Error Handling — Typed error classes
- Webhooks — React to resource events (ISR revalidation, cache busting, etc.)
- Contributing — Development commands
Quick Start
npm install @praxium/sdkLocale 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" # optionalHow 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:
- Register your webhook endpoint URL in the admin portal (Settings → Webhooks)
- Set
PRAXIUM_WEBHOOK_SECRETto 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 nothing — revalidatePath() 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