# OneTrial full integration reference ## How it works 1. The browser snippet (https://onetrial.dev/v1.js, 15 KB gzipped, async) computes a device hint and passive signals and exchanges them for a signed **visitorToken** (JWT, 5-minute TTL). It never shows UI. 2. On form submit, your frontend reads `await OneTrial.getToken()` and sends it with the signup to your backend. 3. Your backend calls `POST /api/v1/decisions` with the token, email, and client IP (plus `cardFingerprint` and `userId` when you have them). 4. You get `{ decision: "allow" | "challenge" | "deny", score, reasons[], challengeOptions[], challengeId?, challengeUrl? }`. - allow: create the trial. - challenge: send the visitor to `challengeUrl` (hosted) or run the headless flow; unlock the trial on the `challenge.completed` webhook. - deny: show a paid plan or a support contact. Never expose score or reasons to the visitor. 5. Coupon and promo redemptions use the same endpoint with `event: "promo"` and a `promoCode`. They are never counted as consumed trials, so scoring one cannot make a paying customer look like a repeat. 6. New workspaces start in **shadow mode**: every signup is scored and recorded but the API returns allow (`shadow: true, wouldBe: "..."`). Flip to live from the dashboard or `PATCH /api/v1/settings {"shadowMode": false}`. Secrets: `ot_test_...` / `ot_live_...` keys are server-side only. `ot_pk_...` is public and goes in the snippet. ## Endpoints (Authorization: Bearer ot_test_... unless noted) | method | path | purpose | |---|---|---| | POST | /api/v1/visitor-token | browser, no auth: mints the visitorToken (called by the snippet) | | POST | /api/v1/decisions | score a signup -> decision | | GET | /api/v1/decisions?decision=&email=&days=&cursor= | list decisions | | GET | /api/v1/decisions/{id} | one decision with reasons | | POST | /api/v1/decisions/{id}/feedback | { label: "fp" \| "tp", note? } | | POST | /api/v1/signals/card | push a card fingerprint from your Stripe | | GET | /api/c/{challengeId} | visitor-safe challenge status (no auth) | | POST | /api/v1/challenges/{id}/complete | headless completion | | GET/POST/DELETE | /api/v1/allowlist, /api/v1/blocklist | { type: email\|domain\|device\|ip\|card, value } | | GET/POST/DELETE | /api/v1/webhooks | endpoints; POST returns the secret once | | GET | /api/v1/integration/status | machine-readable checklist | | GET/PATCH | /api/v1/settings | thresholds { allow, challenge }, shadowMode, stack, planPriceCents | | POST | /api/v1/keys | mint another key (test from test; live needs live) | | POST | /api/v1/connect/stripe/link | Stripe Connect authorize link | | POST | /api/device/code, /api/device/token | device authorization for CLI/MCP (no auth) | Decision response: { decisionId, decision, score (0-100), reasons: [{code, message, weight}], challengeOptions: ["require_card"|"email_verify"|"captcha"], challengeId?, challengeUrl?, shadow?, wouldBe?, usage: {used, limit, warn} } ## Errors Every 4xx/5xx body is `{ "error": { "code", "message", "fix" } }`. `fix` is the exact corrective step; agents should act on it. | status | code | fix | |---|---|---| | 401 | missing_api_key / invalid_api_key | Send `Authorization: Bearer ot_test_...` from the dashboard | | 400 | validation_error | The message names the field; correct it | | 400 | invalid_email / invalid_ip | Send the raw email and the client IP (from x-forwarded-for) | | 429 | plan_limit_reached | Free plan cap hit this month; upgrade under Billing | | 409 | challenge_resolved | The challenge already passed or failed; request a new decision | | 409 | stripe_not_connected | Connect Stripe or push fingerprints to POST /api/v1/signals/card | ## Webhooks Register: `POST /api/v1/webhooks {"url": "https://api.yourapp.com/onetrial", "events": []}` (empty = all). The signing secret (`whsec_...`) is returned once. Events: `decision.created`, `challenge.completed`, `challenge.failed`. Body: `{ id, type, createdAt, data }`. Header `OneTrial-Signature: t=,v1=.">`. Three attempts with backoff; endpoint must be public https. Verify (Node): ```js import { createHmac, timingSafeEqual } from 'node:crypto'; export function verify(secret, rawBody, header) { const m = /t=(\d+),v1=([0-9a-f]+)/.exec(header ?? ''); if (!m || Math.abs(Date.now() / 1000 - Number(m[1])) > 300) return false; const expected = createHmac('sha256', secret).update(`${m[1]}.${rawBody}`).digest(); const given = Buffer.from(m[2], 'hex'); return expected.length === given.length && timingSafeEqual(expected, given); } ``` On `challenge.completed` with `data.outcome === "passed"`, unlock the trial for `data.decisionId`. On `challenge.failed`, treat as deny. ## Card fingerprints Card fingerprints are scoped to a Stripe account, so cards must be collected on **your** account. Two options: - **Connect** (recommended): Settings -> Connect Stripe. Hosted card challenges create the SetupIntent on your account; the fingerprint is harvested and the decision re-scored automatically. - **Push**: keep your own Stripe flow and forward fingerprints from your `setup_intent.succeeded` / `payment_method.attached` webhook: `POST /api/v1/signals/card {"decisionId": "...", "cardFingerprint": ""}` If the decision has a pending challenge this completes it (repeat card -> deny). Otherwise the fingerprint is attached for future matching. Card numbers never reach OneTrial. ## Headless challenges Skip the hosted page and run your own UI: - `GET /api/c/{challengeId}` (no auth) returns `{ status, options, workspaceName }`, nothing sensitive. - Complete from your backend: `POST /api/v1/challenges/{challengeId}/complete {"method": "email_verify" | "captcha" | "sms_verify" | "require_card", "outcome": "passed" | "failed", "cardFingerprint"?: "..."}`. - `require_card` with a fingerprint re-scores: a repeat card escalates to deny and the response carries both scores. ## Plans Free 500 decisions/mo (hard cap, 429), Starter $49 2,500, Growth $199 15,000, Scale $499 75,000. Usage in every decision response (`usage`). --- # OneTrial with Next.js (App Router) 1. Snippet on the signup page: ```html ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in a Route Handler (server): ```ts // app/api/signup/route.ts import { headers } from 'next/headers'; export async function POST(req: Request) { const { email, visitorToken } = await req.json(); const ip = (await headers()).get('x-forwarded-for')?.split(',')[0] ?? '0.0.0.0'; const userId = await createUser(email); const 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 webhook createTrial(userId); return Response.json({ ok: true }); } ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it. Snippet placement: `app/layout.tsx` -> ` ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in your signup handler: ```ts app.post('/signup', async (req, res) => { const { email, visitorToken } = req.body; const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0] ?? req.socket.remoteAddress; const userId = await createUser(email); const 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 webhook createTrial(userId); res.json({ ok: true }); }); ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it. --- # OneTrial with Django 1. Snippet on the signup page: ```html ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in the signup view: ```python # views.py def signup(request): email = request.POST["email"]; visitor_token = request.POST.get("visitorToken") ip = request.META.get("HTTP_X_FORWARDED_FOR", request.META["REMOTE_ADDR"]).split(",")[0] user_id = create_user(email) import os, requests r = requests.post("https://onetrial.dev/api/v1/decisions", headers={"Authorization": f"Bearer {os.environ['ONETRIAL_API_KEY']}"}, json={"visitorToken": visitor_token, "email": email, "ip": ip, "userId": user_id}, timeout=5) d = r.json() if d["decision"] == "deny": return paid_plan_or_support() if d["decision"] == "challenge": return redirect(d["challengeUrl"]) # trial unlocks on the webhook create_trial(user_id) return JsonResponse({"ok": True}) ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it. --- # OneTrial with Rails 1. Snippet on the signup page: ```html ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in SignupsController#create: ```ruby # app/controllers/signups_controller.rb def create user = User.create!(email: params[:email]) visitor_token = params[:visitorToken] res = Net::HTTP.post(URI("https://onetrial.dev/api/v1/decisions"), { visitorToken: visitor_token, email: email, ip: request.remote_ip, userId: user.id }.to_json, "Authorization" => "Bearer #{ENV.fetch('ONETRIAL_API_KEY')}", "Content-Type" => "application/json") d = JSON.parse(res.body) return render_paid_plan if d["decision"] == "deny" return redirect_to d["challengeUrl"], allow_other_host: true if d["decision"] == "challenge" create_trial(user) end ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it. --- # OneTrial with Laravel 1. Snippet on the signup page: ```html ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in the signup controller: ```php // app/Http/Controllers/SignupController.php public function store(Request $request) { $email = $request->input('email'); $user = User::create([...]); $res = Http::withToken(env('ONETRIAL_API_KEY'))->post('https://onetrial.dev/api/v1/decisions', [ 'visitorToken' => $request->input('visitorToken'), 'email' => $email, 'ip' => $request->ip(), 'userId' => $user->id, ])->json(); if ($res['decision'] === 'deny') return view('paid-plan'); if ($res['decision'] === 'challenge') return redirect()->away($res['challengeUrl']); $this->createTrial($user); return response()->json(['ok' => true]); } ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it. --- # OneTrial with Supabase Auth 1. Snippet on the signup page: ```html ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in the step after auth.signUp: ```ts // Supabase Edge Function or your server, AFTER supabase.auth.signUp succeeds: const { data: { user } } = await supabase.auth.signUp({ email, password }); const userId = user!.id; const 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 webhook createTrial(userId); // On deny: supabase.auth.admin.deleteUser(userId) or leave the account without a trial. ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it. --- # OneTrial with Clerk 1. Snippet on the signup page: ```html ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in the user.created webhook (or server action): ```ts // Clerk webhook user.created -> decide, or in your server action right after createUser const userId = evt.data.id; const email = evt.data.email_addresses[0].email_address; const visitorToken = evt.data.unsafe_metadata?.visitorToken; // set it from the sign-up form const 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 webhook createTrial(userId); ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it. Pass the visitor token through sign-up: `signUp.create({ emailAddress, password, unsafeMetadata: { visitorToken: await OneTrial.getToken() } })`. --- # OneTrial with Auth0 1. Snippet on the signup page: ```html ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in a Post-Registration Action or your app backend: ```ts // Auth0 Post-Registration Action or your backend after the callback const userId = event.user.user_id; const email = event.user.email; const visitorToken = event.request.query?.visitorToken; // forward it via loginWithRedirect({ authorizationParams: { visitorToken } }) const 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 webhook createTrial(userId); ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it. --- # OneTrial with Firebase Auth 1. Snippet on the signup page: ```html ``` 2. Send the token with the form: ```js // Signup form (browser): include the token in your POST const token = await OneTrial.getToken(); // window.OneTrial is set by the snippet OneTrial.markSubmit(); // records time-to-submit formData.append('visitorToken', token); ``` 3. Decide in a blocking function or your backend: ```ts // Cloud Function (beforeUserCreated blocking function) or your backend after createUserWithEmailAndPassword const userId = user.uid; const email = user.email!; const 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 webhook createTrial(userId); ``` 4. Env: `ONETRIAL_API_KEY=ot_test_...` (server only). Test keys hit the same engine; switch to `ot_live_` when you go live. 5. Verify: `GET /api/v1/integration/status` returns a checklist. Shadow mode is on until you flip it.