stile
Guides

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"
  }
}
ParameterTypeDescription
typestringThe broad failure category — one of "invalid_request_error", "authentication_error", "rate_limit_error", or "api_error".
codestringA 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.
paramstringThe request parameter the error relates to, when applicable.
request_idstringA 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

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 — idempotency key collision or state conflict.
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_limited429Too many accept_existing attempts for the same email (5 per 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_reuse409A different request body was sent with the same idempotency 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) {
      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 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"];
        $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 data
func 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
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));
        }
    }
}

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

StatusRetryable?Action
429YesWait for Retry-After header duration, then retry
500, 502, 503, 504YesRetry with exponential backoff
400NoFix the request parameters
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
409NoUse a different idempotency key
422NoThe 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

On this page