Error Handling
The shape of an error response depends on which endpoint produced it. Resource endpoints return the ProxyHat envelope; authentication, validation, and framework-level failures return Laravel's native error shape. This page documents both, the HTTP status codes actually used, and how to handle them in your client.
errors object or a description. Branch on the HTTP status code first, then read whichever body the endpoint returns. The two shapes are described below.
Envelope Errors (Resource Endpoints)
The resource controllers — sub-users, sub-user-groups, proxy-presets, proxy-descriptors, isp-proxies, and isp-store / catalog — return the standard envelope on both success and failure. On failure, success is false, payload is null, errors holds the details, and description is a short app-specific summary:
{
"success": false,
"payload": null,
"errors": {
"traffic_limit": ["The traffic limit field must be an integer."]
},
"description": "Validation error"
}
When one of these endpoints hits an internal guard or an unexpected exception (rather than input validation), it returns HTTP 400 with errors as a flat list and an app-specific description:
{
"success": false,
"payload": null,
"errors": ["An unexpected error occurred"],
"description": "Operation not allowed"
}
description text is application-specific and may change. It is meant for logging and quick diagnosis, not for programmatic branching. Match on the HTTP status code and, where present, on the errors field keys — never on the human-readable description string.
Native Errors (Auth, Validation & Framework)
Authentication, framework-level validation, and other non-resource endpoints (auth, analytics such as traffic / requests / domain-breakdown, profile, plans, payments, subscription, coupons, and the public locations dropdowns) return Laravel's native error shapes, not the envelope.
A missing or invalid bearer token is rejected by Sanctum before your handler runs, producing a bare message:
HTTP/1.1 401 Unauthorized
{
"message": "Unauthenticated."
}
Framework-level validation failures return HTTP 422 with a message and an errors map of field names to arrays of messages:
HTTP/1.1 422 Unprocessable Content
{
"message": "The email field is required. (and 1 more error)",
"errors": {
"email": ["The email field is required."],
"password": [
"The password field must be at least 8 characters.",
"The password field must contain at least one uppercase and one lowercase letter."
]
}
}
errors + description: "Validation error"); everywhere else you get the native message + errors shape shown above. In both, the errors object maps a field name to an array of one or more messages, so client code that reads errors[field][0] works against either.
Two-Factor Challenges (403)
Sensitive resource actions (for example creating, updating, or deleting a sub-user) can require a TOTP code. When 2FA is enabled on the account and no twofa_code is supplied, the endpoint returns HTTP 403 with an envelope that carries an extra requires_2fa flag:
{
"success": false,
"payload": null,
"errors": [],
"description": "2FA verification required",
"requires_2fa": true
}
Retry the same request with a valid twofa_code in the JSON body. An incorrect code returns HTTP 422 with errors: {"twofa_code": ["Invalid 2FA code"]}.
HTTP Status Codes
| Code | Meaning | When you see it |
|---|---|---|
| 200 | OK | Request succeeded. The body contains the requested data (envelope or flat, depending on the endpoint). |
| 201 | Created | A resource was created successfully. |
| 204 | No Content | Success with no response body (e.g. POST /auth/logout revokes the token and returns 204). |
| 400 | Bad Request | A resource endpoint hit an internal guard or unexpected error. Envelope with errors as a flat list. |
| 401 | Unauthorized | Missing or invalid bearer token. Native {"message": "Unauthenticated."}. |
| 403 | Forbidden | Authenticated but not permitted — e.g. a 2FA challenge (requires_2fa: true) or an account restriction. |
| 404 | Not Found | The route or referenced resource does not exist. |
| 422 | Unprocessable Content | Input validation failed. Read the errors map (envelope on resource endpoints, native elsewhere). |
| 429 | Too Many Requests | A throttled route's per-route limit was exceeded. See Rate Limiting below. |
| 500 | Internal Server Error | An unhandled server error. Retry later or contact support if it persists. |
Rate Limiting (429)
Rate limits are applied per route, not globally, and only a subset of endpoints are throttled. There is no single universal limit. The limits currently enforced include:
| Route | Limit |
|---|---|
| POST /auth/login | 5 requests / minute (per IP) |
| POST /auth/register | 5 requests / minute (per IP) |
| GET /locations, /locations/{country}/* | 60 requests / minute (public) |
| Coupon endpoints | 10 requests / minute (per user) |
When a throttled route's limit is exceeded, Laravel returns HTTP 429 with a native message body. The Retry-After and X-RateLimit-* headers are present only on responses from throttled routes — untouched endpoints do not emit them:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
{
"message": "Too Many Attempts."
}
Retry-After before retrying. On throttled routes you can also watch X-RateLimit-Remaining to slow down proactively before you hit the limit. Because most endpoints are not throttled, do not depend on these headers being present in general.
Handling Errors in Code
Branch on the HTTP status code first, then read whichever body shape the endpoint returns. The example below reads payload from the envelope on success and falls back to the native message for auth and framework errors.
# Capture the status code separately from the body
curl -s -o response.json -w "%{http_code}" \
https://api.proxyhat.com/v1/sub-users \
-H "Authorization: Bearer __API_KEY__" \
-H "Accept: application/json"
import requests
response = requests.get(
"https://api.proxyhat.com/v1/sub-users",
headers={
"Authorization": "Bearer __API_KEY__",
"Accept": "application/json",
},
)
if response.ok:
body = response.json()
print(body.get("payload")) # envelope endpoints
elif response.status_code == 401:
print("Invalid or missing API key")
elif response.status_code == 422:
body = response.json()
# errors is a field -> [messages] map in both shapes
errors = body.get("errors", {})
for field, messages in errors.items():
print(f"{field}: {messages[0]}")
elif response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", 1))
print(f"Rate limited. Retry after {retry_after}s")
else:
body = response.json()
print(body.get("description") or body.get("message") or response.text)
const response = await fetch("https://api.proxyhat.com/v1/sub-users", {
headers: {
"Authorization": "Bearer __API_KEY__",
"Accept": "application/json",
},
});
if (response.ok) {
const body = await response.json();
console.log(body.payload); // envelope endpoints
} else if (response.status === 401) {
console.error("Invalid or missing API key");
} else if (response.status === 422) {
const body = await response.json();
// errors is a field -> [messages] map in both shapes
for (const [field, messages] of Object.entries(body.errors ?? {})) {
console.error(`${field}: ${messages[0]}`);
}
} else if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After") ?? "1";
console.error(`Rate limited. Retry after ${retryAfter}s`);
} else {
const body = await response.json();
console.error(body.description ?? body.message ?? response.statusText);
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer resp.Body.Close()
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
switch resp.StatusCode {
case 200, 201:
fmt.Println(body["payload"]) // envelope endpoints
case 401:
fmt.Println("Invalid or missing API key")
case 422:
// errors is a field -> [messages] map in both shapes
if errs, ok := body["errors"].(map[string]interface{}); ok {
for field, messages := range errs {
fmt.Printf("%s: %v\n", field, messages)
}
}
case 429:
fmt.Printf("Rate limited. Retry after %ss\n", resp.Header.Get("Retry-After"))
default:
if d, ok := body["description"]; ok {
fmt.Println(d)
} else {
fmt.Println(body["message"])
}
}