Error Handling
Handle API errors and implement retry logic in any language — no SDK required.
Error response format
All API errors return a JSON body in this shape:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "No such verification_session: 'vks_unknown'",
"param": "id",
"request_id": "req_abc123"
}
}Include the request_id when contacting support — it lets us trace the exact request in our logs.
HTTP status codes
| Code | Meaning |
|---|---|
| 200 | OK — request succeeded. |
| 201 | Created — resource was created successfully. |
| 400 | Bad Request — missing or invalid parameters. |
| 401 | Unauthorized — invalid, missing, or revoked API key. |
| 404 | Not Found — the requested resource doesn't exist. |
| 409 | Conflict — idempotency key collision or state conflict. |
| 422 | Unprocessable Entity — the request is valid but can't be fulfilled. |
| 429 | Too Many Requests — rate limit exceeded. Retry after the Retry-After header value. |
| 500 | Internal Server Error — something went wrong on our end. Retry with exponential backoff. |
Error types
| Type | When it occurs |
|---|---|
invalid_request_error | A parameter is missing, invalid, or the operation isn't allowed in the current state. |
authentication_error | The API key is missing, malformed, revoked, or expired. |
rate_limit_error | Too many requests were sent in the current window. |
api_error | An unexpected server error occurred. Safe to retry. |
Common error codes
| Code | Status | Description |
|---|---|---|
parameter_invalid | 400 | A required parameter is missing or has an invalid value. |
resource_missing | 404 | The requested session, event, or endpoint doesn't exist. |
use_case_prohibited | 422 | The product type is not permitted in the detected jurisdiction (deprecated — use the Compliance API instead). |
jurisdiction_unresolvable | 422 | Could not determine the user's jurisdiction from their IP. Pass jurisdiction explicitly. |
accept_existing_rate_limited | 429 | Too many accept_existing attempts for the same email (5 per 15-minute window). |
publishable_key_scope | 403 | A publishable key was used on an endpoint that requires a secret key. |
email_not_verified | 400 | Email verification (OTP) is required before creating a session for a returning user. |
webhook_required | 400 | Live mode requires at least one active webhook endpoint. |
session_not_found | 404 | The verification session does not exist or belongs to a different organization. |
session_expired | 422 | The session has expired and can no longer be used. |
session_already_cancelled | 422 | The session has already been cancelled. |
session_terminal | 422 | The session is in a terminal state (verified, failed, cancelled, expired) and cannot be modified. |
idempotency_conflict | 409 | A different request body was sent with the same idempotency key. |
Handling errors
Parse the JSON error body and branch on type or status to handle different failures:
async function stileRequest(method, path, body) {
const res = await fetch(`https://api.stile.dev/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.STILE_API_KEY}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) {
const err = data.error;
switch (err.type) {
case "authentication_error":
throw new Error(`Auth failed: ${err.message}`);
case "rate_limit_error":
throw new Error(`Rate limited. Retry after ${res.headers.get("Retry-After")}s`);
case "invalid_request_error":
throw new Error(`Bad request [${err.code}]: ${err.message}`);
default:
throw new Error(`API error: ${err.message} (${err.request_id})`);
}
}
return data;
}import requests, os
def stile_request(method, path, json=None):
res = requests.request(
method,
f"https://api.stile.dev/v1{path}",
headers={"Authorization": f"Bearer {os.environ['STILE_API_KEY']}"},
json=json,
)
data = res.json()
if not res.ok:
err = data["error"]
if err["type"] == "authentication_error":
raise Exception(f"Auth failed: {err['message']}")
elif err["type"] == "rate_limit_error":
raise Exception(f"Rate limited. Retry after {res.headers.get('Retry-After')}s")
elif err["type"] == "invalid_request_error":
raise Exception(f"Bad request [{err['code']}]: {err['message']}")
else:
raise Exception(f"API error: {err['message']} ({err['request_id']})")
return datatype StileError struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
Param string `json:"param"`
RequestID string `json:"request_id"`
Status int
}
func (e *StileError) Error() string {
return fmt.Sprintf("[%s] %s: %s (request_id: %s)",
e.Type, e.Code, e.Message, e.RequestID)
}
func stileRequest(method, path string, body io.Reader) ([]byte, error) {
req, _ := http.NewRequest(method, "https://api.stile.dev/v1"+path, body)
req.Header.Set("Authorization", "Bearer "+os.Getenv("STILE_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
data, _ := io.ReadAll(res.Body)
if res.StatusCode >= 400 {
var errResp struct{ Error StileError `json:"error"` }
json.Unmarshal(data, &errResp)
errResp.Error.Status = res.StatusCode
return nil, &errResp.Error
}
return data, nil
}require "net/http"
require "json"
class StileError < StandardError
attr_reader :type, :code, :param, :request_id, :status
def initialize(err, status)
@type = err["type"]
@code = err["code"]
@param = err["param"]
@request_id = err["request_id"]
@status = status
super(err["message"])
end
end
def stile_request(method, path, body = nil)
uri = URI("https://api.stile.dev/v1#{path}")
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{ENV['STILE_API_KEY']}"
req["Content-Type"] = "application/json"
req.body = body.to_json if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)
raise StileError.new(data["error"], res.code.to_i) unless res.is_a?(Net::HTTPSuccess)
data
endclass StileError extends Exception {
public string $type;
public string $code;
public ?string $param;
public string $requestId;
public int $status;
public function __construct(array $err, int $status) {
$this->type = $err["type"];
$this->code = $err["code"];
$this->param = $err["param"] ?? null;
$this->requestId = $err["request_id"];
$this->status = $status;
parent::__construct($err["message"]);
}
}
function stileRequest(string $method, string $path, ?array $body = null): array {
$ch = curl_init("https://api.stile.dev/v1{$path}");
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer " . getenv("STILE_API_KEY"),
"Content-Type: application/json",
]);
if ($body) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
if ($status >= 400) {
throw new StileError($data["error"], $status);
}
return $data;
}Retry with exponential backoff
Retry on 429 (rate limit) and 5xx (server error). Never retry 4xx client errors — fix the request first.
The retry formula: delay = min(500ms * 2^attempt + random(0-500ms), 30s)
async function stileRequestWithRetry(method, path, body, maxRetries = 2) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const res = await fetch(`https://api.stile.dev/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${process.env.STILE_API_KEY}`,
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 429 || res.status >= 500) {
if (attempt < maxRetries) {
const delay = Math.min(500 * 2 ** attempt + Math.random() * 500, 30000);
await new Promise((r) => setTimeout(r, delay));
continue;
}
}
const data = await res.json();
if (!res.ok) throw new Error(data.error.message);
return data;
}
}import time, random
def stile_request_with_retry(method, path, json=None, max_retries=2):
for attempt in range(max_retries + 1):
res = requests.request(
method,
f"https://api.stile.dev/v1{path}",
headers={"Authorization": f"Bearer {os.environ['STILE_API_KEY']}"},
json=json,
)
if res.status_code in (429, 500, 502, 503, 504):
if attempt < max_retries:
delay = min(0.5 * 2**attempt + random.random() * 0.5, 30)
time.sleep(delay)
continue
data = res.json()
if not res.ok:
raise Exception(data["error"]["message"])
return datafunc stileRequestWithRetry(method, path string, body io.Reader, maxRetries int) ([]byte, error) {
for attempt := 0; attempt <= maxRetries; attempt++ {
data, err := stileRequest(method, path, body)
if stileErr, ok := err.(*StileError); ok {
if stileErr.Status == 429 || stileErr.Status >= 500 {
if attempt < maxRetries {
delay := math.Min(500*math.Pow(2, float64(attempt))+rand.Float64()*500, 30000)
time.Sleep(time.Duration(delay) * time.Millisecond)
continue
}
}
}
return data, err
}
return nil, fmt.Errorf("max retries exceeded")
}def stile_request_with_retry(method, path, body = nil, max_retries: 2)
(0..max_retries).each do |attempt|
begin
return stile_request(method, path, body)
rescue StileError => e
raise unless [429, 500, 502, 503, 504].include?(e.status)
raise if attempt >= max_retries
delay = [0.5 * 2**attempt + rand * 0.5, 30].min
sleep(delay)
end
end
endfunction stileRequestWithRetry(string $method, string $path, ?array $body = null, int $maxRetries = 2): array {
for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
try {
return stileRequest($method, $path, $body);
} catch (StileError $e) {
if (!in_array($e->status, [429, 500, 502, 503, 504]) || $attempt >= $maxRetries) {
throw $e;
}
$delay = min(0.5 * pow(2, $attempt) + lcg_value() * 0.5, 30);
usleep((int)($delay * 1_000_000));
}
}
}Don't retry 4xx errors automatically
Client errors (400, 401, 404) indicate a problem with the request itself. Retrying them won't help — fix the underlying issue first. Only retry 429 (rate limit) and 5xx (server errors).
Idempotency
Use the Idempotency-Key header when creating sessions to prevent duplicates if your request is retried after a network timeout:
curl -X POST https://api.stile.dev/v1/verification_sessions \
-H "Authorization: Bearer vk_test_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_12345" \
-d '{"type": "age", "use_case": "alcohol_delivery"}'If a request with the same key was already processed, the original response is returned without creating a duplicate.
Retryable vs permanent errors
| Status | Retryable? | Action |
|---|---|---|
| 429 | Yes | Wait for Retry-After header duration, then retry |
| 500, 502, 503, 504 | Yes | Retry with exponential backoff |
| 400 | No | Fix the request parameters |
| 401 | No | Check your API key |
| 404 | No | The resource doesn't exist |
| 409 | No | Use a different idempotency key |
| 422 | No | The operation is not allowed in the current state |