Cipher SDK and API
Validate survey responses from your own application.
The Cipher API lets you score responses you collect anywhere, not just inside Surbee. Send a response plus the behavioral signals you captured, and Cipher returns a quality score and a recommendation.
You will need an API key first. See API keys.
Install the SDK
pnpm add @surbee/cipherimport { Cipher } from '@surbee/cipher';
const cipher = new Cipher({
apiKey: process.env.CIPHER_API_KEY!, // cipher_sk_...
tier: 2, // 1–5, which set of checks to run
thresholds: { fail: 0.5, review: 0.75 }, // optional, defaults to { fail: 0.4, review: 0.7 }
// endpoint: 'https://www.surbee.dev/v1/cipher', // optional, this is the default
});How scoring works
Every tier is scored on Surbee's servers, so the SDK is a thin client: it collects the response and behavioral signals and sends them to Cipher. The detection logic, weights, and thresholds are never shipped to your client and can't be inspected or reverse-engineered — which matters, since the people you are screening for fraud are exactly the ones who would read a local bundle.
The difference between tiers is what runs, not where:
- Tiers 1–2 run fast, rule-based checks (behavioral, timing, device, content) with no AI model, so they return in milliseconds and are free.
- Tiers 3–5 add AI-powered checks (AI-text, VPN, fraud-ring), which take a little longer and cost per response.
Because everything is server-side, an API key is required for every tier. Treat any client-side signal collection as input only — never as a trusted score.
cipher.getTierInfo().checks; // CheckId[] the configured tier runs
cipher.getTierInfo().usesAI; // false for tiers 1–2, true for 3–5
cipher.estimateCost(); // per-response price (0 for tiers 1–2)Validate a response
POST /api/cipher/validate runs the checks for the requested tier and returns a
verdict. Authenticate with a bearer token.
Request
{
"tier": 2,
"thresholds": { "fail": 0.5, "review": 0.75 },
"input": {
"responses": [
{ "question": "Would you recommend us?", "answer": "Yes" },
{ "question": "What stood out?", "answer": "It saved me time", "responseTimeMs": 8200 }
],
"behavioralMetrics": { },
"deviceInfo": { },
"context": { }
}
}- tier. Which set of checks to run, from 1 to 5. Must be within your key's tier limit.
- thresholds.
failis the minimum score to keep a response.reviewis the score above which a response is accepted without review. - input. The response data plus any behavioral, device, and context signals you captured.
Response
{
"score": 0.91,
"passed": true,
"recommendation": "keep",
"confidence": 0.62,
"flags": [],
"summary": {
"verdict": "High-quality legitimate response",
"issues": [],
"positives": ["Response timing appears natural", "No automation tools detected"],
"suggestion": "Response can be accepted as-is"
},
"checks": [
{ "checkId": "rapid_completion", "passed": true, "score": 0, "details": null },
{ "checkId": "writing_process", "passed": true, "score": 0, "details": null }
],
"perQuestion": {
"q2": { "writing": "composed", "analysisMs": 0.014 }
},
"meta": {
"tier": 3,
"processingTimeMs": 41,
"analysisMs": 0.12,
"checksRun": 15,
"checksPassed": 15,
"requestId": "req_ab12cd34",
"timestamp": 1730000000000
}
}Key fields:
- score. Quality from 0 to 1, where higher is better. This is the inverse of risk.
- recommendation. One of
keep,review, ordiscard, derived from your thresholds. - flags. Human readable names of any checks that failed.
- summary. A plain language verdict with the issues, positive signals, and a suggested action.
- checks. The per check breakdown. A check marked
skippeddid not have the data it needs, such as mouse movement from a phone. - perQuestion. What Cipher found on each question, keyed by
questionId(orq1,q2and so on): how the answer was written (typed,composed,pasted,transcribedoruntyped), any hidden characters or chatbot phrasing, and the time spent analysing it. - meta.analysisMs. Time the engine spent on the whole response, separate from network time.
Cipher scores SDK requests with the same engine as surveys hosted on Surbee, so every check described in these docs applies to both.
To get perQuestion.writing, collect signals with CipherTracker and call tracker.questionStart(questionId) as each question appears, or put data-question-id on the element that wraps each question.
Example
// tier and thresholds come from the new Cipher({ ... }) config above —
// validate() takes just the response data.
const result = await cipher.validate({
responses: [
{ question: 'Would you recommend us?', answer: 'Yes' },
{ question: 'What stood out?', answer: 'It saved me time', responseTimeMs: 8200 },
],
behavioralMetrics, // optional, from the client-side tracker
deviceInfo, // optional
context, // optional
});
if (result.recommendation === 'discard') {
// reject or quarantine the response
} else if (result.recommendation === 'review') {
// queue for a human to look at
}Validate a batch
When you have many responses at once (a finished study, a nightly job, a CSV
export), send them together with validateBatch. On top of scoring each
response, a batch can run cross-respondent fraud detection: identical answers
across people, synchronized timing, and shared devices. Cross-analysis is on by
default at tier 5 and opt-in below it.
const batch = await cipher.validateBatch({
submissions: rows.map((r) => ({
responses: r.responses,
behavioralMetrics: r.metrics,
deviceInfo: r.device,
})),
crossAnalysis: true,
});
console.log(batch.summary); // { total, passed, review, failed, avgScore }
console.log(batch.fraudIndicators); // { duplicateAnswers, coordinatedTiming, deviceSharing, fraudRingScore }
const clean = batch.results.filter((r) => r.recommendation === 'keep');One credit is charged per submission. fraudIndicators is only returned when
cross-analysis runs (tier 5, or crossAnalysis: true).
Network checks (IP reputation)
The tier 4-5 network checks (datacenter IP, VPN, Tor, proxy) use IPQualityScore. Two things turn them on:
-
Pass the respondent's IP. Capture it server-side when you collect the response (for example
req.headers['x-forwarded-for']) and send it ascontext.ipAddress. The IP of your call to Cipher is your own server, so we never use it.await cipher.validate({ responses, deviceInfo, context: { ipAddress: respondentIp }, // the survey taker's IP }); -
Set the key on the Surbee server. Network lookups run server-side with an
IPQS_API_KEYenvironment variable. Get one by creating a free account at ipqualityscore.com (the free tier covers about 5,000 lookups per month). The key is on the Proxy & VPN Detection API settings page in the dashboard.
Without an IP or without the key, the network checks are skipped (they never produce a false positive). All other checks are unaffected.
Build a complete integration
A production setup has three pieces: capture signals in the browser, validate on your server, then act on the verdict. Never call Cipher straight from the browser with a secret key. The browser only collects signals; your server does the scoring.
1. Capture behavior in the browser
You do not need a separate library. Collect the handful of signals the checks read and post them to your own API alongside the answers.
// useBehaviorTracker.ts (client)
export function startTracker() {
const startedAt = Date.now();
let keypressCount = 0, backspaceCount = 0, pasteEvents = 0, tabSwitchCount = 0;
const onKey = (e: KeyboardEvent) => { keypressCount++; if (e.key === 'Backspace') backspaceCount++; };
const onPaste = () => { pasteEvents++; };
const onBlur = () => { tabSwitchCount++; };
document.addEventListener('keydown', onKey);
document.addEventListener('paste', onPaste);
window.addEventListener('blur', onBlur);
return {
stop() {
document.removeEventListener('keydown', onKey);
document.removeEventListener('paste', onPaste);
window.removeEventListener('blur', onBlur);
return {
metrics: {
sessionId: crypto.randomUUID(),
duration: Date.now() - startedAt,
keypressCount, backspaceCount, pasteEvents, tabSwitchCount,
},
device: {
userAgent: navigator.userAgent,
platform: navigator.platform,
language: navigator.language,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
screenWidth: screen.width,
screenHeight: screen.height,
webDriver: navigator.webdriver === true,
},
};
},
};
}2. Validate on your server
// app/api/survey/submit/route.ts (Next.js App Router)
import { Cipher } from '@surbee/cipher';
import { NextResponse } from 'next/server';
const cipher = new Cipher({ apiKey: process.env.CIPHER_API_KEY!, tier: 4 });
export async function POST(req: Request) {
const { answers, metrics, device } = await req.json();
const result = await cipher.validate({
responses: answers, // [{ question, answer, responseTimeMs? }]
behavioralMetrics: metrics,
deviceInfo: device,
});
if (result.recommendation === 'discard') {
return NextResponse.json({ ok: false, reason: result.summary.verdict }, { status: 422 });
}
await saveResponse({ answers, cipher: { score: result.score, recommendation: result.recommendation, flags: result.flags } });
return NextResponse.json({ ok: true });
}3. Act on the verdict
Store the score with every response so you can filter later, and route the in-between cases to a human instead of throwing them away.
switch (result.recommendation) {
case 'keep': await accept(response); break;
case 'review': await sendToReviewQueue(response, result.flags); break;
case 'discard': await quarantine(response, result.summary); break;
}Pick a tier
| You need | Tier | Cost |
|---|---|---|
| Block obvious bots and speed-runners, free | 1-2 | Free |
| The above plus AI-written and low-quality text | 3-4 | Per response |
| Maximum accuracy plus cross-respondent fraud rings | 5 | Highest |
Start at the lowest tier that meets your need and move up if junk is getting
through. You can change tier per call.
Quick examples
For drop-in recipes (React form, Next.js route, Express, dataset screening, retry logic, browser capture), see Quick examples.
Predict with the ML model
POST /api/cipher/predict returns the machine learning model's fraud
probability for a stored response. Useful when you have already extracted
features and want the model's view directly.
Request
{ "responseId": "resp_123", "modelVersion": "latest" }Response
{
"fraudProbability": 0.08,
"fraudVerdict": "low_risk",
"confidence": 0.74,
"topSignals": [
{ "feature": "completionTimeSeconds", "contribution": 0.03, "value": 142 }
],
"modelVersion": "2025.11",
"inferenceTimeMs": 12
}- fraudProbability. 0 to 1, where higher means more likely fraudulent.
- fraudVerdict.
low_risk,medium_risk,high_risk, orfraud. - topSignals. The features that contributed most to the prediction.
Errors
| Code | Meaning |
|---|---|
INVALID_API_KEY | Missing, malformed, or inactive key |
INSUFFICIENT_CREDITS | The key has no credits left |
TIER_NOT_AVAILABLE | Requested a tier above the key's limit |
SERVER_ERROR | Something went wrong on our side |
Health check
GET /api/cipher/health returns the service status, for uptime monitoring.
{ "status": "operational", "service": "cipher", "version": "1.0.0" }