A signup arrives
A new address, a browser with no history, an IP you have not sold to before. It looks like any other Tuesday.
email d.mercer+t7@mailinator.comip 45.83.220.7visitorToken signed, valid for 5 minutesThe browser has been here
This device already finished a free trial, under a different name. That alone proves little, because people share machines.
The address is new, the person is not
Canonicalized, stripped of the plus tag and the dots, it resolves to an account you already have.
The mailbox is temporary
It is a disposable domain. Nobody opens a renewal notice at an address built to expire.
Same block, different door
The subnet has already been used to consume a trial. A VPN moves the address, not the neighbourhood.
And always at this hour
The accounts this one links to started their trials in the same part of the day. People have habits. So do their scripts.
Five signals. Four families. One answer.
out of 100
The real response carries a weight on every reason. Those numbers, and the two lines that turn a score into a verdict, belong to your dashboard and your API, not to this page.{"decision": "deny","score": 81,"reasons": [{ "code": "D_DEVICE_REPEAT", "message": "Device previously used for a consumed trial" },{ "code": "E_EMAIL_REPEAT", "message": "Email matches a prior account" },{ "code": "E_DISPOSABLE_DOMAIN","message": "Disposable email domain" },{ "code": "N_IP24_REPEAT", "message": "IP subnet previously used for a consumed trial" },{ "code": "B_ABUSE_HOUR_MATCH", "message": "Signup hour matches prior abuse in linked cluster" }]}
D_DEVICE_REPEATE_EMAIL_REPEATE_DISPOSABLE_DOMAINN_IP24_REPEATB_ABUSE_HOUR_MATCHOne tag. One call.
The tag mints a short-lived token in the browser and shows nothing. Your server sends that token with the signup and gets the answer back. No decision is ever made in the browser, because the answer carries the score and the reasons and those are yours alone.
Card numbers never reach us. Emails are stored canonicalized and hashed, with the address encrypted.
On your signup page<script src="https://onetrial.dev/v1.js" data-key="YOUR_PUBLIC_KEY" async></script>
In your signup handlerconst res = await fetch('https://onetrial.dev/api/v1/decisions', {method: 'POST',headers: { Authorization: `Bearer ${process.env.ONETRIAL_API_KEY}`, 'Content-Type': 'application/json' },body: JSON.stringify({ visitorToken, email, ip, userId }),});const d = await res.json();if (d.decision === 'deny') return showPaidPlanOrSupport();if (d.decision === 'challenge') return redirect(d.challengeUrl); // trial unlocks on the challenge.completed webhookcreateTrial(userId);
Three answers, and what each one costs you
Send them to a paid plan
The evidence is strong enough that a trial would be a repeat. Offer the paid plan or a way to reach you. The visitor never sees a score or a reason, so nobody learns what to change next time.
Ask for one proof
A card, an email link, or a captcha. We host the page and tell your server the outcome, or you run it inside your own UI. A real customer clears it and carries on.
Create the trial
Nothing about this signup looks like a repeat. This is most of your traffic, and it should never notice that any of this happened.
Nobody gets turned away on your first day.
New workspaces start in shadow mode. Every signup is scored and recorded, and your app is always told allow. You spend a week reading what would have happened, decide whether the answers match your own judgement, and only then turn enforcement on. It is the cheapest way to find out whether a fraud tool suits your traffic, and most of them will not let you do it.
json{ "decision": "allow", "shadow": true, "wouldBe": "deny", "score": 81 }
Four plans, priced on decisions
A decision is one answered signup. The free plan stops at its cap rather than billing you by surprise.
$0
per month$49
per month$199
per month$499
per monthOr let the agent do it
The CLI detects your framework, writes the env vars, injects the tag and self-tests. The dashboard also hands you a prompt carrying your real key, so Cursor or Claude Code has nothing left to guess.
bashnpx onetrial initnpx @onetrial/mcp # the decision feed, the sandbox and the rules, as agent tools