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://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 }
  ],
  "meta": {
    "tier": 2,
    "processingTimeMs": 41,
    "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.

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" }
Cipher SDK and API | Surbee