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
- Real online panels are unproven. This screen has not been validated on real online-panel bots. Treat it as a ranked review aid, never as proof that a respondent is a bot.
- Informed attackers evade it. Fakes that copy real retest variability, and language models told what the detector looks for, largely evaded the research detector until it was trained on that attack.
- History needs repeat respondents. Respondents you cannot link to an earlier wave get an answer-pattern score only. It is much weaker, and those rows are marked "answers only (lower confidence)".
- One public dataset. The shipped model is trained on Twin-2K-500 (114 mostly ordinal and slider items) with public model twins and rule-based fakes. On a simulated wave of 1,000 held-out Twin-2K people with 60 injected fakes from generators the model never saw, an 8% review queue (80 respondents) held 51 of the 60. That is one wave, so expect it to move by several points (across 24 such simulated waves the queue held 48 to 53). For respondents scored with history the summary gives a rough 90% range for the share of fakes in the wave, labelled "history mode; validated on public Twin-2K simulations": on that demo wave it reads 4.5% (2.8% to 6.1%) against a true 6.0%. That range was checked on simulated fakes from one public study, not on a real online panel. It is withheld when the wave looks heavily contaminated or unlike the training data, and for respondents scored on answers only the summary reads "Not estimated" and says why.
- Measure it on your own data. If you hold labelled waves, the partner validation harness reports AUC, recall and precision at your queue size, each with confidence intervals, and sets the screen's contamination estimate (where it gives one) beside your labelled rate. Ask us for it before you rely on the screen.
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.
| Field | Required | Meaning |
|---|---|---|
current_file | yes | The wave to screen. At most 50 MB and 100,000 rows. |
prior_files[] | no | Earlier waves, oldest first; repeat the field once per file. Up to 6, each at most 50 MB. |
config | yes | JSON string, below. |
| config key | Default | Meaning |
|---|---|---|
link_id_column | required | Column that names the same respondent in every wave. |
prior_link_id_columns | same as above | List aligned with prior_files[]; null where the column name is the same. |
supplier_column | none | Supplier respondent ID, copied into the queue so you can reconcile with the supplier. |
queue_fraction | 0.05 | Share 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_tuned | true | Use your organisation's tuned model when you have one. |
webhook | false | Send 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.
| code | Where | Fix |
|---|---|---|
missing_parameter, unknown_parameter, invalid_parameter, unsupported_file_type, empty_file, file_too_large, invalid_file_content, request_too_large | create | Send current_file and only the documented fields; CSV files, at most 50 MB each, at most 6 prior files. |
invalid_config | create | Correct config; param names the key, for example config.link_id_column. |
idempotency_key_reused, invalid_idempotency_key | create | Use a new key for different files or config; keys are 1 to 255 printable characters. |
live_key_required | delete (403) | Delete a live screen with a live key. |
queue_full | create (429) | Too many screens are waiting; retry in a minute. |
column_not_found, unreadable_file, too_many_rows, wave_not_screenable | job error | The message says what to change, for example fewer than 50 respondents or fewer than 5 numeric answer columns. |
screen_failed, interrupted | job error | Unexpected, 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_ready | any | Check the id; downloads and labels need a succeeded screen. |
unknown_respondent, invalid_label, invalid_labels, invalid_json, invalid_csv, unsupported_content_type | labels | Label only queue rows, with confirm, clear or null, as JSON or CSV of at most 5 MB. |
What we keep
- Your files are processed in memory and in temporary files deleted when the job ends. They are never stored.
- Results (the queue with respondent and supplier IDs, scores and reasons, and the summary) are kept 30 days, 24 hours for test keys, then purged.
DELETEremoves them from the live system at once; nightly backups hold them for up to 14 days. - Your marks and the aggregate screening features of the respondents you marked (about 40 numbers such as the share of answers repeated from earlier waves, not the answers themselves) are kept until you delete that screen, even after its result has been purged, because they train your tuned model. Marks made with a test key never tune a live screen. They are never shared with another organisation.