Gates
Migrate from v0 to v1
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, 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:
{
"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": "2025-08-04T03:57:18+00:00"
}
}
/v1 returns the same decision as a flat object, 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": "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:
ruleis always present./v0omitsmatched_rulewhen no rule matched and the gate'sdefault_actionapplied./v1always returns therulekey,nullin that case. Code that checks whethermatched_ruleexists becomes a check forrulenot beingnull.idis the decision's handle. It carries the value/v0reported asmeta.request_id, and it is the{decision_id}the Management API 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 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 before reading the decision body:
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.