Start here

Integrate the protected-action flow

One integration protects one consequential action. Your backend registers the action, creates a subject, and asks GrayPass for a decision before the action executes. The browser observes permitted evidence and completes challenges. The service that executes the action verifies the proof.

The SDKs are @graypass/node for your backend and @graypass/browser for the page. In this release they are installed from the repository checkout rather than a public registry:

Shell
npm install ./packages/sdk-node ./packages/sdk-browser

Your backend holds the sk_test_* credential. The browser receives only a short-lived, subject-bound client token. This guide runs against a sandbox environment. The core protected-action flow is exercised by the repository's end-to-end suite.

Register an action from the backend

TypeScript
import { GrayPassClient } from '@graypass/node';

const graypass = new GrayPassClient({
  apiKey: process.env.GRAYPASS_SECRET_KEY!,
  endpoint: 'http://127.0.0.1:8000',
  keysUrl: 'http://127.0.0.1:8000',
  tenantId: process.env.GRAYPASS_TENANT_ID!,
  environmentId: process.env.GRAYPASS_ENVIRONMENT_ID!,
});
const controlPlane = new GrayPassClient({
  apiKey: process.env.GRAYPASS_ADMIN_KEY!,
  endpoint: 'http://127.0.0.1:8000',
  keysUrl: 'http://127.0.0.1:8000',
  tenantId: process.env.GRAYPASS_TENANT_ID!,
  environmentId: process.env.GRAYPASS_ENVIRONMENT_ID!,
});

const action = await controlPlane.registerAction({
  key: 'payout.destination.change',
  name: 'Change payout destination',
  consequence: 'Future payouts go to a different bank account.',
  riskClass: 'high',
  resourceType: 'merchant_account',
  contextSchema: {
    type: 'object',
    required: ['destination_fingerprint'],
    properties: {
      destination_fingerprint: { type: 'string', minLength: 3, maxLength: 64 },
    },
    additionalProperties: false,
  },
  allowedAudiences: ['payments-app'],
});

Use the admin-scoped credential only for action and policy lifecycle changes. Keep the server-scoped credential on the runtime authorization path. The Console owner session can perform the same control-plane setup without issuing an admin key.

Credential scopes are minted on the Console Integrations page by an admin or owner. client mints browser tokens only. server operates subjects, sessions, authorization, outcomes, and proofs. admin additionally registers actions, creates and activates policy versions, reviews holds, and manages evaluations. Every credential is bound to one environment by its mode: a sandbox credential can never read or change the live environment, through the API or through the Console API. A credential can mint credentials no stronger than itself.

The action starts in shadow mode. Live enforcement is blocked by the server until an applicable real-human validation and calibration release is registered. Sandbox enforcement can be used for integration testing.

Create a subject and a browser token

TypeScript
const subject = await graypass.createSubject({
  externalRef: user.id,
  anchor: 'customer_session',
  recoveryPolicy: 're_enroll_with_anchor',
});

const browserCredential = await graypass.createClientToken(subject.id, {
  origin: 'https://merchant.example',
});

The origin must already be registered for the tenant. Declaring an anchor does not mean GrayPass verified it. The Assurance Profile reports anchor validity as unsupported in this release.

Enroll from server-reported coverage

Start an enrollment observer in the browser. Let the person use the product normally, and enable the completion control only when status().enrollment_ready is true:

TypeScript
import { GrayPass } from '@graypass/browser';

const enrollmentObserver = await GrayPass.observe({
  token: window.graypassClientToken,
  subject: window.graypassSubjectId,
  purpose: 'enroll',
  onError: (error) => console.error(error),
});

const status = await enrollmentObserver.status();
if (status.enrollment_ready) {
  await yourBackend.completeEnrollment({
    subject: window.graypassSubjectId,
    session: enrollmentObserver.session,
  });
  await enrollmentObserver.stop();
}

The backend calls the server SDK and then ends the enrollment observer:

TypeScript
await graypass.completeEnrollment(subject.id, enrollmentSessionId);

Do not turn elapsed time or a fixed client-side counter into enrollment success. The canonical server requires accepted windows, active time, and at least one observed modality.

Observe in the browser

HTML
<script type="module">
  import { GrayPass } from '@graypass/browser';

  const observer = await GrayPass.observe({
  token: window.graypassClientToken,
  subject: window.graypassSubjectId,
  purpose: 'observe',
  onEvidence: (ack) => console.log(ack.coverage),
  onError: (error) => console.error(error),
  });
</script>

The browser sends derived feature summaries. It does not send typed characters, clipboard data, screen contents, or raw cursor paths. The session-bound HMAC detects accidental corruption and parties without page execution. It does not prove genuine behavior against compromised first-party JavaScript, XSS, a malicious extension, or a compromised device.

Optional eye tracking

Camera collection is off by default. Offer an explicit enable control and a visible Stop camera control. Declining or stopping the camera supplies no gaze evidence and no camera-related penalty; typing, pointer, and scroll collection can continue.

Create a tracker and pass it to the observer that should receive its summaries:

TypeScript
const eyeTracker = GrayPass.createEyeTracker({
  assetBase: '/gaze/', // Assets on this page's origin, not the API origin.
  video: cameraPreview,
  onUpdate: (state) => renderCameraState(state),
});

const observer = await GrayPass.observe({
  token: window.graypassClientToken,
  subject: window.graypassSubjectId,
  purpose: 'observe',
  eyeTracking: eyeTracker,
});

enableCameraButton.addEventListener('click', async () => {
  try {
    await eyeTracker.start();
    const result = await eyeTracker.calibrate({
      onTarget: (target) => showCalibrationTarget(target),
    });
    if (result.status !== 'ready') showCalibrationRetry();
  } catch (error) {
    showCameraError(error);
  } finally {
    hideCalibrationTarget();
  }
});

stopCameraButton.addEventListener('click', () => eyeTracker.stop());

The UI helpers in this example belong to your application. Place each target at its x and y position in the viewport, normalized from zero to one; phase, index, and total describe calibration progress. Calibration fits on one set of targets and checks predictions on different targets. Inspect the returned status: finishing the sequence can still return needs_calibration. validationError measures local screen-estimation error, not identity accuracy.

Use the tracker during enrollment too if you want later camera comparisons. Pass it to one observer at a time; it can stay running while you end the enrollment observer and start an observation session. A compatible enrolled camera reference is required before camera summaries can contribute experimental supplemental evidence. Camera evidence cannot establish identity or liveness on its own.

Calibration stays in memory. Recalibrate after restarting the camera, or when the tracker requests it because the viewport, head position, or calibration age changed. A hidden page stops the camera; returning requires another explicit start. Stopping an observer does not stop a shared tracker. On pause, exit, or deletion, release the camera before ending the observer:

TypeScript
async function endObservation() {
  eyeTracker.stop();
  await observer.stop();
}

Raw images, eye crops, face geometry, and gaze paths remain local. The observer can submit only coarse dwell, transition, dispersion, and region summaries under webcam-gaze/v1, with actual sampling and calibration quality. Poor or unavailable camera measurements contribute no gaze evidence. Server acknowledgements still determine accepted enrollment coverage.

Host camera assets on your origin

Build the SDK from the checkout, then serve the entire packages/sdk-browser/dist/gaze/ directory at your application's /gaze/ path. Keep worker.js, models/, wasm/, notices, and their directory structure together. Serve JavaScript with a JavaScript MIME type and .wasm files as application/wasm.

Shell
npm --prefix packages/sdk-browser ci
npm --prefix packages/sdk-browser run build

Set assetBase to that local directory if you use a different path. For example, a page on https://merchant.example must load its worker and models from https://merchant.example/gaze/, even when the observer's endpoint is https://api.graypass.org. The SDK rejects a cross-origin assetBase. Camera access requires HTTPS or a browser-supported localhost secure context.

The page's Content Security Policy must permit worker-src 'self', its first-party scripts, and connections to your chosen GrayPass API endpoint. Its Permissions Policy must allow camera=(self); microphone access is not needed. Serve only the worker response with this dedicated policy, which allows its first-party WASM runtime to compile:

HTTP
Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; worker-src 'none'; object-src 'none'; frame-ancestors 'none'; base-uri 'none';

Keep the HTML page's script-src 'self' policy. Do not add a CDN, a blob worker, or general JavaScript unsafe-eval. Your application's web server must set the worker response header; configuring the GrayPass API host does not configure a worker served from your application.

Authorize on the backend

Use a stable business request id for retries. The SDK derives a stable nonce from that id unless you supply one explicitly.

First evaluate the action in shadow mode. A shadow result records the real policy decision but never issues a proof and must never execute the action. After inspecting the shadow trace, a sandbox integration may explicitly enter enforce mode:

TypeScript
const shadow = await graypass.authorize({
  subject: subject.id,
  session: browserSessionId,
  action: action.key,
  resource: { type: 'merchant_account', id: merchant.id },
  context: { destination_fingerprint: destinationFingerprint },
  audience: 'payments-app',
  idempotencyKey: `${request.id}:shadow`,
});
if (shadow.proof !== null) throw new Error('shadow mode returned a proof');
await graypass.reportOutcome(shadow.id, 'not_executed');

await controlPlane.updateAction(action.id, { mode: 'enforce' });

That mode change is available for a sandbox integration. The server rejects the equivalent live change until the scientific release gate passes.

TypeScript
const decision = await graypass.authorize({
  subject: subject.id,
  session: browserSessionId,
  action: action.key,
  resource: { type: 'merchant_account', id: merchant.id },
  context: { destination_fingerprint: destinationFingerprint },
  audience: 'payments-app',
  idempotencyKey: request.id,
});

switch (decision.decision) {
  case 'allow':
    // In enforce mode, verify before executing.
    break;
  case 'challenge':
    return showActionBoundChallenge(decision.challenge);
  case 'hold':
    return queueForReview(decision.id);
  case 'deny':
    return refuse(decision.human_readable);
}

insufficient_evidence is never a final decision. It remains a typed assurance status and reason. The immutable policy translates it into challenge, hold, or deny.

Complete a challenge

The browser receives only a completion receipt:

TypeScript
const receipt = await observer.completeChallenge(challengeId, {
  method: 'passkey',
  confirm: async (details) => {
    return showAndConfirmExactAction({
      action: details.display.action,
      consequence: details.display.consequence,
      resourceFingerprint: details.display.resource_digest,
      contextFingerprint: details.display.context_digest,
      requestingApplication: details.display.requesting_application,
    });
  },
});
await yourBackend.resumeProtectedAction(receipt.authorization);

The backend retrieves the child authorization by id, recomputes the action hash from its own request material, verifies the proof, checks one-time state where required, executes, then reports the outcome.

TypeScript
const child = await graypass.getAuthorization(receipt.authorization);
if (!child.proof?.compact) throw new Error('allow did not include a proof');
const verified = await graypass.verify(child.proof.compact, {
  audience: 'payments-app',
  expectedAction: 'payout.destination.change',
  expectedActionHash: child.action_hash,
});

if (!verified.valid) throw new Error(verified.reason);
await executePayoutChange();
await graypass.reportOutcome(child.id, 'executed');

The sandbox confirmation used by the public Demo is deliberately labeled as a simulation. It is not represented as a passkey.

Receive canonical events by webhook

Register a public HTTPS destination from the Console Integrations page or with an admin-role session against POST /v1/webhooks. Private, loopback, link-local, and unresolvable destinations are refused at registration and again at every delivery attempt. Every registration is bound to an environment: a credential can subscribe only to its own environment, and a signed-in owner chooses sandbox, live, or both. Sandbox decisions are never delivered to a destination bound to live. Each event is signed with the webhook's own secret:

X-GrayPass-Event:         authorization.decided
X-GrayPass-Delivery-Id:   evt_...
X-GrayPass-Timestamp:     unix seconds
X-GrayPass-Signature-V2:  v2=HMAC_SHA256(secret, timestamp.delivery_id.raw_body)

Verify with parseWebhook from @graypass/node against the raw request body. The Console offers four delivery operations, each audited:

Only admin and owner roles can send, rotate, replay, or discard. Every role that can open the Console can inspect delivery state.

Generated from the API contract in contracts/ and the source in docs/. The build fails if this page disagrees with the running API.