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:
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
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
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:
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:
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
<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:
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:
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.
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:
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:
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.
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:
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.
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:
- Send test event: one synchronous, signed
webhook.testdelivery. The response reports the real result. A network failure is parked as a dead letter; a destination refused before the first attempt reports its reason and creates nothing. - Rotate secret: replaces the signing secret once and shows the new value one
time. For the following 24 hours (configurable with
GRAYPASS_WEBHOOK_ROTATION_GRACE_S;0forces an immediate cutover) every delivery, replay, and in-flight retry carries comma-separated signatures under both the new and the outgoing secret - current first - so your endpoint keeps verifying with either secret while you deploy the new one.parseWebhookaccepts a match on any listed signature. Rotating again inside the window replaces the outgoing secret; at most two are ever valid. - Deliveries in flight: events still owned by the durable outbox. Rows leave this list on delivery or when they become dead letters.
- Dead letters: deliveries that exhausted retries. Replay makes one signed attempt and records the outcome; discard drops the event permanently.
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.