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/cipher
import { 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. fail is the minimum score to keep a response. review is 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, or discard, 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 skipped did not have the data it needs, such as mouse movement from a phone.
  • perQuestion. What Cipher found on each question, keyed by questionId (or q1, q2 and so on): how the answer was written (typed, composed, pasted, transcribed or untyped), 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:

  1. Pass the respondent's IP. Capture it server-side when you collect the response (for example req.headers['x-forwarded-for']) and send it as context.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
    });
  2. Set the key on the Surbee server. Network lookups run server-side with an IPQS_API_KEY environment 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 needTierCost
Block obvious bots and speed-runners, free1-2Free
The above plus AI-written and low-quality text3-4Per response
Maximum accuracy plus cross-respondent fraud rings5Highest

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, or fraud.
  • topSignals. The features that contributed most to the prediction.

Errors

CodeMeaning
INVALID_API_KEYMissing, malformed, or inactive key
INSUFFICIENT_CREDITSThe key has no credits left
TIER_NOT_AVAILABLERequested a tier above the key's limit
SERVER_ERRORSomething 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" }