---
title: Migrate from v0 to v1
description: Move a Gates integration from the /v0 decision endpoint to the /v1 response shape.
---

Integrations built during the early beta call `POST /v0/gates/{gate_id}/decisions`. The current endpoint is `POST /v1/gates/{gate_id}/decisions`: the same decision, returned in a flatter shape.

`/v0` keeps working indefinitely, so nothing forces this migration. Migrating is a matter of changing the version segment in the URL and updating where your code reads a few fields; the request body, authentication, [credit cost](/docs/get-started/credits), and the decisions themselves are identical on both paths. The same input gets the same verdict from both versions.

## What changed

`/v0` wraps the verdict in a `decision` object and the request metadata in a `meta` object:

```json
{
  "input": {
    "email": "octocat@github.com",
    "ip": "1.1.1.1"
  },
  "decision": {
    "action": "allow",
    "matched_rule": {
      "id": "01k1hxqrn2f7xfreq20y0p012d",
      "name": "Allow professional email providers",
      "message": null
    }
  },
  "signals": {
    "email": { ... },
    "domain": { ... },
    "ip": { ... }
  },
  "meta": {
    "version": "0.1",
    "request_id": "01K1SKM0ZDCEEES5QA9N3M5EPX",
    "duration_ms": 52.62,
    "created_at": "2025-08-04T03:57:18+00:00"
  }
}
```

`/v1` returns the same decision as a flat object, the verdict at the top:

```json
{
  "id": "01K1SKM0ZDCEEES5QA9N3M5EPX",
  "gate_id": "01k2wjgq8rnv6t0aefy3s7xdhb",
  "action": "allow",
  "rule": {
    "id": "01k1hxqrn2f7xfreq20y0p012d",
    "name": "Allow professional email providers",
    "message": null
  },
  "input": {
    "email": "octocat@github.com",
    "ip": "1.1.1.1"
  },
  "signals": {
    "email": { ... },
    "domain": { ... },
    "ip": { ... }
  },
  "duration_ms": 52.62,
  "created_at": "2025-08-04T03:57:18+00:00"
}
```

Field by field:

| /v0 | /v1 |
|---|---|
| `decision.action` | `action` |
| `decision.matched_rule` | `rule` |
| `meta.request_id` | `id` |
| `meta.duration_ms` | `duration_ms` |
| `meta.created_at` | `created_at` |
| `meta.version` | Removed. The URL carries the version. |
| Not returned | `gate_id` |
| `input`, `signals` | Unchanged, same content and position in your parsing. |

Two of these are more than a rename:

- **`rule` is always present.** `/v0` omits `matched_rule` when no rule matched and the gate's `default_action` applied. `/v1` always returns the `rule` key, `null` in that case. Code that checks whether `matched_rule` exists becomes a check for `rule` not being `null`.
- **`id` is the decision's handle.** It carries the value `/v0` reported as `meta.request_id`, and it is the `{decision_id}` the [Management API](/docs/gates/management-api#get-a-decision) accepts to read the decision back later.

Error handling changes in these cases:

| Case | /v0 | /v1 |
|---|---|---|
| Invalid or missing input: `missing_input`, `invalid_email`, `invalid_domain`, `both_email_and_domain_provided` | `400` | `422` |
| Application throughput limit exceeded | `429` with `error: "Too many requests"` | `429 rate_limit_exceeded` |
| Monthly credits used up | `429 rate_limit_exceeded` | `429 quota_exceeded` |

[Errors](/docs/gates/errors) covers the `/v1` contract in full.

## Update your integration

For successful decision responses, change `/v0/` to `/v1/` in the URL and move the field reads. Handle failures using the [error-handling policy](/docs/gates/errors#handling-failures) before reading the decision body:

```diff
  response = requests.post(
-     f"https://api.usercheck.com/v0/gates/{GATE_ID}/decisions",
+     f"https://api.usercheck.com/v1/gates/{GATE_ID}/decisions",
      headers={
          "Authorization": f"Bearer {API_KEY}",
          "Content-Type": "application/json",
      },
      json={"email": email},
  )
  data = response.json()
- decision = data.get("decision", {})

- if decision.get("action") == "block":
-     message = decision.get("matched_rule", {}).get("message")
+ if data.get("action") == "block":
+     message = (data.get("rule") or {}).get("message")
      raise SignUpError(message)

- request_id = data.get("meta", {}).get("request_id")
+ request_id = data.get("id")
```

Note the guard on the rule: `/v0` omits `matched_rule` entirely when the default action applied, so a `.get()` default was enough. `/v1` always includes `rule` but sets it to `null` in that case, so the fallback becomes `or {}`.

Everything your code reads from `signals` stays exactly where it was.

For errors, use the status and code changes in the table above. On `/v1`, `rate_limit_exceeded` means pause and retry, while `quota_exceeded` means check your credits. Honor `Retry-After` when present and use bounded backoff when it is absent.

Cloudflare IP blocks return `429 rate_limit_exceeded` on both versions. Since `/v0` also uses that code for exhausted credits, check your account usage if the cause is unclear. Do not infer quota exhaustion from a missing header.

Both versions serve the same gates, so you can migrate one caller at a time: a service still on `/v0` and one already on `/v1` can evaluate the same gate side by side while you roll the change out.
