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({ apiKey: "pk_test_…" });
const { sessionId } = await gp.startSession({ userId: "u_42" });
gp.on("trust_change", ({ trust, state }) => {
console.log("trust", trust, 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. Verify on the server
// express
import { graypass, requireGrayPass } from "@graypass/middleware/express";
app.use(graypass({ apiKey: process.env.GRAYPASS_SECRET_KEY! }));
app.get("/dashboard", requireGrayPass({ minTrust: 0.7 }), (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 });
});
3a. 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
Two key types per tenant.
pk_test_…/pk_live_…— public key, safe to ship in client SDKs.sk_test_…/sk_live_…— secret key, server only. Never commit, never embed.
Both types issue from the same self-service flow. Rotate either at any time from the portal.
API endpoint index
Sessions
Start a session
POST /v2/sessions/start
Authorization: Bearer pk_…
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 pk_…
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
POST /v2/enroll
{ "user_id": "u_42", "session_id": "sess_…" }
→ { "enrolled": true, "template_id": "tmpl_…" }
Rotate a template (cancelable biometrics)
POST /v2/templates/rotate
{ "user_id": "u_42" }
→ { "rotated": true, "template_id": "tmpl_…" }
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.
Webhooks
Configure a callback URL in the portal. We POST signed events for
trust.state_change, trust.degraded,
trust.critical, and trust.recovered.
Verify the X-GrayPass-Signature header with the secret
configured on that webhook.
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_in": 60 }
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
400bad request. Schema validation failed.401unauthorized. Missing or invalid key.402payment required. Free-tier call quota exhausted, or subscription canceled / past-due. Body includes adetailwith the reason and the portal URL to add a card.403forbidden. Wrong tenant, revoked key, or scope-restricted (pk_*key on mutating route whenGRAYPASS_REQUIRE_KEY_SCOPE=1).404not found.409conflict. Used by/authorizewhen anIdempotency-Keyis in-flight or by template rotation race.413payload too large. Body over the configured cap (default 256 KiB).429rate limited. SeeX-RateLimit-Reset. Also returned by SSE telemetry stream when the per-tenant cap is hit.503service unavailable. Backing store unreachable, or webhook event still claimed by another worker.
Limits
- Rate limit: 1,000 req/h on test keys; 10,000 req/h on live keys; higher on enterprise. Set per-tenant in the portal.
- Request body: ≤ 256 KiB by default (
GRAYPASS_MAX_BODY_BYTES). Returns 413 above the cap. - Token
ttl_seconds: 10 seconds minimum, 3,600 seconds (1 hour) maximum. Idempotency-Key: ≤ 128 characters, cached for 60s after first verdict.- SSE telemetry streams: 50 concurrent per tenant per process (
GRAYPASS_SSE_MAX_PER_TENANT).
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
- 1,000 billable calls per calendar month.
- Call 1,001 returns
402 Call quota exhausted (free_tier_exhausted)until you add a card or the next cycle starts. - Test keys ship with this by default. No upfront commitment.
Pay-as-you-go — add a card to remove the cap
Graduated tiers, applied per call. The price drops as your volume rises.
- 0–1,000 calls/mo — free
- 1,001–10,000 — $0.0120 / call
- 10,001–100,000 — $0.0060 / call
- 100,001–1,000,000 — $0.0025 / call
- 1,000,001+ — $0.0012 / call
- Prepaid credit packs — $99 / 20k, $399 / 100k, $999 / 350k, $2,499 / 1M (20–38% off PAYG). Buy in portal.
Commit plans — predictable monthly + discount
- Builder — $79/mo, 20,000 calls included, $0.005/call overage (breakeven ≈ 14,000 calls vs PAYG).
- Scale — $399/mo, 200,000 calls included, $0.0012/call overage (breakeven ≈ 125,000 calls).
- Enterprise — custom contract: SLA, BAA, single-tenant, dedicated CSM.
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.