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. Every failure returns the same JSON envelope, a stable machine-readable code, and an HTTP status that tells you whether a retry can succeed. This page catalogs every status and error code, shows handler patterns in five languages, and covers safe retries with backoff and idempotency keys.
Anatomy of an error
Every non-2xx response carries a single top-level error object:
{
"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 | The broad failure category — one of "invalid_request_error", "authentication_error", "rate_limit_error", or "api_error". |
code | string | 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 | The request parameter the error relates to, when applicable. |
request_id | string | A unique identifier for this request. |
Keep the request_id
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. |
| 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 — 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 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 | Too many accept_existing attempts for the same email (5 per 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 | 409 | A different request body was sent with the same idempotency 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) {
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.id/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.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"];
$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.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 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.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 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).
Using @stile/node?
The Node SDK retries 429, 5xx, and network failures automatically (maxRetries
defaults to 2) — you don't need to implement backoff yourself.
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 |
| 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 | Use a different idempotency key |
| 422 | No | The operation is not allowed in the current state |
Rate limits
Limits are applied per API key over a rolling one-minute window: 1,000 requests/min for secret keys, 100/min for publishable keys. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers; 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 409 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 SDK
Typed errors, automatic 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.