Signup protection Pricing Documentation Sign up Log in

Gates

Migrate from v0 to v1

Integrations built during the beta call POST /v0/gates/{gate_id}/decisions. The current endpoint is POST /v1/gates/{gate_id}/decisions.

/v1 is not a drop-in replacement: the response shape changed. If you only change /v0 to /v1 in the URL, your code no longer finds decision.action. Depending on how it reads the response, it either fails or quietly lets every signup through.

/v0 keeps working and its responses won't change, so no action is required. Move when it suits you.

What stays the same: authentication, credit cost, your gates and rules, and the verdicts. The same input gets the same decision from both versions.

To migrate, make four changes:

  1. Change /v0/ to /v1/ in the URL.
  2. Send the body as JSON with Content-Type: application/json. See Send a JSON body.
  3. Read the verdict and a few other fields from their new places. See Response changes.
  4. Update the status codes and error codes you check. See Error changes.

Send a JSON body

/v1 only accepts a JSON body with Content-Type: application/json. /v0 also accepted form-encoded and multipart bodies. On /v1 those return 400 invalid_json.

The request fields are the same: email or domain, plus an optional ip. Check how your HTTP client sends them. For example, in Python requests use json=, not data=. In PHP cURL, pass json_encode($payload) to CURLOPT_POSTFIELDS, not an array, and set the Content-Type: application/json header yourself.

Response changes

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

{
  "input": {
    "email": "[email protected]",
    "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": "2026-08-18T03:57:18+00:00"
  }
}

/v1 returns a flat object, with the verdict at the top:

{
  "id": "01k1skm0zdceees5qa9n3m5epx",
  "gate_id": "01k2wjgq8rnv6t0aefy3s7xdhb",
  "action": "allow",
  "rule": {
    "id": "01k1hxqrn2f7xfreq20y0p012d",
    "name": "Allow professional email providers",
    "message": null
  },
  "input": {
    "email": "[email protected]",
    "ip": "1.1.1.1"
  },
  "signals": {
    "email": { ... },
    "domain": { ... },
    "ip": { ... }
  },
  "duration_ms": 52.62,
  "created_at": "2026-08-18T03:57:18+00:00"
}

Field by field:

/v0 /v1
decision.action action
decision.matched_rule rule, always present, null when no rule matched
meta.request_id id, the same value, returned in lowercase
meta.duration_ms duration_ms
meta.created_at created_at
meta.version Removed. The URL carries the version.
Not returned gate_id
input input, unchanged
signals signals, same fields and values. Inside signals.email and signals.domain the keys come in a different order, so read fields by name.

Two of these are more than a rename:

  • rule is always present. /v0 leaves out matched_rule when no rule matched and the gate's default_action applied. /v1 always returns rule, set to null in that case. A check for whether matched_rule exists becomes a check for rule not being null.
  • id is the decision's handle. It replaces meta.request_id. /v1 returns it in lowercase, where /v0 returns uppercase, so update any check that expects uppercase. It is the {decision_id} the Management API accepts to read the decision back later.

Error changes

On /v1, every error from the API carries an error code and a message. These cases differ from /v0:

Case /v0 /v1
Invalid or missing input: missing_input, invalid_email, invalid_domain, invalid_ip, both_email_and_domain_provided 400 422, same error code
email or domain sent as null, empty, not a string, or longer than 255 characters 422 with message and errors, no error code 422 validation_failed, with errors
Form-encoded or multipart body Accepted 400 invalid_json
Malformed JSON, or JSON sent without Content-Type: application/json 400 missing_input 400 invalid_json
Too many requests per second 429 with {"status": 429, "error": "Too many requests"} 429 rate_limit_exceeded
Monthly credits used up 429 rate_limit_exceeded 429 quota_exceeded
Unknown path or HTTP method Generic 404 or 405 without an error code 404 not_found or 405 method_not_allowed
Request id on error bodies Some errors include meta.request_id None: error and message only

401 unauthorized, 403 pro_only, and 404 gate_not_found keep the same status and code.

Match on the error code, not on message. /v1 rewords some messages, for example for a domain with an unknown top-level domain.

On /v1, a 429 tells you which limit you hit. rate_limit_exceeded means slow down and retry, honoring Retry-After when present. quota_exceeded means your monthly credits are used up, and retrying won't help until they reset or you enable overage. /v0 reports used-up credits as rate_limit_exceeded, so any code that guessed between the two can go.

Errors covers the full /v1 error contract.

Update your integration

A typical Python integration changes like this:

  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}"},
      json={"email": email},
      timeout=5,
  )
  if response.status_code != 200:
      return handle_gate_error(response)  # your fallback, updated for the new error codes
  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")
+ decision_id = data.get("id")

Note the or {} on the rule. /v0 leaves matched_rule out when the default action applied, so a .get() default was enough. /v1 sets rule to null in that case, so data.get("rule", {}) would return None, not {}.

Every field you read from signals keeps its name and path.

If your integration has no client timeout or fallback yet, add them now: see Timeouts and failures.

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 call the same gate side by side while you roll out the change.

Previous
Migrate from email & domain APIs