Docs

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"
  }
}
ParameterTypeDescription
typestring | undefinedThe broad failure category — one of "invalid_request_error", "authentication_error", "rate_limit_error", or "api_error".
codestring | undefinedA stable, machine-readable identifier for the specific failure. Branch on this in code.
messagestringA human-readable explanation of what went wrong. Intended for logs and debugging — don't string-match on it.
paramstring | undefinedThe request parameter the error relates to, when applicable.
request_idstring | undefinedA 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:

Synthetic dashboard API Inspector example showing a failed verification-session request with its status, timing, error code, and redacted request body

HTTP status codes

CodeMeaning
200OK — request succeeded.
201Created — resource was created successfully.
400Bad Request — missing or invalid parameters.
401Unauthorized — invalid, missing, or revoked API key.
402Payment Required — billing is suspended or the sandbox monthly quota is exhausted.
403Forbidden — the key is valid but not allowed to perform this operation.
404Not Found — the requested resource doesn't exist.
409Conflict — the resource is in a state that prevents the requested operation.
422Unprocessable Entity — the request is valid but can't be fulfilled.
429Too Many Requests — rate limit exceeded. Retry after the Retry-After header value.
500Internal 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.

TypeWhen it occurs
invalid_request_errorA parameter is missing, invalid, or the operation isn't allowed in the current state.
authentication_errorThe API key is missing, malformed, revoked, or expired.
rate_limit_errorToo many requests were sent in the current window.
api_errorAn unexpected server error occurred. Safe to retry.

Error codes

code identifies the exact failure and is the value to branch on programmatically:

CodeStatusDescription
parameter_invalid400A required parameter is missing or has an invalid value.
resource_missing404The requested session, event, or endpoint doesn't exist.
billing_suspended402Your organization's billing is suspended — requests are blocked until billing is resolved.
test_quota_exceeded402Sandbox mode is capped at 500 verifications per calendar month. Resets on the 1st.
use_case_prohibited422The workflow's use case is not permitted in the detected jurisdiction.
jurisdiction_unresolvable422Could not determine the user's jurisdiction from their IP. Pass jurisdiction explicitly.
accept_existing_rate_limited429More than 10 accept_existing attempts for the same email in a 15-minute window.
rate_limit_exceeded429Too many requests in the current window. Retry after the Retry-After header value.
publishable_key_scope403A publishable key was used on an endpoint that requires a secret key.
publishable_session_create_blocked403Your organization has migrated to backend-created sessions — create them with a secret key.
captcha_required400Publishable-key session creation from the browser requires a Cloudflare Turnstile captcha_token.
email_not_verified400Email verification (OTP) is required before creating a session for a returning user.
webhook_required400Live (non-sandbox) organizations require at least one active webhook endpoint.
session_not_found404The verification session does not exist or belongs to a different organization.
session_terminal409The session is in a terminal state (verified, failed, cancelled, expired) and cannot be modified.
session_not_redeemable409The session is not in a state where a VP token or OTP can be redeemed against it.
session_locked_to_other_device409The session is being completed on another device (desktop→mobile handoff lock).
vp_token_invalid400The supplied VP token is malformed, expired, or revoked. Fall back to full verification.
no_matching_vp400accept_existing found no reusable verification for this email/phone at the required strength.
otp_not_proven400The returning user hasn't completed the email OTP challenge yet.
idempotency_key_reuse400A different request body was sent with the same idempotency key. Use the original body or a new key.
api_key_invalid401The 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 data
type 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
end
class 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 data
func 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
end
function 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

StatusRetryable?Action
429YesWait for Retry-After header duration, then retry
500, 502, 503, 504YesRetry with exponential backoff
400NoFix the request; for idempotency_key_reuse, use the original body or a new key
401NoCheck your API key
402NoResolve billing in the dashboard, or wait for the sandbox quota to reset
403NoUse the right key type (secret vs publishable) for the endpoint
404NoThe resource doesn't exist
409NoBranch on code and fix the resource state conflict
422NoThe 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

On this page