Skip to main content
@phosra/link is the provider-side SDK for Phosra Link. The recommended integration is a server-only v3 credential, one same-origin catch-all route, one durable worker, and the mandatory-branded React component.
A PHOSRA_CREDENTIAL is operator-issued. You cannot mint one yourself. The production manifest, trust material, client-directory discovery, and the SDK itself are live and public — but credential issuance runs through an approved Phosra operator workflow in production, and the self-serve lane that exists on one internal census is not an integration target yet (see Local development). To request one, email developers@phosra.com with your census DID, the exact origin your app is served from, and the display name parents should see. Background: what you cannot self-serve yet.
Do not pin an older minor. The Golden APIs on this page (createGoldenLinkServer, and PhosraLink from @phosra/link/react) live behind the ./server and ./react subpaths, which only exist in the current generation. Installing @phosra/link@0.6.1 succeeds and then fails at import with ERR_PACKAGE_PATH_NOT_EXPORTED.
@phosra/link is ESM-only. The Golden server is Node-only and must be imported from @phosra/link/server. Never import credentials or server APIs into a client component.

Family setup vNext

The additive family setup surface handles one destination account with zero or many provider-authorized children. It supports child-, family-, platform-, repair-, and preconnection entry points; existing and shared destination profiles; reviewed profile creation; policy translation notices; partial background completion; and signed provider events. It does not turn Phosra Link into a parent dashboard. Compose the durable runtime in the parental-controls application’s server process:
Mount familyLink.handler on the same origin as the parent-facing component for all methods under /api/phosra/link/vnext/sessions. Run familyLink.runWorker() in one long-running worker process. The handler creates a session only after your authenticate adapter identifies the parent and your children adapter proves the bounded child set. The server returns a short-lived, one-time pfl1 token to your own authenticated launch route. Give that token to the mandatory-branded React flow in memory:
Bootstrap consumes the one-time token and establishes a Secure, HttpOnly, SameSite=Strict continuation cookie bound to that exact family session. The browser SDK sends same-origin credentials on every later request. Do not host the component on a different origin, put raw parent or child authority in browser state, or weaken the cookie to make a cross-site iframe work. For iOS and Android, present the same provider-owned route inside your existing authenticated app shell. Transfer the one-time token through native-to-page in-memory state or a transient URL fragment that the page immediately removes; never place it in a query parameter that reaches server logs. Route the flow’s terminal callback back to the parental-controls app. The parent starts and ends in your app, while the server runtime and durable worker remain identical to web. The interactive reference at phosra.com/prototype/phosra-link uses simulated Custo and Notflix data, is excluded from search indexing, and is for evaluating entry points, mapping, review, recovery, and responsive polish—not for creating a real connection. Your application owns only its existing parent authentication, child lookup, and policy facts. Link owns environment discovery, trust verification, signed Intent V2 creation, public consent copy, OAuth handoff, durable delivery, retries, and evidence-based status.
Do not await createGoldenLinkServer(...) at module scope. Before it resolves, the factory fetches and verifies the signed environment manifest over the network and applies the package-owned schema to your database. Under ESM, a module whose top-level evaluation throws is permanently marked errored: every later import re-throws the same error, so one census blip or a cold database at boot leaves the route answering 500 until the process is redeployed. Your framework also evaluates the route module at build time. Memoise the promise and clear it on rejection, as above.Two consequences worth planning for: the census named by your credential must be reachable at boot, and the database role the server runs as needs DDL rights unless you pre-migrate — see Schema and upgrades.
Next.js needs no transport plumbing — on 0.7.59 or later. Next patches globalThis.fetch, and on its dynamic path (every fetch in next dev) it hands back a reconstructed Response whose intrinsic URL slot is empty. Before 0.7.59 the SDK read that slot to bind a signed document to the URL it requested, so it refused its own reads: createGoldenLinkServer(...) threw GoldenEnvironmentBootstrapError: ENVIRONMENT_PRESET_INVALID for every call made from inside a render or route handler, while the same composition worked in a plain Node worker — so ceremonies completed and rules were enforced, but the app could never read its own connection state.If you see that on an older minor, upgrade rather than reaching for a globalThis.fetch workaround. 0.7.59 compares against the URL the SDK already knows it asked for, so a stock composition — a plain import and one createGoldenLinkServer(...) call, exactly as above — is all that is required.
Import that composition from the catch-all route. The route contains no credential or environment configuration of its own:
This path is fixed — it is not your choice. The credential pins the callback at {application.origin}/api/phosra/link/callback, and the handler derives its /api/phosra/link/sessions route from that same constant. Mount the catch-all anywhere else and every request answers 404 ROUTE_NOT_FOUND, with nothing in the body to tell you why. The catch-all is important. It serves the callback, session creation and status, retry/cancel, and signed platform-event receiver under /api/phosra/link/*. Mounting only the callback breaks recovery and prevents later platform evidence from reaching the provider.

children.resolve must return the census child id

id is the child’s census identity — a UUID the census issued — not your own primary key. The SDK accepts a bare UUID or an already-prefixed child:<uuid> and normalises both to child:<uuid>; anything else is refused. Store the census id alongside your own child record (the reference provider keeps a dedicated census_child_ref column) and return that column here. Your local id is fine as the childRef your UI passes in — it just must not cross the SDK boundary as id, because that value becomes the grant target_ref and the consent target. If your children do not exist on the census yet, provision them first — the Link ceremony cannot mint one for you. POST /setup/quick returns a family, a child, and an active policy in one call; its child.id is the value this adapter must return. See the Quickstart. The last row is the reason to wire onDiagnostic before your first ceremony: the HTTP body carries LINK_OPERATION_FAILED and nothing else, and the silent case carries nothing at all until it fails downstream.

policy.resolve returns exactly one content_rating rule

The installed rule-copy edition supports one policy category. Return an array holding exactly one enabled rule in this exact shape — no extra keys, no params wrapper, no label or description (the SDK owns all public copy, so an application cannot make a granted category sound weaker than it is):
Return null when this parent/child/platform combination has no policy: the handler answers 404 PLATFORM_NOT_AVAILABLE, which is the honest outcome, not a fault. Anything else — a second rule, a duplicate category, an unknown family or scale, a max_allowed outside 0–21, an extra property, a params wrapper — throws LinkPolicyError inside the SDK and surfaces as a 502 with the diagnostic subcode START_RESOLVE_PLATFORM_FAILED, because the policy adapter is called while the platform is being resolved. If your product stores per-board ceilings (MPAA, TV Parental Guidelines, ESRB, PEGI), convert them to a single age before you return: take the maximum age-equivalent across the boards the parent has already chosen, so the ceiling you send is the one they actually set rather than an invented stricter one.

The authenticated householdRef boundary

householdRef is your stable, provider-local family authority. Return it only after authenticating the parent and proving that the child belongs to that parent. The SDK derives a platform-scoped digest for signed Intent V2. The raw value is not a browser input and is not sent to the platform. Never use a display name, email address, or client-supplied family ID as householdRef.

Your adapters must be deterministic for the life of a ceremony

children.resolve and policy.resolve are called again on the OAuth callback, and the SDK asserts that the authority it journaled when the session was created still matches. A change to id, ageBand, the derived householdRef digest, or the resolved policy scope between start and finish rejects the finish.
The most common way to trip this is deriving ageBand from a birth date: a child whose birthday crosses a band boundary mid-ceremony resolves differently at the callback than at grant time. The rejection is correct — consent gathered for an under-13 child no longer describes a 13-year-old — but it lands late, after the parent has already completed the platform’s OAuth.The thrown error is not exported from @phosra/link or @phosra/link/server. Detect it by name and message:
The same error is thrown for granted-scope and state-digest mismatches, so copy derived from it must not claim the child’s age changed. Offer a “start the connection again” affordance — not a retry, which cannot succeed.
An occupied same-family platform profile does not add a provider API or React state for the host to implement. The Golden Gatekeeper authorize handler recommends a separate profile and presents Choose another profile, Confirm sharing, and Cancel and return in that order. It explains that the strictest combined protections apply and that shared activity cannot be reliably attributed to one child. Before token exchange, the handler displays only the authenticated platform catalog label recovered from its server-side sealed catalog and the verified same-family member count. The signed selected-profile presentation follows token exchange. It does not expose raw platform IDs, family/child authority, target or aggregate identifiers, or policy parameters; forms carry only bounded opaque tokens. Names and numeric ages of existing members require a future provider-signed member-display resolver and are not claimed by this release.

Run the durable worker

Run exactly one worker loop in a worker process and abort it during graceful shutdown. This is a standalone process, so import by relative path — the @/ alias your framework resolves does not exist for plain node:
Launch it with a TypeScript loader, or compile it first:
Top-level await is fine here: this module is a process entry point, so a failed bootstrap exits and your supervisor restarts it. The hazard described above is a shared module that your routes import. The worker serializes delivery and retry work. A successful HTTP response means a verified receiver acknowledgement, not that every control is applied. Parent status advances only when signed platform evidence supports it.
The verified environment expires; a long-lived process must be able to rebuild it. The server captures the signed environment manifest once, at construction, and every trust-bearing operation re-checks it against the wall clock. Nothing inside the server re-fetches it. The manifests in use today carry a 30-day validity window (the live production manifest is issued and expires exactly 30 days apart), so a container that outlives its captured manifest starts refusing Link operations with phosra/link-server: durable Trust List authority conflict.The memoised factory above is what makes recovery possible: drop the cached promise (or restart the process) and the next call rebuilds against a fresh manifest. A module-scope singleton has no way back.

Render the parent experience

PhosraLinkSessionStartError and refusalCode are ten lines you write once — Preserve the refusal code below has both. Forward the supplied signal. Link cancels abandoned session creation, supports restart-safe same-tab recovery, polls bounded server-authoritative status, and never turns a delivery acknowledgement into a fake success state. PhosraLinkDialog remains an alias for source compatibility. New code should use the shorter PhosraLink export. Shared-profile confirmation preserves the same createSession, onEvent, and onExit behavior. Do not wrap the component in a second consent modal.

Preserve the refusal code

A refused session start answers with a code-bearing body — {"error":{"code":"ORIGIN_REJECTED"}} for the most common permanent fault. throw new Error("Unable to start") throws that away, and the dialog can then only offer a generic retry for something retrying cannot fix. Read the code defensively and carry it on a typed error:
ORIGIN_REJECTED is permanent configuration, never transient — see ORIGIN_REJECTED for the parent-facing copy and the fix.

What onExit and onEvent mean

onExit receives a reason, and only two of the six mean a connection exists: If you ignore the argument and only setOpen(false), the parent completes the flow, the dialog closes, and your page still renders Connect — because nothing revalidated. onEvent receives opened, handoff_started, status_changed, linked, completed, cancelled, expired, and error. Status events are observational and can be frequent; treat linked and completed as the only ones that earn a host refresh. The error event carries a code of START_FAILED, STATUS_FAILED, STATUS_TIMEOUT, CANCEL_UNCONFIRMED, or RETRY_UNCONFIRMED.
Neither onEvent nor onExit explains why a ceremony failed — by design, both are browser-facing and content-free. The server-side onDiagnostic hook is the only channel that carries the cause: an operation, a subcode (START_ORIGIN_REJECTED, START_RESOLVE_CHILD_FAILED, CALLBACK_RESOLVE_PLATFORM_FAILED, …), a causeClass, a retryable flag, and a correlationId. It is deliberately free of messages, stack traces, URLs, identifiers, and user values, so it is safe to log verbatim.

Server API

Grant reads and disconnects require the exact app-authorized tuple { grantId, platformDid, targetRef }. Do not accept that tuple directly from an untrusted browser without re-authorizing it against your own tenant boundary.

Recover the grant tuple

GoldenLinkServer has no grant-enumeration method, and the ceremony does not hand the tuple back to your UI. Read it from the package’s own store, then re-authorize it against your tenant before you pass it to any operation:
listGrantsByChild is scoped by target, so the filter above is what binds the grant to the parent your own session already proved. Reject anything that does not match exactly one active row.

Let a parent resume or start over

A connection that stalls part-way through is the normal case a first integration forgets, and the result is a dead Connect button forever. Give the parent both affordances:
restartIncompleteGrant never touches a confirmed connection, so it is safe to expose behind a Start over control. Label the pair with plain copy — Resume and Start over — and keep the last verified state visible while either runs.

Let a parent refresh an existing connection

When a platform reports degraded, stale, or a previously applied result that needs to be checked again, do not create another Link ceremony. The same method also resumes an exhausted profile refresh after disconnectGrant() so the platform can fetch the signed revocation tombstone and finish its release. Re-authorize the child and exact active or disconnect-pending grant in your own database, then pass only that exact tuple to the SDK:
The method records a signed retry request in the provider outbox. The existing runWorker() process delivers it, and Gatekeeper independently checks current grant authority before reopening only a failed degraded or stale profile lifecycle. A signed revocation request cannot reopen healthy or unrelated authority. A healthy shared-profile aggregate and its other child memberships remain intact. Keep the last verified state visible while the refresh runs; label the parent action with plain copy such as Sync now or Check disconnect. If the platform is unreachable for the request’s five-minute validity window, the request becomes immutable expired evidence. A later authorized parent check mints the next signed retry epoch; repeated checks while a request is still active reuse that exact request rather than creating parallel work.

Schema and upgrades

The package owns its own provider tables and ships the complete SQL inside the npm tarball. ready() — which the Golden factory calls for you — applies that SQL, which means your runtime database role needs DDL rights unless you apply it out of band. Apply it as a deploy step instead, with a role that has database authority, and the runtime role can drop CREATE:
This step needs only DATABASE_URL. It does not read PHOSRA_CREDENTIAL or touch the census, Trust List, or delivery configuration. Run exactly one migration owner — never the web process and the worker concurrently.
Re-run it on every SDK upgrade. The schema moves with the package: the shipped SQL both creates tables and adds columns to tables you already have. If you pre-migrated once and dropped DDL rights from the runtime role, an upgraded SDK boots straight into SchemaNotReadyError: link/server-schema: could not provision the complete provider schema: …. Make migrateLinkServerSchema part of the same deploy command as your own migrations so the two can never drift.printLinkServerSchema() and LINK_SERVER_SCHEMA_SQL expose the same SQL if you prefer to review or check it into your own migration tool.

Local development

Your credential pins the exact origin your app is served from. A v3 credential carries application.origin, and the SDK enforces it on every mutation: a request whose Origin header does not match is refused 403 ORIGIN_REJECTED before any connection claim is made. This is deliberate — it is what stops another site from starting Link ceremonies against your provider — but it means a production credential cannot be used from http://localhost. Production credentials are HTTPS-origin-only, and the production census’s signed manifest publishes allow_http_loopback: false.
If Link fails the moment a parent clicks Connect, and your server log shows a 403 on POST /api/phosra/link/sessions, this is almost always the cause. The parent-facing dialog says the app is not set up for this address; your onDiagnostic hook receives subcode START_ORIGIN_REJECTED. Compare the application.origin inside your credential with the origin your app is actually served from — not with the one you intended to deploy to.

Sandbox credentials, and where they come from today

Sandbox credentials are Phosra Link’s test keys. They are the same v3 envelope shape and differ from production credentials in exactly two ways that matter here: The loopback allowance is not a special case in the SDK — it is published in the sandbox census’s signed environment manifest as redirect_policy.allow_http_loopback, with an exact host list (127.0.0.1, localhost), and the SDK admits an http origin only when the credential says sandbox and the verified manifest allows that hostname.
You cannot mint a sandbox Link credential yourself today, and the partner sandbox cannot back one. Verified live on 2026-07-27:
  • phosra-api-sandbox-production.up.railway.app — the partner sandbox every other page targets — serves no environment manifest (/.well-known/phosra/environment-manifest-v1404) and exposes no Link credential route. Nothing can bootstrap a Link credential against it.
  • phosra-api-sandbox-staging.up.railway.app is the only census publishing a manifest with allow_http_loopback: true, and the only one carrying the self-serve credential lane. It is Phosra-internal — not an integration target, and its own docs say so (see the two sandbox hosts).
So a local-development credential is issued to you, bound to that census, by Phosra: email developers@phosra.com with the exact loopback origin you want (http://localhost:3005, not just localhost). This section documents what such a credential does once you hold one; it is not a lane you can open on your own yet.
Once you have one, nothing in your code changes:
The credential itself names the census, the trust root, and the manifest it must verify against, so there is no separate “sandbox mode” flag to set and no way to accidentally mix a sandbox credential with production data — the environment labels must match on both sides or the SDK fails closed. Note that this makes your Link census a property of the credential: it is not necessarily the sandbox host in the curl examples elsewhere in these docs.
Cookies ignore the port. Every app you run on http://localhost shares one cookie jar, so two apps that use the same session-cookie name evict each other’s sessions. We hit this running a provider and a platform side by side in local development: both used the WorkOS AuthKit default cookie name wos-session, so signing in at the platform silently logged the parent out of the provider, and the Link callback came back 401 UNAUTHENTICATED with no clue as to why.Give every app its own cookie name before you run two of them together:
Any session library with a configurable cookie name has the same failure mode.

Ports and the callback path

The origin is an origin: scheme, host, and port, with no path. If your dev server runs on port 3005, the credential must say http://localhost:3005; http://localhost (port 80) is a different origin and will be rejected. A provider credential also derives its callback as {origin}/api/phosra/link/callback, which must be the catch-all route you already mounted — so a provider’s client registration for local development lists http://localhost:3005/api/phosra/link/callback exactly. If you change dev ports, request a new sandbox credential. There is no way to edit an issued one: the origin is inside the signed envelope.

When you are ready for real families

Swap PHOSRA_CREDENTIAL for your production credential and deploy to the HTTPS origin it names. No code changes — the same createGoldenLinkServer call reads whichever credential is present, and the credential decides which census, trust root, and environment it belongs to.

Evidence states are intentionally different

  • bound means a durable connection exists.
  • rule_recorded means requested controls were recorded, not applied.
  • profile_ready means the platform has a verified profile and results are being checked.
  • applied with E4 means every requested control has current concrete platform evidence.
  • degraded, refused, and stale remain visible; the SDK does not paint them green.
  • revoked means the provider authority is revoked. Platform removal is complete only after the signed release lifecycle confirms it.

Advanced compatibility API

createLink(...) and the low-level ceremony functions remain available for older writer-plane integrations. They are not the recommended durability boundary for a new parent application. New integrations should begin with createGoldenLinkServer(...) and PhosraLink. See the Provider quickstart, branding contract, and disconnect guide.