API docs
- Add the browser snippet to your signup page and send its token with the form.
- From your server, call
POST /v1/signups/assessafter creating the account, and give free credits by the returned tier.
Keys
- Publishable key (
pk_live_…): goes in the browser snippet. Safe to show; it only works from your allowed websites. - Secret key (
sk_live_…): server only, sent asAuthorization: Bearer sk_live_…. Never put it in a web page or app.
Create and revoke keys in your dashboard under API keys.
1. The browser snippet
<script async src="https://honeyp.ai/v1/honeyp.js" data-key="pk_live_YOUR_PUBLISHABLE_KEY"></script>
On submit, ask for a token and send it to your server with the rest of the form. Guard the call: if the script didn't load, the signup must still work.
let honeypToken = null;
try { if (window.Honeyp) honeypToken = await Honeyp.token(); } catch (e) {}
// send honeypToken with the email and password
The snippet collects technical summaries of the signup page (counts and timings), never what people type, and stores nothing on the device. Use each token for one signup: tokens expire after 15 minutes, and a reused token counts against the signup.
2. Check a signup
POST /v1/signups/assess with your secret key, right after you create the account.
curl https://honeyp.ai/v1/signups/assess \
-H "Authorization: Bearer sk_live_YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"account_id": "usr_123", "email": "new@user.com", "ip": "203.0.113.7",
"user_agent": "Mozilla/5.0 ...", "client_token": "TOKEN_FROM_THE_BROWSER"}'
| Field | Required | Notes |
|---|---|---|
account_id | Recommended | Your ID for the new account (letters, digits, . _ : @ -, up to 128). Needed for labels and outcomes. |
email | Recommended | Hashed on arrival with a key unique to you; never stored in readable form. |
ip | Recommended | The visitor's IP as your server saw it. Shortened to its network before storage. |
user_agent | Recommended | The visitor's browser user agent. |
client_token | For web signups | From Honeyp.token(). Omit for API or mobile signups and set signup_method. |
signup_method | No | web (default) or api for signups without a browser. |
timezone | For apps | The device's time zone name, for example Europe/Berlin. Web signups get this from the snippet automatically. |
device_id | For apps | Your app's own device identifier (for example the iOS identifier for vendor or an Android app-set ID), 8 to 200 characters. Hashed on arrival with a key unique to you. Web signups get this from the snippet automatically. |
mode | No | shadow to force shadow mode for this call. |
Response
{
"assessment_id": "asm_3f9c…",
"mode": "enforce",
"decision": "allow_reduced",
"credit_tier": {"name": "reduced", "fraction": 0.2},
"risk_score": 62,
"confidence": "high",
"reasons": [{"code": "…", "weight": 45, "detail": "Why this signup was flagged, in plain language"}, …],
"recommended_step_up": ["email_verification", "passkey"],
"degraded": false,
"reasons_level": "full",
"latency_ms": 3.1
}
Give the account your free credits × credit_tier.fraction. In shadow mode, decision is
always allow and the real answer is in shadow_decision.
Base your code on decision and credit_tier: the explanation can be shorter.
reasons_level says how much is included. During a self-serve trial it's limited: reasons
without weights, and the score rounded to the nearest 10. Paid plans, and accounts we've set up with you, get
full. Occasionally it's hidden for a while (no reasons or score); decisions are never affected.
When the trial's checks are used up, when the trial has ended without a paid plan, or if your website already
belongs to another honeyp.ai account, we stop checking: you get "mode": "not_checked",
decision allow with full credits, and the reason in not_checked. Your signups
keep working; nothing is scored or stored. Paid plans are never stopped for going over (that's billed as overage).
Decisions
| Decision | Credits | Suggested action |
|---|---|---|
allow | Full | Nothing: a real person. |
allow_monitor | Full | Watch the first 72 hours (send events, below). |
allow_reduced | Reduced (20% by default) | Unlock the rest after email verification or a passkey. |
review | Held | Hold credits until someone on your team looks; a review task can be created automatically. |
block | None | No free value. Offer a way to contact you. |
Thresholds and the reduced fraction are set per customer: tell us what you'd like and we'll change them (dashboard controls are coming).
Let people ask for a review
When you limit an account, link the person to the appeal page with the check's assessment_id:
https://honeyp.ai/appeal?a=asm_3f9c…
Their email and message go straight to your team by email (and to Slack, Teams or your phone if connected);
honeyp.ai keeps no copy. If they're right, label the account legit and report the outcome
appeal_upheld. Offering a review route is required by our terms and expected under GDPR.
Fail open
Use a short timeout (we suggest 800 ms) and give full credits if honeyp.ai can't be reached. An outage on our side should never block a real user.
const res = await fetch("https://honeyp.ai/v1/signups/assess", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.HONEYP_SECRET_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ account_id: user.id, email, ip: req.ip,
user_agent: req.headers["user-agent"], client_token: honeypToken }),
signal: AbortSignal.timeout(800),
}).then((r) => r.json()).catch(() => null);
const fraction = res?.credit_tier?.fraction ?? 1; // unreachable: full credits
await grantCredits(user, FREE_CREDITS * fraction);
3. Tell us what happened (improves accuracy)
POST /v1/labels
Mark an account as abuse or legitimate. Labels tune the decisions for your signups.
{"account_id": "usr_123", "label": "abuse"} // or "legit"
POST /v1/accounts/outcome
Report what happened later. kind is one of converted, credits_exhausted,
active_7d, refunded, chargeback, banned,
appeal_upheld; value is an optional amount.
{"account_id": "usr_123", "kind": "converted", "value": 20}
POST /v1/accounts/usage (first 72 hours)
Farmed accounts often look human at signup but burn their free credits within minutes, in waves from one network. Report usage, ideally when an account runs out, and we'll say whether to hold what's left.
{"account_id": "usr_123", "credits_used": 100, "credits_total": 100}
→ {"recommendation": "hold_remaining", "reasons": [{"code": "usage.fast_burn", …}, {"code": "usage.burn_wave", …}],
"minutes_since_signup": 6.2, "share_used": 1.0}
recommendation is ok, watch or hold_remaining. It's advice:
nothing is blocked. After 72 hours the answer is always ok with "window": "closed".
POST /v1/accounts/events
Farms run a script after signup: the same steps at the same moments. Send a few of your own events per account
as they happen (for example first_project, used_credits, invited_friend):
short names of letters, digits and _ . : -, never personal data. Once an account has three steps
after its first 10 seconds, we compare their timing with your other recent signups. People doing the same steps
take their own time; accounts that match to within a few seconds are in lockstep.
{"account_id": "usr_123", "event": "first_project"}
→ {"recommendation": "hold_remaining", "reasons": [{"code": "events.lockstep", …}],
"matching_accounts": ["usr_118", "usr_119", "usr_121"], "events_recorded": 3}
at (Unix seconds) is optional, for events you send later. Up to 50 events are kept per account and
follow your retention setting. Lockstep accounts also appear together on the dashboard's Farms card.
Child accounts
If your service has child accounts, such as profiles a parent creates inside a family account, check them through the adult they belong to. We receive nothing about the child. You decide who is a child, from your own age check or a platform's signal such as Apple Family Sharing, Google Play age signals or an Amazon household profile. honeyp.ai never estimates anyone's age.
POST /v1/signups/assess
{"account_type": "child", "parent_account_id": "u_123", "source": "apple_family"}
- Put the browser snippet on the page where the account holder signs up. Child profiles created later are checked from your server, with no snippet.
- Send only
account_type,parent_account_idand an optionalsource(a short label for where your child signal came from). A check that also carries an email, IP address, user agent, browser token, device, time zone or the child's own account ID is refused with422 child_data_not_accepted, and nothing is stored. - The answer follows the adult's own check. A child under an adult who was flagged or labelled abuse gets the same, a child whose adult we haven't checked is held for review, and an adult adding 5 or more child accounts in a week has their credits reduced (10 or more: held).
- We keep only the adult's account ID, the source and the time. In scans, rows with
account_typechildare skipped without being read; addparent_account_idto count them against their adult.
Scan existing accounts inside your network
Accounts that signed up before honeyp.ai was protecting you can be scanned without exporting anything. The honeyp.ai scanner is a container you run on your own servers. It reads your accounts with one read-only query against your database (PostgreSQL, MySQL, Snowflake, BigQuery or SQLite) or a CSV file, pseudonymises each account on that machine (salted fingerprints, networks and email domains), has honeyp.ai analyse those rows without keeping them, and writes the report there. Emails, IP addresses and account IDs never leave your network.
docker run --rm -v "$PWD/results:/out" \
-e HONEYP_SOURCE="postgresql://readonly_user@db.internal:5432/app?sslmode=require" \
-e HONEYP_DB_PASSWORD -e HONEYP_SECRET_KEY -e HONEYP_QUERY_FILE=/out/accounts.sql \
honeyp-scanner scan
Your query returns one row per account: account_id and created_at, plus email,
ip, credits_used and converted if you have them. Run
check instead of scan to test the connection first.
To see the results in your dashboard, add --upload. Only what's in
upload.json is sent: counts, reasons, and the flagged accounts' IDs, scores and email domains. Review that
file before you turn uploads on. The scanner comes with the Growth and Enterprise plans (and the free trial), on request: email support@honeyp.ai
and we'll send it with a setup guide for your security team.
Reading your data
GET /v1/assessments?limit=50: your most recent checks (up to 500).GET /v1/stats?days=7: decision counts and how labelled accounts were decided.
Privacy requests
POST /v1/privacy/subjects/lookupwith{"email": …}and/or{"account_id": …}: what we hold about one of your users.POST /v1/privacy/subjects/delete: the same, and erase it.
Errors
| Status | Meaning |
|---|---|
400 | The body isn't valid JSON. |
401 | Missing, wrong or revoked secret key. |
413 | The request is too large. |
422 | A field is invalid; detail says which. |
5xx | Our side: fail open and carry on. |
Errors look like {"error": "invalid_account_id", "detail": "…"}.
Node.js and Next.js package
@honeyp/node wraps the API with a short timeout and fails open. No dependencies; Node 18+.
Until it's on npm, design partners get it from us directly.
import { Honeyp, requestInfo } from "@honeyp/node";
const honeyp = new Honeyp({ secretKey: process.env.HONEYP_SECRET_KEY }); // sk_live_..., server only
const result = await honeyp.assess({ accountId: user.id, email, clientToken, ...requestInfo(req) });
await grantCredits(user, honeyp.credits(result, 100)); // 100, 20 or 0 by default
Call assess once per new account. A second call for the same person looks like a repeat signup.
Guides
Next.js (App Router)
// app/api/signup/route.js
import { Honeyp, requestInfo } from "@honeyp/node";
const honeyp = new Honeyp({ secretKey: process.env.HONEYP_SECRET_KEY });
export async function POST(req) {
const { email, password, clientToken } = await req.json();
const user = await createUser(email, password); // your existing signup
const result = await honeyp.assess({ accountId: user.id, email, clientToken, ...requestInfo(req) });
await grantCredits(user.id, honeyp.credits(result, 100));
return Response.json({ ok: true, credits: honeyp.credits(result, 100), verifyToUnlock: result.decision === "allow_reduced" });
}
Supabase
The Before User Created auth hook runs before the account exists. Pass the browser token in the sign-up metadata, then point the hook at an Edge Function.
await supabase.auth.signUp({ email, password, options: { data: { honeyp_token: clientToken } } });
// supabase/functions/honeyp-check/index.ts (verify the hook's signature as Supabase documents)
import { Honeyp } from "npm:@honeyp/node";
const honeyp = new Honeyp({ secretKey: Deno.env.get("HONEYP_SECRET_KEY") });
Deno.serve(async (req) => {
const { user, metadata } = await req.json();
const result = await honeyp.assess({
accountId: user.id, email: user.email, ip: metadata?.ip_address,
clientToken: user.user_metadata?.honeyp_token, signupMethod: user.user_metadata?.honeyp_token ? "web" : "api",
});
if (result.decision === "block") {
return Response.json({ error: { http_code: 403, message: "We couldn't create this account. Contact support." } });
}
// Save the tier for when you grant credits, e.g. in a honeyp_decisions table, using the service role key.
await saveDecision(user.id, honeyp.credits(result, 100));
return Response.json({});
});
Check the hook's payload fields against Supabase's current documentation.
Firebase
A blocking function runs before the user is created and can set custom claims. Firebase's client SDKs can't pass the browser token in, so email, network and browser checks apply without the snippet.
import { beforeUserCreated, HttpsError } from "firebase-functions/v2/identity";
import { Honeyp } from "@honeyp/node";
const honeyp = new Honeyp({ secretKey: process.env.HONEYP_SECRET_KEY });
export const honeypCheck = beforeUserCreated(async (event) => {
const user = event.data;
const result = await honeyp.assess({ accountId: user.uid, email: user.email, ip: event.ipAddress,
userAgent: event.userAgent, signupMethod: "api" });
if (result.decision === "block") throw new HttpsError("permission-denied", "We couldn't create this account.");
return { customClaims: { free_credits: honeyp.credits(result, 100) } };
});
Clerk
Clerk has no hook before an account is created, so check right after sign-up from your own route and keep the
credits in Clerk metadata. The free_credits check makes it safe to call twice.
// app/api/after-signup/route.js (call it from the page once Clerk's sign-up completes, with the browser token)
import { auth, clerkClient } from "@clerk/nextjs/server";
import { Honeyp, requestInfo } from "@honeyp/node";
const honeyp = new Honeyp({ secretKey: process.env.HONEYP_SECRET_KEY });
export async function POST(req) {
const { userId } = await auth();
if (!userId) return new Response("Not signed in", { status: 401 });
const client = await clerkClient();
const user = await client.users.getUser(userId);
if (user.publicMetadata.free_credits !== undefined) return Response.json({ ok: true }); // checked already
const { clientToken } = await req.json();
const result = await honeyp.assess({ accountId: userId, email: user.primaryEmailAddress?.emailAddress,
clientToken, ...requestInfo(req) });
await client.users.updateUserMetadata(userId, { publicMetadata: { free_credits: honeyp.credits(result, 100),
honeyp_decision: result.decision } });
return Response.json({ ok: true });
}
A Python client (HoneypClient) ships with the platform too.