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:
- Change
/v0/to/v1/in the URL. - Send the body as JSON with
Content-Type: application/json. See Send a JSON body. - Read the verdict and a few other fields from their new places. See Response changes.
- 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:
ruleis always present./v0leaves outmatched_rulewhen no rule matched and the gate'sdefault_actionapplied./v1always returnsrule, set tonullin that case. A check for whethermatched_ruleexists becomes a check forrulenot beingnull.idis the decision's handle. It replacesmeta.request_id./v1returns it in lowercase, where/v0returns 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.