Kernsignal/API docs

Bot screen

Bot screen

Send a tracking wave and its earlier waves. Get back a ranked review queue of the respondents whose answers look least like their own history, with plain-language reasons, for a person to look at.

The screen returns a review queue. It never rejects anyone, and a score is a ranking, not a probability that someone is a bot. Only respondents your analyst confirms belong on a reconciliation list.

Limits to know first

Quickstart

Use a ks_test_ key while you try it. Test keys are never billed and their jobs are purged after 24 hours.

curl -X POST https://api.kernsignal.ai/v1/screens \
  -H "Authorization: Bearer $KERNSIGNAL_API_KEY" \
  -H "Idempotency-Key: wave3-2026-11-20" \
  -F current_file=@wave3.csv \
  -F "prior_files[]=@wave1.csv" -F "prior_files[]=@wave2.csv" \
  -F 'config={"link_id_column": "panelist_id", "supplier_column": "psid", "queue_fraction": 0.05}'

Screens are billed per respondent, so send an Idempotency-Key on every create call: a retry with the same key then returns the original job instead of starting and billing a second screen. The call returns 202 with a job object whose status is queued. Poll GET /v1/screens/{id} until it is succeeded or failed, or subscribe to the webhook events below.

from kernsignal import Kernsignal

client = Kernsignal(api_key="ks_test_...")
screen = client.screens.create("wave3.csv", ["wave1.csv", "wave2.csv"],
                               link_id_column="panelist_id", supplier_column="psid",
                               idempotency_key="wave3-2026-11-20")
screen = client.screens.wait(screen["id"])          # a failed screen is returned, check screen["status"]
open("review_queue.csv", "w").write(client.screens.review_queue_csv(screen["id"]))
client.screens.labels(screen["id"], {"R100231": "confirm", "R100877": "clear"})

POST /v1/screens

Multipart form. Send CSV; Qualtrics exports with the extra header rows are fine. Answers must be numeric codes, not labels. Free text, dates and metadata columns are ignored and listed in the summary.

FieldRequiredMeaning
current_fileyesThe wave to screen. At most 50 MB and 100,000 rows.
prior_files[]noEarlier waves, oldest first; repeat the field once per file. Up to 6, each at most 50 MB.
configyesJSON string, below.
config keyDefaultMeaning
link_id_columnrequiredColumn that names the same respondent in every wave.
prior_link_id_columnssame as aboveList aligned with prior_files[]; null where the column name is the same.
supplier_columnnoneSupplier respondent ID, copied into the queue so you can reconcile with the supplier.
queue_fraction0.05Share of the wave to put in the queue, 0.001 to 0.5. Or give queue_size (1 to 5000), not both.
mode"auto""auto", "history" or "answer_pattern". Auto uses history when at least 5% of current respondents appear in the prior files; the result says which was used and why.
missing_codes[]Numeric codes that mean "no answer", for example [99, -1].
subgroups[]Up to 5 columns for the linkage breakdown in the summary.
use_tunedtrueUse your organisation's tuned model when you have one.
webhookfalseSend screen.succeeded or screen.failed to your webhook endpoints.

Idempotency

Send an Idempotency-Key header of 1 to 255 printable characters. The same key with the same files and config within 24 hours returns 202 with the original job and the header Idempotent-Replayed: true. The same key with different files or config is a 400 idempotency_key_reused. A key outside 1 to 255 printable characters is a 400 invalid_idempotency_key. Keys are scoped to your organisation and shared with audits, so use a different key for each request.

GET /v1/screens/{id}

Returns the job object: id (scr_ plus 20 characters), object ("screen"), status, created_at, started_at, finished_at, mode ("live" or "test"), config, result and error. When succeeded, result holds:

{
  "mode": "history",
  "mode_reason": "90% of current respondents appear in 1 prior file, so each is compared with their own earlier answers.",
  "model": {"source": "shipped", "base_model": "botscreen_v1", "n_labels": null},
  "summary": {"n_respondents": 1000, "queue_k": 80, "queue_pct": 8.0,
              "contamination": {"reliable": true, "level": 0.9, "n_covered": 904, "n_not_estimated": 96,
                                "point": 0.045, "low": 0.028, "high": 0.061,
                                "by_mode": {"history": {"status": "estimated", "n": 904, "point": 0.045, "low": 0.028, "high": 0.061,
                                                        "scope": "history mode; validated on public Twin-2K simulations"},
                                            "answer-pattern": {"status": "withheld", "n": 96, "reason": "not validated"}},
                                "display": {"value": "4.5%",
                                            "sub": "90% range 2.8% to 6.1%, for the 904 respondents scored with history (history mode; validated on public Twin-2K simulations). Not estimated for the 96 scored on answers only (not validated)"}},
              "linkage": {}, "topline_queue": {}, "notes": []},
  "queue": [{"rank": 1, "respondent_id": "223", "supplier_id": "SUP-5162538", "score": 1.0,
             "evidence": "history + answers",
             "reason_1": "Moves 0.8 steps on average from its earlier answers on scale and choice items (typical 0.3)",
             "reason_2": "...", "reason_3": "..."}]
}

Objects inside summary are shortened here. In this example (a public Twin-2K demo wave of 1,000 respondents) contamination.reliable is true: the 904 respondents scored with history get a 90% range, point, low and high, and every estimated evidence type carries its scope, here "history mode; validated on public Twin-2K simulations". That range was checked on simulated fakes from one public study, not on a real online panel: read it as a rough range, not a measurement. A withheld evidence type (the 96 respondents scored on answers only here) carries its reason instead of a number; reliable is false when nothing could be estimated, for example when the wave looks heavily contaminated or unlike the training data.

Review queue and summary

GET /v1/screens/{id}/review_queue.csv has the columns rank, respondent_id, supplier_id, score, evidence, reason_1, reason_2, reason_3, decision. decision holds the marks you have sent. You can edit that column and send the file back to the labels endpoint. A cell that would start like a spreadsheet formula (=, +, - or @) gets a leading single quote so that a spreadsheet does not run it; the labels endpoint removes that quote again before it matches respondent_id.

GET /v1/screens/{id}/summary.html is a one-page wave QA summary: the contamination estimate (or "Not estimated" with the reason; a shown range is labelled with its scope), the queue split by evidence, the topline shift if the queue were removed, linkage by prior wave, the limits above and the shipped model's validation.

POST /v1/screens/{id}/labels

Confirm or clear respondents in the queue. Send JSON or CSV. Only respondents in this screen's queue can be labelled.

curl -X POST https://api.kernsignal.ai/v1/screens/scr_.../labels \
  -H "Authorization: Bearer $KERNSIGNAL_API_KEY" -H "Content-Type: application/json" \
  -d '{"labels": {"223": "confirm", "871": "clear", "412": null}}'

curl -X POST https://api.kernsignal.ai/v1/screens/scr_.../labels \
  -H "Authorization: Bearer $KERNSIGNAL_API_KEY" -H "Content-Type: text/csv" \
  --data-binary @review_queue.csv

Values are "confirm", "clear" or null to remove a mark. In a CSV, use the columns respondent_id and label (or decision, as in review_queue.csv); blank cells are rows you have not reviewed and are skipped. The response gives the screen's label counts and your model status:

{"id": "scr_...", "object": "screen_labels",
 "labels": {"confirm": 10, "clear": 10, "pending": 60},
 "model": {"status": "trained", "version": 1, "n_confirm": 10, "n_clear": 10, "needed_per_class": 8}}

Your own model

Once your organisation has at least 8 confirmed and 8 cleared respondents across its screens, a small tuned model is trained on those marks and blended with the shipped score on your later screens. Until then model.status is insufficient_labels and screens use the shipped model alone. The tuned model belongs to your organisation: it is never applied to another organisation's screens, and your marks are never used to train the shipped model. Marks come only from queue rows, so the tuned model learns the hard cases, not the whole wave. Each retraining creates a new version.

DELETE /v1/screens/{id}

Removes the screen, its result and your marks on it from the live system immediately. Nightly backups hold them for up to 14 days. Your tuned model is retrained without them, or switched off if fewer than 8 per class remain. Deleting a live screen requires a live key (ks_live_...); a test key receives 403 live_key_required. Returns {"id": "scr_...", "deleted": true}.

Webhooks

Create endpoints with POST /v1/webhook_endpoints and verify the Kernsignal-Signature header as described in Webhooks. A screen sends screen.succeeded or screen.failed only when its config.webhook is true. The event's data.object is the job object without result; fetch the result with GET /v1/screens/{id}.

Errors

Requests that cannot start return the standard error envelope with HTTP 400, 403, 404 or 429. A file or column problem found while the screen runs makes the job failed with the same envelope in error.

codeWhereFix
missing_parameter, unknown_parameter, invalid_parameter, unsupported_file_type, empty_file, file_too_large, invalid_file_content, request_too_largecreateSend current_file and only the documented fields; CSV files, at most 50 MB each, at most 6 prior files.
invalid_configcreateCorrect config; param names the key, for example config.link_id_column.
idempotency_key_reused, invalid_idempotency_keycreateUse a new key for different files or config; keys are 1 to 255 printable characters.
live_key_requireddelete (403)Delete a live screen with a live key.
queue_fullcreate (429)Too many screens are waiting; retry in a minute.
column_not_found, unreadable_file, too_many_rows, wave_not_screenablejob errorThe message says what to change, for example fewer than 50 respondents or fewer than 5 numeric answer columns.
screen_failed, interruptedjob errorUnexpected, or the service restarted while the screen ran (uploads are never stored). Resubmit; if it fails again, contact us with the screen id.
screen_not_found, screen_not_readyanyCheck the id; downloads and labels need a succeeded screen.
unknown_respondent, invalid_label, invalid_labels, invalid_json, invalid_csv, unsupported_content_typelabelsLabel only queue rows, with confirm, clear or null, as JSON or CSV of at most 5 MB.

What we keep