# Migrate from Turnstile

> Replace a Cloudflare Turnstile widget and its siteverify call with AgentGate, attribute by attribute and field by field.

AgentGate's widget and server check follow the same shape as Turnstile: a
script, an element with a site key inside your form, a token in a hidden
field, and a server-side call that redeems it once. Most migrations change
a handful of lines.

> [!NOTE]
> Turnstile names below are from Cloudflare's public documentation. Check
> them against the version you use.

## 1. Create a site

Add your site in the [console](/docs/get-started#1-create-a-site). You get
a site key (`site_…`) and a backend secret (`ags_…`). Configure every
origin that serves the form, and an **action** for each form: unlike
Turnstile, AgentGate requires an action and checks it at validation.

## 2. Swap the widget

```html title="Before (Turnstile)"
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form method="post" action="/signup">
  <div class="cf-turnstile" data-sitekey="0x4AAA…" data-action="signup" data-theme="light"></div>
  <button>Sign up</button>
</form>
```

```html title="After (AgentGate)"
<script src="https://agentgate.example/sdk/v1/agentgate.js" async></script>
<form method="post" action="/signup">
  <div class="agentgate" data-sitekey="site_…" data-action="signup" data-mode="managed"></div>
  <button>Sign up</button>
</form>
```

| Turnstile | AgentGate | Notes |
| --- | --- | --- |
| `class="cf-turnstile"` | `class="agentgate"` | |
| `data-sitekey` | `data-sitekey` | |
| `data-action` | `data-action` | required; `[a-z0-9_]{1,64}`, configured on the site |
| `data-language` | `data-lang` | `en`, `es`, `fr`, `de`, `pt`, `hi`, `ja`, `zh` |
| widget mode (managed, non-interactive, invisible) and `data-appearance` | `data-mode`: `managed`, `invisible`, `interactive` | set per widget, not in a dashboard |
| `data-callback`, `data-error-callback`, `data-expired-callback` | `callback`, `errorCallback`, `expiredCallback` options of `AgentGate.render()` | the declarative widget has no callback attributes |
| `data-response-field-name` (default `cf-turnstile-response`) | fixed: `agentgate_token` | an existing field with that name is reused |
| `data-refresh-expired` | automatic | the token is cleared before expiry and the widget verifies again at submit |
| `data-execution="execute"` | always | the widget verifies when the form is submitted |
| `data-theme`, `data-size` | none | the widget uses system colours |
| `data-cdata` | none | keep your own data server-side |
| `data-timeout-callback` | `errorCallback` with code `timeout` | |

JavaScript API:

| Turnstile | AgentGate |
| --- | --- |
| `turnstile.render(el, opts)` | `AgentGate.render(el, opts)` |
| `turnstile.execute(el)` | `await AgentGate.execute({action})`: resolves with the token directly |
| `turnstile.getResponse(id)` | `AgentGate.getResponse(id)` |
| `turnstile.reset(id)` | `AgentGate.reset(id)` |
| `turnstile.remove(id)` | `AgentGate.remove(id)` |
| `?render=explicit&onload=cb` | call `AgentGate.init()` / `render()` after the script loads |

If your page sets a CSP, replace `challenges.cloudflare.com` with your
AgentGate endpoint in `script-src` and `connect-src`, and add
`worker-src blob:` ([details](/docs/content-security-policy)). AgentGate
uses no iframe, so `frame-src` needs nothing.

## 3. Swap the server call

```js title="Before (Turnstile)"
const form = new URLSearchParams({ secret: process.env.TURNSTILE_SECRET,
  response: body["cf-turnstile-response"], remoteip: ip, idempotency_key: opId });
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body: form });
const v = await r.json();
if (!v.success || v.action !== "signup") return reject(v["error-codes"]);
```

```js title="After (AgentGate)"
const r = await fetch("https://agentgate.example/v1/siteverify", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: "Bearer " + process.env.AGENTGATE_SECRET },
  body: JSON.stringify({ token: body.agentgate_token, expectedAction: "signup",
    expectedOrigin: "https://app.example.com", requestId: opId }),
});
const v = await r.json();
if (!v.success) return reject(v.error);
```

| Turnstile request | AgentGate request | Notes |
| --- | --- | --- |
| `secret` | `Authorization: Bearer ags_…` (or `secret`) | |
| `response` | `token` | |
| `remoteip` | none | AgentGate saw the client itself |
| `idempotency_key` | `requestId` | a retry with the same ID returns the same success |
| form-encoded or JSON | JSON only | send `Content-Type: application/json` |
| check `action` yourself | `expectedAction` (required) | AgentGate refuses a mismatch with `action_mismatch` |
| check `hostname` yourself | `expectedOrigin` | a full origin, `https://host[:port]` |

| Turnstile response | AgentGate response |
| --- | --- |
| `success` | `success` |
| `challenge_ts` (ISO time) | `issuedAt` (Unix milliseconds) |
| `hostname` | `origin` |
| `action` | `action` |
| `cdata` | none |
| `error-codes` (array) | `error` (one code) |

| Turnstile error | AgentGate error |
| --- | --- |
| `missing-input-secret`, `invalid-input-secret` | `invalid_credential` (401) |
| `missing-input-response`, `bad-request` | `invalid_request` (400) |
| `invalid-input-response` | `invalid_token` (422) |
| `timeout-or-duplicate` | `expired_token` (422) or `token_used` (409) |
| `internal-error` | `unavailable` (503) |

## 4. Differences to plan for

- **Token lifetime.** Two minutes by default (Turnstile: five). Verify at
  submit time; the widget already does.
- **Pre-clearance.** Turnstile's pre-clearance cookie corresponds to
  AgentGate [clearances](/docs/tokens-and-clearances#clearances), accepted
  by the AgentGate gateway on clearance actions.
- **Monitor mode.** New sites start in monitor mode: every valid token
  passes and `wouldDecision` shows what enforce would have done. Switch to
  enforce when you are satisfied.
- **Testing keys.** Turnstile's dummy keys map to
  `site_test_always_pass`, `site_test_always_fail`,
  `site_test_force_interactive` and the secrets `ags_test_always_pass`,
  `ags_test_always_fail`, `ags_test_token_spent` ([Testing](/docs/testing)).
- **Agents.** AgentGate also admits AI agents that sign their requests; see
  [Signed agents](/docs/signed-agents).
