Test vs live keys
Foil issues separate test and live key pairs.- Test keys are prefixed
pk_test_*/sk_test_*; live keys arepk_live_*/sk_live_*. - Sessions created with test keys are tagged in the dashboard so you can filter integration traffic out of real metrics.
- Test and live keys are not interchangeable. A sealed token issued under a test publishable key cannot be verified with a live secret key — verification fails locally with a
FoilTokenVerificationError. Keep the environments fully separate. - Everything else — verdicts, scoring, phases, fingerprinting — behaves identically across environments.
What verdicts to expect
An unmodified automation runner should land onbot every time. Stealth variants still land on bot but with lower confidence, and real user traffic should land on human.
| Runner | Expected decision.verdict | Notes |
|---|---|---|
| Playwright (default Chromium) | bot, high risk_score | webdriver_detected fires deterministically. |
| Puppeteer (default) | bot, high risk_score | Same. |
| Selenium / WebDriver | bot, high risk_score | Same. |
playwright-stealth, puppeteer-extra-stealth | bot, lower risk_score | attribution.bot.facets.concealment_style typically stealth. |
Headless Chromium via --headless=new with no automation library | bot | Environment probes catch it; no behavioral signal needed. |
| Real browser with real user interaction before submit | human, phase: "behavioral" | Behavioral signals accumulate once the user moves and types. |
getSession() is being called against a different Foil.start() instance than the one your page initialized.
Runnable Playwright example
This asserts end-to-end that a bot gets blocked at signup:npx playwright test against your local dev server. If the assertion passes, your block path is wired correctly. Flip it to expect(response.status()).toBe(200) and run against your staging build to verify the allow path for manually-driven sessions.
For Puppeteer, Selenium, or WebDriverIO, the pattern is the same — launch, navigate, submit, assert the response. Foil detects them all.
Forcing an inconclusive verdict
inconclusive is Foil saying “not enough signal either way.” The easiest way to produce one on purpose is to call getSession() immediately on page load, before any user interaction:
inconclusive identically to human, you’re leaving coverage on the table; see Verdicts & scoring for the recommended policy split.
Debugging common issues
| Symptom | Cause | Fix |
|---|---|---|
getSession() rejects with runtime.unavailable | Method called before start() resolved, or after destroy() | Await the start() promise before any client method. See Browser SDK → API reference. |
start() rejects with runtime.bootstrap_failed | CSP blocks cdn.usefoil.com or wasm-eval, or the CDN load fails | See Content Security Policy. |
| Sealed token verification fails locally with no clear reason | Mismatched environment — test-key token verified with a live secret key (or vice versa) | Confirm pk_* and sk_* come from the same organization and environment. |
Handoff verifies locally but requests to api.usefoil.com return auth.origin_not_allowed | The publishable key’s allowed-origin list doesn’t include your dev host | Add http://localhost:3000 (or your dev origin) to the key’s allowed origins in the dashboard, or use an unrestricted test key. |
Server receives sealedToken: undefined | Request-body shape mismatch | Confirm the browser sends JSON with a foil: { sessionId, sealedToken } field, and that your body parser runs before your verification middleware. |
Most real test sessions score as inconclusive | Page flow calls getSession() before any interaction accumulates | Call getSession() at action time (right before the fetch), not on page load. See Browser SDK → Best practices. |
What’s next
- Going to production — rollout checklist and monitoring
- Verdicts & scoring — policy patterns for each verdict band
- Troubleshooting — production error triage