Error Handling
Parse Stile's error envelope, branch on stable error codes, and retry safely with exponential backoff and idempotency keys — in any language, no SDK required.
Stile errors are designed to be handled programmatically. API responses use a top-level error object and an HTTP status that tells you whether a retry can succeed. Core public routes return the complete envelope below; a few older routes may omit type, code, param, or request_id, so handlers must tolerate missing optional fields.
Anatomy of an error
Every non-2xx API response carries a top-level error object. The complete form is:
{
"error": {
"type": "invalid_request_error",
"code": "parameter_invalid",
"message": "Missing required parameter: workflow_id",
"param": "workflow_id",
"request_id": "req_abc123"
}
}| Parameter | Type | Description |
|---|---|---|
type | string | undefined | The broad failure category — one of "invalid_request_error", "authentication_error", "rate_limit_error", or "api_error". |
code | string | undefined | A stable, machine-readable identifier for the specific failure. Branch on this in code. |
message | string | A human-readable explanation of what went wrong. Intended for logs and debugging — don't string-match on it. |
param | string | undefined | The request parameter the error relates to, when applicable. |
request_id | string | undefined | A unique identifier for this request. |
Keep the request_id when present
Include request_id when contacting support—it lets us trace the exact request. If the body omits
it, check the X-Request-Id response header before falling back to your own correlation ID.
The dashboard's API Inspector keeps each request's method, path, status, timing, redacted request body and headers, and error code together while you debug an integration:

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. |
| 402 | Payment Required — billing is suspended or the sandbox monthly quota is exhausted. |
| 403 | Forbidden — the key is valid but not allowed to perform this operation. |
| 404 | Not Found — the requested resource doesn't exist. |
| 409 | Conflict — the resource is in a state that prevents the requested operation. |
| 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 groups failures into four coarse categories — useful when you want one handler per failure class rather than per code.
| 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. |
Error codes
code identifies the exact failure and is the value to branch on programmatically:
| 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. |
billing_suspended | 402 | Your organization's billing is suspended — requests are blocked until billing is resolved. |
test_quota_exceeded | 402 | Sandbox mode is capped at 500 verifications per calendar month. Resets on the 1st. |
use_case_prohibited | 422 | The workflow's use case is not permitted in the detected jurisdiction. |
jurisdiction_unresolvable | 422 | Could not determine the user's jurisdiction from their IP. Pass jurisdiction explicitly. |
accept_existing_rate_limited | 429 | More than 10 accept_existing attempts for the same email in a 15-minute window. |
rate_limit_exceeded | 429 | Too many requests in the current window. Retry after the Retry-After header value. |
publishable_key_scope | 403 | A publishable key was used on an endpoint that requires a secret key. |
publishable_session_create_blocked | 403 | Your organization has migrated to backend-created sessions — create them with a secret key. |
captcha_required | 400 | Publishable-key session creation from the browser requires a Cloudflare Turnstile captcha_token. |
email_not_verified | 400 | Email verification (OTP) is required before creating a session for a returning user. |
webhook_required | 400 | Live (non-sandbox) organizations require at least one active webhook endpoint. |
session_not_found | 404 | The verification session does not exist or belongs to a different organization. |
session_terminal | 409 | The session is in a terminal state (verified, failed, cancelled, expired) and cannot be modified. |
session_not_redeemable | 409 | The session is not in a state where a VP token or OTP can be redeemed against it. |
session_locked_to_other_device | 409 | The session is being completed on another device (desktop→mobile handoff lock). |
vp_token_invalid | 400 | The supplied VP token is malformed, expired, or revoked. Fall back to full verification. |
no_matching_vp | 400 | accept_existing found no reusable verification for this email/phone at the required strength. |
otp_not_proven | 400 | The returning user hasn't completed the email OTP challenge yet. |
idempotency_key_reuse | 400 | A different request body was sent with the same idempotency key. Use the original body or a new key. |
api_key_invalid | 401 | The API key is missing, malformed, revoked, or expired. |
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.id/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 ?? "api_error") {
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 ?? "unknown"}]: ${err.message}`);
default:
throw new Error(`API error: ${err.message ?? "Request failed"} (${err.request_id ?? "no request id"})`);
}
}
return data;
}import requests, os
def stile_request(method, path, json=None):
res = requests.request(
method,
f"https://api.stile.id/v1{path}",
headers={"Authorization": f"Bearer {os.environ['STILE_API_KEY']}"},
json=json,
)
data = res.json()
if not res.ok:
err = data.get("error", {})
if err.get("type") == "authentication_error":
raise Exception(f"Auth failed: {err['message']}")
elif err.get("type") == "rate_limit_error":
raise Exception(f"Rate limited. Retry after {res.headers.get('Retry-After')}s")
elif err.get("type") == "invalid_request_error":
raise Exception(f"Bad request [{err.get('code', 'unknown')}]: {err.get('message', 'Request failed')}")
else:
raise Exception(f"API error: {err.get('message', 'Request failed')} ({err.get('request_id', 'no 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.id/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.id/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"] ?? null;
$this->code = $err["code"] ?? null;
$this->param = $err["param"] ?? null;
$this->requestId = $err["request_id"] ?? null;
$this->status = $status;
parent::__construct($err["message"]);
}
}
function stileRequest(string $method, string $path, ?array $body = null): array {
$ch = curl_init("https://api.stile.id/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.id/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 retryAfterHeader = res.headers.get("Retry-After");
const retryAfter = retryAfterHeader === null ? null : Number(retryAfterHeader);
const delay = res.status === 429 && retryAfter !== null && Number.isFinite(retryAfter)
? retryAfter * 1000
: 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.id/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 []byte, maxRetries int) ([]byte, error) {
for attempt := 0; attempt <= maxRetries; attempt++ {
// Create a fresh reader on every attempt; an io.Reader is consumed by the first request.
data, err := stileRequest(method, path, bytes.NewReader(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));
}
}
}The Node.js example reads Retry-After directly. The compact Python, Go, Ruby, and PHP examples use exponential backoff for both 429 and 5xx; in production, carry response headers through your error wrapper and prefer a valid Retry-After value for 429 before falling back to the same exponential schedule.
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).
Retry safely from any backend
Honor Retry-After for 429 responses. Use bounded exponential backoff for network failures and
temporary 5xx responses, and reuse the same idempotency key for every retry of a write.
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; for idempotency_key_reuse, use the original body or a new key |
| 401 | No | Check your API key |
| 402 | No | Resolve billing in the dashboard, or wait for the sandbox quota to reset |
| 403 | No | Use the right key type (secret vs publishable) for the endpoint |
| 404 | No | The resource doesn't exist |
| 409 | No | Branch on code and fix the resource state conflict |
| 422 | No | The operation is not allowed in the current state |
Rate limits
Limits are applied per API key in fixed one-minute windows: 1,000 requests/min for secret keys, 100/min for publishable keys. Authenticated requests processed by the limiter include X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset; a 429 additionally carries Retry-After. See Rate limiting in the API reference.
Idempotency
A network timeout leaves you not knowing whether your request landed. Send an Idempotency-Key header on session creation so a retry can never create a duplicate:
curl -X POST https://api.stile.id/v1/verification_sessions \
-H "Authorization: Bearer stile_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_12345" \
-d '{"type": "age", "workflow_id": "wf_YOUR_WORKFLOW_ID"}'const session = await stile.verificationSessions.create(
{ type: "age", workflow_id: "wf_YOUR_WORKFLOW_ID" },
{ idempotencyKey: "order_12345" },
);If a request with the same key was already processed, the original response is returned without creating a duplicate. Reusing a key with a different request body returns 400 idempotency_key_reuse — derive the key from the logical operation (one per order, not one per attempt).
Next steps
API Reference
Authentication, pagination, expansion, idempotency, and rate limiting in one place.
Node.js private preview
Typed errors, network and rate-limit retries, and idempotency keys built in.
Testing
Exercise error paths safely in sandbox mode before going live.
Webhooks
Delivery retries and failure handling on the receiving side.