# Validate tokens

> Redeem every token with POST /v1/siteverify before you act on a request. Request, response, error codes, single use and safe retries.

Your server receives a token with each protected request (the
`agentgate_token` form field, or wherever your page put it) and must redeem
it with AgentGate before doing anything with the request.

> [!IMPORTANT]
> Server-side validation is mandatory. The widget and `execute()` only
> produce a token; a token that is never redeemed protects nothing. Refuse
> the request whenever `success` is not `true`.

## Request

```http
POST /v1/siteverify HTTP/1.1
Host: agentgate.example
Authorization: Bearer ags_…
Content-Type: application/json

{"token": "agr1.…", "expectedAction": "signup", "expectedOrigin": "https://app.example.com", "requestId": "order-7f3a"}
```

Authenticate with the site's backend secret, either as
`Authorization: Bearer ags_…` (preferred) or as a `secret` field in the body.
If both are sent they must be equal. The body is JSON, at most 4 KiB.

| Field | Required | Meaning |
| --- | --- | --- |
| `token` | yes | the token from the page |
| `expectedAction` | yes | the action you expect this token to be for, for example `signup` |
| `expectedOrigin` | no, recommended | the origin of the page that should have produced it, `https://host[:port]` |
| `requestId` | no, recommended | your ID for this operation, `[A-Za-z0-9._:-]{1,128}`; makes retries safe |
| `secret` | no | the backend secret, when you do not send `Authorization` |

`expectedAction` is required so that a token earned on one form can never
authorise a different operation.

## Response

Always JSON with `success`.

```json title="Success"
{
  "success": true,
  "decision": "allow",
  "decisionId": "dec_…",
  "action": "signup",
  "origin": "https://app.example.com",
  "issuedAt": 1790000000000,
  "mode": "enforce"
}
```

```json title="Failure"
{
  "success": false,
  "error": "token_used",
  "message": "receipt already redeemed"
}
```

| Field | Meaning |
| --- | --- |
| `success` | `true` only when the token is valid, unspent, for this action and origin, and now redeemed |
| `decision` | `allow` on success |
| `decisionId` | the decision that issued the token; find it in the dashboard |
| `action`, `origin` | what the token was issued for |
| `issuedAt` | when the token was issued, Unix milliseconds |
| `mode` | the site's mode: `monitor` or `enforce` |
| `wouldDecision` | monitor mode only, when enforce mode would not have allowed: `challenge` or `block` |
| `idempotentRetry` | `true` when this answer repeats an earlier success for the same `requestId` |
| `test` | `true` for [testing secrets](/docs/testing) |
| `error`, `message` | on failure: a stable code and a human-readable message |

## Errors

| HTTP | `error` | Meaning | What to do |
| --- | --- | --- | --- |
| 400 | `invalid_request` | malformed JSON, missing `token` or `expectedAction`, or a bad `requestId` | fix the call |
| 401 | `invalid_credential` | the secret is missing, malformed, revoked, or belongs to another site | check `AGENTGATE_SECRET` |
| 422 | `invalid_token` | not a token, bad signature, unknown site or key | refuse the request |
| 422 | `expired_token` | the token is past its expiry | refuse; the page should verify again |
| 422 | `action_mismatch` | the token was issued for another action | refuse |
| 422 | `origin_mismatch` | the token was issued to another origin | refuse |
| 409 | `token_used` | already redeemed (by another `requestId`) | refuse |
| 503 | `unavailable` | AgentGate's store failed | retry with the same `requestId` (`Retry-After: 5`) |

The order of checks is: secret format, token signature, secret against the
token's site, expiry, action, origin, then atomic redemption. A mismatch or
an expired token is never redeemed, so it cannot burn a token that a
correct call would have accepted. Every code, including the browser and
gateway ones, is on [Error codes](/docs/error-codes).

## Single use and safe retries

- **Atomic.** Of any number of concurrent redemptions of one token, exactly
  one succeeds; the others get `409 token_used`.
- **Idempotent with `requestId`.** If the response to a successful
  redemption is lost, call again with the **same** `requestId`: within the
  token's lifetime AgentGate returns the same success with
  `"idempotentRetry": true`. A different or missing `requestId` gets
  `token_used`.
- **Not a second operation.** An idempotent retry confirms the first
  redemption. Your code must still perform the operation once.
- **Lifetime.** Tokens expire `RECEIPT_TTL_SECONDS` after issue (default
  120, allowed 30–300). Verify at submit time and redeem promptly.

## Examples

```sh title="curl"
curl -sS https://agentgate.example/v1/siteverify \
  -H "Authorization: Bearer $AGENTGATE_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"token":"agr1.…","expectedAction":"signup","expectedOrigin":"https://app.example.com","requestId":"order-7f3a"}'
```

```js title="Node"
// Node 18+ (global fetch)
export async function verifyAgentGate(token, requestId) {
  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, expectedAction: "signup", expectedOrigin: "https://app.example.com", requestId }),
    signal: AbortSignal.timeout(5000),
  });
  const v = await r.json();
  if (!v.success) throw new Error("agentgate: " + v.error); // do not perform the operation
  return v;
}
```

```python title="Python"
import os
import requests

def verify_agentgate(token: str, request_id: str) -> dict:
    r = requests.post(
        "https://agentgate.example/v1/siteverify",
        headers={"Authorization": "Bearer " + os.environ["AGENTGATE_SECRET"]},
        json={"token": token, "expectedAction": "signup",
              "expectedOrigin": "https://app.example.com", "requestId": request_id},
        timeout=5,
    )
    v = r.json()
    if v.get("success") is not True:
        raise PermissionError(v.get("error"))  # do not perform the operation
    return v
```

```go title="Go"
func verifyAgentGate(ctx context.Context, token, requestID string) error {
	body, _ := json.Marshal(map[string]string{
		"token": token, "expectedAction": "signup",
		"expectedOrigin": "https://app.example.com", "requestId": requestID,
	})
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://agentgate.example/v1/siteverify", bytes.NewReader(body))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+os.Getenv("AGENTGATE_SECRET"))
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer resp.Body.Close()
	var v struct {
		Success bool   `json:"success"`
		Error   string `json:"error"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
		return err
	}
	if !v.Success {
		return fmt.Errorf("agentgate: %s", v.Error) // do not perform the operation
	}
	return nil
}
```

Ready-made helpers that also handle the gateway are in the repository:
`examples/go-middleware` (Go `net/http`) and `examples/express`
(Node / Express). See [Go and Node middleware](/docs/gateway-middleware).

## When AgentGate is unreachable

Decide explicitly what your backend does when the call fails (timeout,
connection error, `503`). The recommended default mirrors AgentGate's own
modes: refuse (fail closed) for sites in enforce mode, accept and log (fail
open) for sites in monitor mode. Retry a `503` once with the same
`requestId`.

## Test without a browser

Use the [testing secrets](/docs/testing): `ags_test_always_pass`,
`ags_test_always_fail` (`422 invalid_token`) and `ags_test_token_spent`
(`409 token_used`) return fixed answers for test tokens, so you can
exercise both paths of your handler in unit tests.
