Skip to content

Sensor Protocol

Everything WebDecoy knows about a request arrives through one of a handful of sensors: the Cloudflare edge Worker, the WordPress plugin, the Node SDK, the browser tag. They all speak the same protocol, and this page is that protocol.

It is written so you can build a sensor for a stack we do not ship one for (a Laravel app, a Go service, an Nginx origin) without reading our source.


There are two, and the difference is not convenience. It decides how much your detections are trusted.

Endpoint Auth Use it for
POST /api/v1/detect Authorization: Bearer sk_live_... Anything server-side. A plugin, an SDK, an origin module.
POST /api/v1/detect/public none (aid + sid in the body) The browser tag only, where no secret can be kept.

Build against /api/v1/detect. A server-side sensor can hold an API key, so it should. The public endpoint exists because a script running in a visitor’s browser cannot keep a credential, and detections arriving there are scored as what we can prove rather than what they claim.

On the public endpoint, aid and sid route a detection to the right account. They do not authenticate anyone: both sit in the page source of every instrumented site. So a payload sent there is read as a description of the connection it arrived on, and nothing else:

  • ip and ua are ignored. The address and user agent recorded are the ones we observed.
  • cs is ignored, including any ja4. A fingerprint we did not measure never becomes a WAF rule.
  • s and f are still scored, but on their own they cannot push a detection past the threshold that fires an automatic response action.

The refused values are kept on the row as claimed_ip and claimed_user_agent so a misconfigured sensor is visible rather than silent. A sensor that reports on other people’s requests must use /api/v1/detect with an API key.

Base URL is https://in.webdecoy.com.


POST /api/v1/detect HTTP/1.1
Host: in.webdecoy.com
Authorization: Bearer sk_live_...
Content-Type: application/json
{
"source": "my_php_sensor",
"ua": "GPTBot/1.0 (+https://openai.com/gptbot)",
"ip": "203.0.113.42",
"url": "https://example.com/pricing",
"f": ["known_crawler_ua"],
"cs": {
"hn": ["accept", "accept-encoding", "host", "user-agent"],
"al": "en-US,en;q=0.9",
"ae": "gzip, deflate, br"
},
"ts": 1756500000000
}

A 204 means accepted. The endpoint is fire-and-forget by design: it returns no verdict, so a sensor never waits on us and a reporting outage cannot become a site outage.


Field Type What it is
source string Which sensor this came from. Attribution only. See Source is not authority.
ua string The client’s User-Agent, not your HTTP client’s.
ip string The client’s address. See Getting the client IP right.
url string Full URL the client requested.
f string[] Flags describing what you observed. Free-form; [] is valid.
cs object Client signals. Not optional for a server-side sensor. See below.
Field Type What it is
ts int64 Client timestamp, milliseconds since epoch.
ref string Referer header. Its absence is itself a signal, so send it when present.
metadata object Extra context. Scalars only, at most 24 keys, values under 256 characters.

Request or response bodies, cookie values, or credentials. The protocol has no field for them and they will be rejected.


This is the field most likely to be forgotten, and omitting it quietly costs you most of your score.

"cs": {
"hn": ["accept", "accept-encoding", "host", "user-agent"],
"al": "en-US,en;q=0.9",
"ae": "gzip, deflate, br"
}
  • hn: the client’s request header names, lowercased and sorted. Names only. The name set is what carries the entropy, and your visitors’ header values are not ours to collect.
  • al: the client’s Accept-Language.
  • ae: the client’s Accept-Encoding.

Strip anything your own infrastructure added (cf-*, x-forwarded-*, load-balancer headers) so the same client fingerprints identically whether it was seen at your origin or at a CDN edge.

Why it matters: your sensor beacons us over its own HTTP connection, so the request we receive carries your server’s headers, not the client’s. Without cs the network fingerprint tier is empty and the header score is zero. Worse, every detection you send fingerprints as the same “actor”: your reporter. That is how one identity once accumulated hundreds of unrelated crawlers.


Send the address of the client, resolved through your own trusted-proxy configuration. Do not send the socket peer if you sit behind a CDN, and do not blindly trust the leftmost X-Forwarded-For entry, because that value is attacker-controlled.

The rule that holds everywhere: walk X-Forwarded-For from the right, skipping addresses you know to be your own proxies, and stop at the first one you do not recognise. If you have no trusted-proxy list configured, use the socket address and nothing else.

Getting this wrong does not produce an error. It produces detections that all share one address, which silently defeats rate limiting and actor correlation.


source tells us which sensor a row came from. It is used for attribution and in the dashboard.

It does not decide how much the detection is trusted. That is derived server-side from how the request authenticated, so a caller cannot name a privileged source and inherit its treatment. A server-side sensor is scored on what a server-side sensor can actually observe: user agent, IP, header set and ordering, path and timing.

Pick a stable, descriptive value and keep it: laravel_middleware, nginx_lua, django_asgi.

The same rule covers the rest of the payload, and it is worth stating plainly because it is what your API key buys. A sensor reports on somebody else: ip, ua and cs describe a visitor, not the process sending them. Those values reach IP enrichment, actor correlation and any response action the site owner has configured, so believing one requires knowing who is speaking. An authenticated sensor is believed. An unauthenticated caller is recorded as itself.


What you cannot observe, and why that is fine

Section titled “What you cannot observe, and why that is fine”

No origin-side sensor can produce a TLS fingerprint. Your process is handed a decrypted request and never sees the ClientHello. That is true for every vendor, not just us.

An absent TLS fingerprint costs the TLS component of the score and the ability to push a fingerprint rule to a WAF. It does not stop a detection being useful, and the scoring weights already account for a sensor that structurally cannot reach that dimension.

If a CDN in front of you injects a JA3 or JA4 header, pass it through in metadata and we will use it.


Send a request whose User-Agent begins with WebDecoy-Test/. Every sensor is required to report it, and it produces a detection labelled is_test that appears in the dashboard while being excluded from statistics, billing, actor correlation, and enforcement.

Terminal window
curl -A "WebDecoy-Test/1.0 (my sensor)" https://your-site.example/

If a detection appears, your sensor is wired correctly end to end. If one does not, the sensor is not seeing the request at all, which is the failure worth catching, because a sensor that reports nothing looks exactly like a quiet site.


Fail open, always. If we are unreachable, slow, or return an error, serve the request. A sensor that can take a site down is worse than no sensor.

Do not block on the beacon. Report out-of-band where your runtime allows it, and where it does not, bound it: a short timeout, abandoned rather than retried. Delivery differs by runtime: waitUntil at a CDN edge, a bounded flush before a serverless handler returns, a background queue in a long-running process. Pick the one your platform actually supports rather than assuming a detached promise survives.

Skip static assets. Images, stylesheets and scripts are noise, and reporting them costs you volume for nothing.

Rate limits. The public endpoint allows 100 requests per minute per IP. Authenticated endpoints are governed by your plan.