ReqKey.docs
SDKs

Rust SDK

The official Rust SDK for API key validation, credit metering, consumer rate limits, and correlated traffic analytics. One crate ships a shared async core, a blocking SyncClient for synchronous applications, and optional native adapters for Axum, Actix Web, Rocket, and Warp — with typed decisions, typed errors, and synchronous resolvers throughout.

Prefer it filled in for you?
The dashboard's Setup Guide generates this exact integration from a few questions — with your real API id, key location, and a copy-ready brief for AI coding agents. Explore every option interactively in the SDK playground.

Install

Rust 1.88 or newer is required, matching the current Actix Web 4 line. The default build enables the async Client, the blocking SyncClient, and Rustls — framework adapters are opt-in cargo features, so the core never pulls a web framework into your dependency graph:

terminal
# Default build: async Client + blocking SyncClient, Rustls TLS, no framework
cargo add reqkey

# Add a framework adapter with its cargo feature
cargo add reqkey --features axum
cargo add reqkey --features actix-web
cargo add reqkey --features rocket
cargo add reqkey --features warp

# Every adapter at once, or platform TLS instead of Rustls
cargo add reqkey --features full
cargo add reqkey --no-default-features --features native-tls,blocking
Published from Git tags
Releases are cut from version tags and published to crates.io; rendered API docs appear on docs.rs. The request contract and middleware behavior track the ReqKey Python SDK, so switching stacks never means relearning ReqKey.

The SDK reads your project credential from the REQKEY_PROJECT_KEY environment variable (falling back to the legacy REQKEY_ROOT_KEY). Copy the real key from the dashboard, and keep it out of source control:

.env / shell
export REQKEY_PROJECT_KEY="reqkey_your_project_key"

Quickstart

Build a Middleware once from a Client and a MiddlewareConfig, then attach it with your framework's native mechanism. Every route it covers is guarded automatically — your handlers stay untouched, and the application never calls /key/validate or /ingest directly.

use axum::{extract::Extension, routing::post, Json, Router};
use reqkey::{
    axum::ReqKeyLayer,
    middleware::{KeyLocation, Middleware, MiddlewareConfig},
    Client, VerificationResult,
};
use serde_json::{json, Value};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = Client::from_env()?;
    let config = MiddlewareConfig::builder("api_payments")
        .key_location(KeyLocation::header("X-Startup-Key"))
        .exclude_path("/health")
        .build()?;

    let app = Router::new()
        .route("/payments", post(create_payment))
        .layer(ReqKeyLayer::new(Middleware::new(client, config)));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

async fn create_payment(Extension(decision): Extension<VerificationResult>) -> Json<Value> {
    Json(json!({ "credits_remaining": decision.credits_remaining }))
}

Each adapter uses the same shared engine and builder. For Hyper, Tower, or any custom stack, translate your request into a RequestContext, call Middleware::authorize, run the endpoint only for an Authorized/Bypass outcome, then call Middleware::record. For scripts and workers, use the direct Client or the synchronous SyncClient.

Reading the decision

After successful validation, the full VerificationResult — the consumer, credits remaining, and a traceable request id — is stored where each framework keeps request-scoped values:

Axum
extract Extension<VerificationResult> in the handler
Actix Web
request.extensions().get::<VerificationResult>()
Rocket
take a ReqKeyGuard, then call reqkey.decision()
Warp
WarpRequest::decision() on the filter's guard handle

The decision reads naturally in authorization code:

  • decision.allowed() — true when the key is valid; decision.reason categorizes a denial.
  • decision.credits_remaining and decision.credits_limit — the consumer's balance (Option<i64>).
  • decision.request_id — a traceable id for support and log correlation.

When fail-open is active, a service failure is exposed instead as ReqKeyFailure in the same request state. Successful responses also carry X-ReqKey-Request-ID, X-ReqKey-Credits-Limit, X-ReqKey-Credits-Remaining, and X-ReqKey-Validation-Time-Ms headers — add them to CORS expose_headers if browser JavaScript must read them.

Modes

One builder call decides what the middleware does per request:

mode
.mode(Mode::Both)       // validate keys AND record analytics (default)
.mode(Mode::Validate)   // key checks and credit deduction only — nothing recorded
.mode(Mode::Ingest)     // analytics only — no key checks at all

In Mode::Both, denied requests — missing keys, exhausted credits, forbidden access, rate-limit hits — are recorded too, so abuse is visible in your dashboards. Call .ingest_denied_requests(false) to record successful requests only. In ingest-only mode, another component's validation can be correlated via .request_id_resolver(...). Ingestion runs inside the request lifecycle — the SDK never spawns a background task that can vanish on shutdown.

Where the key comes from

Match however your consumers already send credentials. The location and its name fuse into a single KeyLocation value:

key extraction
// Custom header (recommended)
.key_location(KeyLocation::header("X-Startup-Key"))

// Authorization: Bearer <key>
.key_location(KeyLocation::header("Authorization"))
.key_scheme(KeyScheme::Bearer)

// ?api_key=<key> — easy to test, but URLs end up in logs
.key_location(KeyLocation::query("api_key"))

// Cookie — sent automatically by browsers
.key_location(KeyLocation::cookie("startup_key"))

// Or take full control with a synchronous resolver
.consumer_key_resolver(|request| {
    request.headers.get("X-Custom-Key").and_then(|v| v.to_str().ok()).map(str::to_owned)
})
Headers over query parameters
Query parameters are available for compatibility, but headers are safer — URLs are retained in browser history and access logs. When query capture is enabled, the configured consumer-key parameter is always removed from both the captured query and the recorded path, while still sent in the dedicated apiKey identity field.

Paths & credit costs

Excluded traffic is never validated, charged, or recorded — for anyone. Credit costs can be the static .credit_cost(n) or a per-request .credit_cost_resolver(...):

routing & pricing
// Exact paths or trailing-* prefixes: never validated, charged, or recorded
.exclude_path("/health")
.exclude_path("/docs/*")

// Or decide per request with a predicate
.should_protect(|request| request.path.starts_with("/api/"))

// Charge different endpoints differently (non-negative u64)
.credit_cost_resolver(|request| {
    Ok(if request.method == Method::POST { 5 } else { 1 })
})

Resolvers are synchronous by design — they inspect already-extracted request metadata and must not perform I/O; put application I/O in the endpoint. The SDK deliberately never auto-retries /key/validate, because validation deducts credits — a safe retry would require server-side idempotency.

Analytics capture

By default each event records the method, normalized endpoint, status, latency, and User-Agent — correlated to the validated consumer. Everything else is opt-in with a plain bool:

capture
MiddlewareConfig::builder("api_payments")
    // All off by default — turn on what your dashboards need
    .capture_query_params(true)
    .capture_request_headers(true)
    .capture_response_headers(true)
    .capture_response_body(true) // Axum only, textual, first 1,000 chars
    .capture_client_ip(true)

    // User-Agent is captured by default — opt out explicitly
    .capture_user_agent(false)

    // Strip additional headers beyond the built-in redaction list
    .exclude_header("X-Internal-Trace")
    .build()?
Redaction is always on
Authorization, Cookie, Set-Cookie, Proxy-Authorization, X-API-Key, and your configured key name are always stripped before anything leaves your server — even with header capture on. Response-body capture is adapter-dependent: Axum captures a textual body only when the size is known, ≤ 4,004 bytes, and uncompressed; Actix, Rocket, and Warp never consume response bodies. Framework middleware never reads incoming request bodies — supply one explicitly with RequestContext::with_request_body in a plain integration if you need .capture_request_body(true).

Failure behavior

Validation fails closed by default: if ReqKey times out or is unreachable, the middleware returns 503 without running your handler. Prefer availability? Set .failure_mode(FailureMode::Open) — transport failures let requests through, while explicitly invalid, exhausted, forbidden, and rate-limited keys are still always denied. Either way, .on_error(...) tells you it happened:

failure
MiddlewareConfig::builder("api_payments")
    .failure_mode(FailureMode::Closed) // or FailureMode::Open: let requests through
    .on_error(|event| {
        // Provider-neutral: post it to Slack, Discord, PagerDuty, ...
        tracing::warn!(
            "reqkey {:?} failed on {} {}: {}",
            event.operation, event.method, event.path, event.error,
        );
    })
    .build()?

The event intentionally excludes API keys, project credentials, captured headers, and bodies; panics inside the callback are caught and logged, so alerting can never replace your application response. On the direct client, decisions are normalized (200/402/403/429 are data, not errors) and real failures surface as the typed reqkey::ErrorConfiguration, Timeout, Transport, Authentication, and Api:

errors.rs
use reqkey::{Client, Error};

match client.verify(key).api_id("api_payments").send().await {
    // Access denial is data, not a transport error — inspect the decision.
    Ok(decision) => println!("remaining: {:?}", decision.credits_remaining),
    // Validation deducts credits, so it is never retried automatically.
    Err(Error::Timeout { operation }) => tracing::warn!("reqkey {operation} timed out"),
    Err(Error::Api { status, message, .. }) => tracing::error!("reqkey {status}: {message}"),
    Err(other) => tracing::error!("reqkey error: {other}"),
}

Configuration reference

Every option below is a method on MiddlewareConfig::builder(api_id), chained before .build(). Booleans are plain — the two opt-out captures, capture_user_agent and ingest_denied_requests, default on.

MiddlewareConfig::builder(…)
project_keyStringrequired

Your project credential, sent to ReqKey as the Bearer token. Read it from the REQKEY_PROJECT_KEY environment variable — never hard-code it.

api_id&strrequired

The ReqKey API this application serves — validation and analytics are scoped to it. Passed to MiddlewareConfig::builder.

modeMode

What the middleware does per request: guard keys and record analytics (the default), key checks only, or analytics only.

enabledbool

One switch to bypass ReqKey entirely — handy as a local-development toggle that leaves your middleware layout untouched.

root_keyString

Backward-compatible alias for project_key on the Client builder. Never set both.

key_locationKeyLocation

Where the key travels. Headers are recommended — query strings end up in logs and browser history.

key_name&str

The customer-facing header, query parameter, or cookie name. Make it yours: X-Startup-Key.

key_schemeKeyScheme

For headers: the raw key value, or a standard Authorization: Bearer token.

consumer_key_resolverresolver

Full control — a closure that extracts the key from anywhere in the request.

credit_costu64

The static cost per successful request. Defaults to 1; use credit_cost_resolver to price endpoints individually.

credit_cost_resolverresolver

Charge heavy endpoints more than light ones — a per-request closure returning the cost.

exclude_path&str

Paths that stay public — never validated, charged, or recorded. Exact matches or trailing-* prefixes.

should_protectresolver

A per-request predicate for when patterns are not enough — protect exactly the routes you choose.

skip_methodsIntoIterator<Method>

HTTP methods that bypass ReqKey. Defaults to [OPTIONS] so CORS preflights stay free.

capture_query_paramsbool

Record redacted query values with each analytics event, appended to the recorded path. Off by default.

capture_request_headersbool

Record request headers — auth headers, cookies, and consumer keys are always stripped first.

capture_response_headersbool

Record response headers, with the same always-on redaction.

capture_response_bodybool

Record textual response bodies, capped at the first 1,000 characters.

capture_client_ipbool

Record the caller’s IP address as resolved by the framework. Off by default.

capture_user_agentbool

Record the User-Agent header. On by default — call .capture_user_agent(false) to opt out.

exclude_header&str

Strip additional headers from captured analytics, beyond the built-in redaction list.

consumer_name_resolverresolver

Label events with a human-readable customer name from a header or another trusted source.

client_ip_resolverresolver

Behind a trusted proxy? Tell the SDK exactly which header holds the real client IP.

path_resolverresolver

Normalize route names before they are recorded — collapse ids so endpoints group cleanly.

request_id_resolverresolver

In ingest-only mode: correlate events to a validation performed by another middleware.

ingest_denied_requestsbool

Record denied traffic too — invalid keys, exhausted credits, rate-limit hits — so abuse is visible. On by default.

failure_modeFailureMode

Fail closed (block with a 503, the default) or fail open (let requests through unvalidated). Invalid and exhausted keys are always denied either way.

on_errorcallback

Called on ReqKey service failures with a provider-neutral event — wire it to Slack, Discord, or PagerDuty. No request secrets included.

error_message(DenialCode, &str)

Rewrite the customer-facing denial messages in your own voice, per error type.

timeoutDuration

How long each ReqKey operation may take before the failure mode kicks in. Defaults to 2s. Set on the Client.

Client settings

The project credential and timeout live on the Client, not the middleware config. Client::from_env() reads REQKEY_PROJECT_KEY; for a private deployment, custom timeout, or an injected Reqwest client, use Client::builder() .project_key(...), .base_url(...) (default https://api.reqkey.com), .timeout(...) (default 2 seconds), and .http_client(...). SyncClient mirrors the same builder for synchronous applications. Never configure both a project and a root key.