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.

There is no single error shape. Do not assume every failure carries an 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"
}
The 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."
    ]
  }
}
Two 422 shapes exist. Resource endpoints wrap validation failures in the envelope (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."
}
Tip: When you receive a 429, back off for the number of seconds given in 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"])
    }
}