@surbee/cipher
v0.4.0
Published
Survey response validation from Surbee: detect bots, fraud, AI-generated and copied answers, and low-effort responses
Maintainers
Readme
@surbee/cipher
Survey response validation from Surbee. Cipher detects bots, fraud, AI-generated or copied answers, and low-effort responses, and scores each response wherever you collect it, not just inside Surbee.
Every check runs on Surbee's servers using the same engine that scores surveys hosted on Surbee. The SDK collects signals and sends them; no detection logic, weights or thresholds ship to your client.
npm install @surbee/cipherRequires Node 18 or later on the server, and a modern browser for CipherTracker.
Quick start
import { Cipher } from '@surbee/cipher';
const cipher = new Cipher({
apiKey: process.env.CIPHER_API_KEY, // cipher_sk_...
tier: 3, // 1 to 5, higher runs more checks
});
const result = await cipher.validate({
responses: [
{ questionId: 'recommend', question: 'Would you recommend us?', answer: 'Yes', questionType: 'boolean' },
{ questionId: 'why', question: 'What stood out?', answer: 'It saved me an hour a week on reporting.' },
],
behavioralMetrics: tracker.getMetrics(), // from CipherTracker, optional but recommended
deviceInfo: tracker.getDeviceInfo(), // from CipherTracker, optional but recommended
context: { ipAddress: respondentIp }, // enables the network checks at tiers 4 and 5
});
result.score; // 0 to 1, higher is better quality
result.recommendation; // 'keep' | 'review' | 'discard'
result.flags; // plain-language reasons for anything that fired
result.perQuestion; // what Cipher found on each questionKeep your API key on the server. Collect signals in the browser with
CipherTracker, send them to your backend with the answers, and call
cipher.validate() there.
Collecting signals with CipherTracker
CipherTracker records how the respondent interacted with the form: pointer
movement, typing rhythm, focus, scrolling, and how each written answer was
produced. It never records the characters someone types, only timing and the
kind of edit (typed, deleted, pasted).
import { CipherTracker } from '@surbee/cipher';
const tracker = new CipherTracker();
tracker.start(); // call in the browser, for example from a React effect
tracker.questionStart('why'); // when a question is shown
// ...the respondent answers...
tracker.questionEnd('why'); // when they move on
const payload = {
behavioralMetrics: tracker.getMetrics(),
deviceInfo: tracker.getDeviceInfo(),
};
tracker.stop(); // on unmountWriting analysis needs to know which question a text box belongs to. Either call
questionStart(questionId) as each question appears, or put
data-question-id="why" on the element that wraps each question. Use the same
questionId in responses when you validate.
Without tracker data Cipher still scores the answers and timing, but checks
that need interaction data are reported as skipped rather than guessed.
What Cipher checks
| Tier | Name | Checks | Adds | | ---- | -------- | ------ | -------------------------------------------------------------------- | | 1 | Basic | 6 | Timing and effort heuristics | | 2 | Standard | 15 | Device fingerprinting and automation detection | | 3 | Enhanced | 24 | AI text analysis, contradictions, text forensics, writing process | | 4 | Advanced | 32 | Full behavioral analysis, IP reputation, quality and semantic checks | | 5 | Maximum | 42 | Every check at full depth, including statistical text analysis |
Highlights from tier 3 up:
- Writing process. How each long answer was produced: typed, composed and revised, pasted, copy-typed from another source, or appearing with no input behind it.
- Text forensics. Invisible characters that typing does not produce, and chatbot phrasing or markdown left in an answer.
- AI text analysis. Whether written answers read as generated, copied, off topic or low effort.
cipher.getTierInfo(tier) and cipher.getAllTiers() return the exact checks
for each tier. The list is generated from the server's own configuration, so it
always matches what runs.
Result shape
{
score: number, // 0 to 1, higher is better
passed: boolean, // score at or above thresholds.fail
recommendation: 'keep' | 'review' | 'discard',
confidence: number, // 0 to 1
flags: string[], // plain-language reasons
summary: { verdict, issues, positives, suggestion },
checks: Array<{
checkId: string,
passed: boolean,
score: number, // 0 to 1, higher is more suspicious
details?: string,
skipped?: boolean, // the input lacked what this check needs
data?: Record<string, unknown>,
}>,
perQuestion: Record<string, {
writing?: 'typed' | 'composed' | 'pasted' | 'transcribed' | 'untyped',
forensics?: string[],
relevance?: 'answers' | 'partial' | 'off_topic' | 'gibberish',
aiProbability?: number | null,
analysisMs: number, // engine time spent on this question
}>,
meta: {
tier, processingTimeMs, analysisMs, checksRun, checksPassed, requestId, timestamp,
},
}perQuestion is keyed by the questionId you send, or q1, q2 and so on in
the order of responses when you do not send one.
Thresholds
score is mapped to a recommendation using two thresholds:
new Cipher({ apiKey, thresholds: { fail: 0.4, review: 0.7 } }); // the defaultsAt or above review is keep, between fail and review is review, and
below fail is discard.
Batches
const batch = await cipher.validateBatch({
submissions: [submissionA, submissionB, submissionC],
crossAnalysis: true, // on by default at tier 5
});
batch.results; // one result per submission, in order
batch.summary; // { total, passed, review, failed, avgScore }
batch.fraudIndicators; // shared answers, coordinated timing, shared devicesUp to 500 submissions per call. Cross-analysis compares submissions with each other to catch coordinated fraud that no single response shows.
Account status
const status = await cipher.checkStatus();
// { valid, credits, unlimited, tier, scopes, rateLimit: { remaining, resetAt } }checkStatus() is free and does not count against your credits.
Errors
Failed calls reject with { code, message, details }:
| Code | Meaning |
| ---------------------- | --------------------------------------------- |
| INVALID_API_KEY | The key is missing, malformed or inactive |
| INSUFFICIENT_CREDITS | Not enough API credit for this request |
| RATE_LIMITED | Too many requests this minute |
| INVALID_INPUT | The request body was not valid |
| TIER_NOT_AVAILABLE | Your key cannot use the requested tier |
| SERVER_ERROR | Something went wrong on Surbee's side |
| NETWORK_ERROR | The request did not reach Surbee |
Configuration
new Cipher({
apiKey: 'cipher_sk_...', // required
tier: 3, // default 3
thresholds: { fail: 0.4, review: 0.7 },
debug: false, // log each request
endpoint: 'https://www.surbee.dev/v1/cipher', // the default
});If you set endpoint yourself, use the www host. The bare surbee.dev domain
redirects to it, and browsers and Node drop the Authorization header when
following a redirect to another host, so requests to the bare domain fail with
INVALID_API_KEY.
Upgrading from 0.3
- The default endpoint moved to
https://www.surbee.dev/v1/cipher. Requests from 0.3 to the bare domain lose their API key on the redirect; upgrading fixes this. behavioralMetrics.durationis now in milliseconds. 0.3's tracker sent seconds, which made every session look rushed.deviceInfo.automationDetectedno longer treats a browser with no plugins as automated, which flagged phones. It now comes withautomationSignals.- New:
perQuestion,meta.analysisMs,checks[].skipped, writing analysis inCipherTracker, andcheckStatus()backed by a real endpoint.
License
MIT
