stile
Guides

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

CodeMeaning
200OK — request succeeded.
201Created — resource was created successfully.
400Bad Request — missing or invalid parameters.
401Unauthorized — invalid, missing, or revoked API key.
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

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.

Common error codes

CodeStatusDescription
parameter_invalid400A required parameter is missing or has an invalid value.
resource_missing404The requested session, event, or endpoint doesn't exist.
use_case_prohibited422The product type is not permitted in the detected jurisdiction (deprecated — use the Compliance API instead).
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).
publishable_key_scope403A publishable key was used on an endpoint that requires a secret key.
email_not_verified400Email verification (OTP) is required before creating a session for a returning user.
webhook_required400Live mode requires at least one active webhook endpoint.
session_not_found404The verification session does not exist or belongs to a different organization.
session_expired422The session has expired and can no longer be used.
session_already_cancelled422The session has already been cancelled.
session_terminal422The session is in a terminal state (verified, failed, cancelled, expired) and cannot be modified.
idempotency_conflict409A 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 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.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
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.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 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).

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

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
404NoThe resource doesn't exist
409NoUse a different idempotency key
422NoThe operation is not allowed in the current state

On this page