GRAYPASS DOC/API-01

Documentation

GrayPass is the behavioral identity layer. Send the SDK, drop in the middleware, and verify users by how they behave instead of what they type. This page covers everything you need to integrate.

Quickstart

Five minutes from npm install to a verified session.

1. Install

npm install @graypass/sdk @graypass/middleware

2. Initialize on the client

import { GrayPassV2 } from "@graypass/sdk";

// init returns a client instance - keep it for the session lifetime
const gp = GrayPassV2.init({
  tokenProvider: async () => {
    const response = await fetch("/graypass/client-token", {
      method: "POST",
      credentials: "include",
    });
    if (!response.ok) throw new Error("client token unavailable");
    return await response.json();
  },
});

const { session_id } = await gp.startSession({ userId: "u_42" });

gp.on("trust_change", ({ confidence, state }) => {
  console.log("trust", confidence, state);
});

// Gate a sensitive action behind trust:
const verdict = await gp.authorize("dashboard.example.com", { minTrust: 0.7 });
if (verdict.authorized) {
  document.cookie = `gp_token=${verdict.token}; Secure; SameSite=Lax`;
}

3. Mint browser credentials on the server

import { clientTokenRoute } from "@graypass/middleware/express";

app.post("/graypass/client-token", express.json(), requireLogin, clientTokenRoute({
  apiKey: process.env.GRAYPASS_SECRET_KEY!,
  getUserId: (req) => req.user.id,
}));

// Response fields match the JSON API:
// { client_token, publishable_key, expires_at, user_id, origin }

The backend derives the subject from its authenticated session and mints a short-lived ct_*. Register the browser app's exact HTTPS origin in the portal first; the middleware forwards that Origin for token binding but never auto-registers it. The SDK renews the token on each request and retries once after a 401. The sk_* never reaches the browser.

3a. One-tag alternative

<script src="https://api.graypass.org/gp/graypass.js"
        data-pk="pk_live_…"
        data-token-url="/graypass/client-token"
        data-user-id="USER_ID_FROM_YOUR_SESSION"
        data-consent="popup"
        defer></script>

data-pk binds the tag to the keypair echoed by the token endpoint; it also verifies that the returned origin equals window.location.origin. The loader does not use data-pk as an API bearer credential.

4. Verify authorization on the server

// express
import { graypass, requireGrayPass } from "@graypass/middleware/express";
const graypassConfig = {
  apiKey: process.env.GRAYPASS_SECRET_KEY!,
  target: "dashboard",
  minTrust: 0.7,
};
app.use(graypass(graypassConfig));

app.get("/dashboard", requireGrayPass(graypassConfig), (req, res) => {
  // requireGrayPass already returned 401 if req.graypass.valid was false
  // or trustAtIssue was below 0.7
  res.render("dashboard", { user: req.graypass.userId });
});

4a. Or in Next.js

// middleware.ts
import { withGrayPass } from "@graypass/middleware/nextjs";

export default withGrayPass({
  apiKey: process.env.GRAYPASS_SECRET_KEY!,
  minTrust: 0.4,
  target: "dashboard",
  stepUpRedirect: "/step-up",
  protectedPaths: ["/dashboard", "/admin"],
});

export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] };

Auth and keys

Keep authentication, public identification, and signing separate.

API keypairs issue from the self-service flow. Mint ct_* only from an authenticated backend, and store each whsec_* when a webhook is created or rotated because list/read responses never reveal it.

API endpoint index

POST/v2/sessions/start begin a continuous session (billable)
POST/v2/client-tokens mint a user- and registered-origin-bound browser credential (server sk_* only)
POST/v2/sessions/{id}/frames submit one feature frame (free)
POST/v2/sessions/{id}/end end and summarize
GET/v2/sessions/{id}/telemetry live trust state
POST/v2/sessions/{id}/telemetry/ticket issue a stream ticket
GET/v2/sessions/{id}/telemetry/stream SSE telemetry (with ticket)
POST/v2/sessions/{id}/authorize issue zero-friction token (billable on success)
POST/v2/verify verify a token server-side
POST/v2/enroll save behavioral template
POST/v2/templates/rotate cancelable-template re-issue
GET/v2/policy read tenant policy
PUT/v2/policy update policy
GET/api/billing/plan current plan + usage + projection
GET/api/billing/usage per-event call breakdown
POST/api/billing/payg/enable turn on metered billing + add card

Sessions

Start a session

POST /v2/sessions/start
Authorization: Bearer ct_…
Content-Type: application/json

{ "user_id": "u_42" }

→ 200
{
  "session_id": "sess_…",
  "user_id":    "u_42",
  "enrolled":   true,
  "trust":      0.5,
  "state":      "initializing",
  "ws_url":     null,
  "created_at": "2026-05-16T20:00:00Z"
}

Submit a feature frame

Send a 15-second nested feature frame. The SDK builds these for you.

POST /v2/sessions/{id}/frames
Authorization: Bearer ct_…
Content-Type: application/json

{
  "frame_id": "f_abc",
  "timestamp": 1748382937.142,
  "duration_ms": 15000,
  "features": {
    "keystroke": { "dwell_mean": 86, "flight_mean": 102, ... },
    "pointer":   { "speed_mean": 0.42, ... },
    "scroll":    { "delta_mean": -34, ... },
    "focus":     { "tab_switch_count": 0, ... },
    "gaze":      { "fixation_rate": 3.1, ... }
  }
}

→ 200
{ "trust": 0.91, "state": "stable", "raw_scores": {...} }

End a session

POST /v2/sessions/{id}/end
→ { "session_id": "sess_…", "duration_s": 142.5, "final_trust": 0.94 }

Authorization

Issue a zero-friction token

POST /v2/sessions/{id}/authorize
Idempotency-Key: f4c2-… (optional; ≤ 128 chars)
{ "target": "settings.example.com", "min_trust": 0.7, "ttl_seconds": 300 }

→ { "authorized": true, "token": "<payload_b64url>.<hmac_b64url>", "trust": 0.92 }

ttl_seconds must be between 10 and 3600 (1 hour). Tokens are HMAC-signed and tenant-scoped. Format is two base64url segments joined by a dot - not a JWT. Verify on the receiving service with POST /v2/verify. Same-tenant only. Successful authorize calls are billable (see Billing).

Sending Idempotency-Key deduplicates retries: the first call within 60s computes the verdict and caches it, subsequent calls return the cached response. A second in-flight call with the same key gets 409 idempotency_in_progress.

Verify a token

POST /v2/verify
{ "token": "<payload_b64url>.<hmac_b64url>", "target": "settings.example.com" }

→ { "valid": true, "user_id": "<tenant_id>:u_42", "trust_at_issue": 0.92 }

user_id is the tenant-scoped form (tenant_id : the value you passed to /v2/sessions/start). The Express + Next.js middleware expose this on req.graypass.valid and req.graypass.userId. Tokens are one-time-use - second verify of the same token returns valid: false.

Enrollment

Submit at least five distinct SDK frames with purpose: "enroll" before completing enrollment. A frame counts only when its per-session HMAC signature is valid and PAD, replay, and non-finite checks are clean.

POST /v2/enroll
Authorization: Bearer ct_…
{ "user_id": "u_42", "session_id": "sess_…" }

→ { "enrolled": true, "user_id": "u_42", "template_id": "tmpl_…", "message": "enrolled_from_session" }

Rotate a template (cancelable biometrics)

POST /v2/templates/rotate
{ "user_id": "u_42" }

→ { "ok": true, "template_id": "tmpl_…", "template_hash": "…", "transform_id": "…", "transform_version": 1, "message": "V2 template rotated successfully" }

If a template ever leaks, rotate the seed. The user's identity stays; the projected template behind it is replaced in milliseconds. The next session re-enrolls from live behavior under the new seed.

Policy

GET /v2/policy
PUT /v2/policy {
  "mode": "enforced",
  "rules": [
    { "state": "stable",    "action": "allow"   },
    { "state": "uncertain", "action": "observe" },
    { "state": "degraded",  "action": "step_up", "grace_period_s": 30 },
    { "state": "critical",  "action": "kill"    }
  ]
}

mode is shadow (observe only, no enforcement) or enforced (actual actions taken). Rules map a trust state to a recommended action. Optional grace_period_s, min_trust, max_trust, and webhook_url per rule. A policy webhook URL must also be an active registration under Webhooks so it can use that registration's disclosed signing secret.

Webhooks

Configure a public HTTPS callback URL and event types in the portal. We POST signed events for trust.state_change, trust.degraded, trust.critical, and trust.recovered. Creation returns a unique whsec_... secret exactly once; rotation replaces it and also shows the new value once. List responses never contain secret material. Keep this separate from your sk_... API key.

X-GrayPass-Signature-V2: v2=<HMAC-SHA256>
X-GrayPass-Timestamp: <unix-seconds>
X-GrayPass-Delivery-Id: evt_...
X-GrayPass-Signature: sha256=<legacy-body-only-HMAC>

The v2 HMAC covers timestamp + "." + delivery_id + "." + exact_raw_body_bytes. Use the raw request bytes and reject stale timestamps. The middleware's parseWebhook does this by default when v2 headers are supplied. The body-only header remains temporarily for old consumers.

Failed deliveries retry with exponential backoff and full jitter, then land in the webhook queue for manual replay.

Telemetry stream

Polling GET /v2/sessions/{id}/telemetry with the secret key works for low-frequency dashboards. For real-time UIs (live trust gauge), use the SSE stream - it requires a short-lived ticket so you never have to leak the secret key into a URL.

POST /v2/sessions/{id}/telemetry/ticket
Authorization: Bearer sk_…
→ { "ticket": "tkt_…", "expires_at": "2026-05-16T20:01:00Z", "session_id": "sess_…" }

GET /v2/sessions/{id}/telemetry/stream?ticket=tkt_…
→ text/event-stream
data: { "trust": 0.94, "state": "stable", "frame_count": 7, ... }
data: { "trust": 0.91, "state": "stable", ... }

Streams are capped per-tenant per process (GRAYPASS_SSE_MAX_PER_TENANT, default 50). Over the cap returns 429. Clients should reconnect with the same ticket on transient disconnect.

Errors

Limits

Billing

GrayPass bills per call. One billable call is one successful POST /v2/sessions/start or one successful POST /v2/sessions/{id}/authorize (where authorized: true). Frames, verify, enroll, telemetry, and webhooks are free — send as many as your scoring needs.

Free tier — no card required

Pay-as-you-go — add a card to remove the cap

Graduated tiers, applied per call. The price drops as your volume rises.

Commit plans — predictable monthly + discount

Billing API

GET /api/billing/plan
→ {
  "billing_mode": "payg",
  "billable_calls": 4218,
  "included_calls": 1000,
  "projected_bill_usd": 25.74,
  "payment_method_on_file": true,
  "days_remaining_in_cycle": 14,
  "cycle_end_ts": 1748908799.0,
  "payg_tier_breakdown": [...]
}

GET /api/billing/usage
→ {
  "calls_this_cycle": 4218,
  "by_event": { "session_start": 1830, "authorize": 2388 },
  "projected_bill_usd": 25.74
}

POST /api/billing/payg/enable        // returns SetupIntent client_secret
POST /api/billing/checkout           // commit plan upgrade (Builder/Scale)
POST /api/billing/portal-link        // Stripe customer portal

Privacy

We never store the raw events you collect. The SDK reduces them to a 54-dimensional summary on the device, ships only the vector, and the backend persists templates, never traces. The OPRF protocol means the server never sees the unblinded behavioral vector either — templates are protected against server compromise. See privacy policy and security on graypass.org.