Skip to content

Python SDK

noresponse is the server-side client for Python. It covers every route in the interactive reference, verifies webhooks and turns your auth provider’s verified claims into auth_context. The client is generated from the NoResponse OpenAPI document. The thin layer on top of it (defaults, response types, errors, wait and webhooks) is hand-written. It is fully typed and ships py.typed, and it works with synchronous and asyncio code. The source is at github.com/noresponsehq/python.

Terminal window
pip install noresponse

The package needs Python 3.10 or later and uses httpx and pydantic. The import name is noresponse. Keep the API key on your server.

import os
import httpx
from noresponse import NoResponse
nr = NoResponse(
os.environ["NORESPONSE_API_KEY"],
server_url="https://api.noresponse.org",
timeout_ms=10_000,
client=httpx.Client(proxy="http://proxy:3128"),
)
Argument Default Notes
api_key NORESPONSE_API_KEY A string, or a function that returns one. It is the first positional argument.
server_url NORESPONSE_BASE_URL, then https://api.noresponse.org HTTPS, except localhost.
timeout_ms 10 000 Per request. Every method also takes timeout_ms.
retry_config see Retries Pass None to turn retries off. Every method except keys.rotate also takes retries.
client, async_client a new httpx.Client and httpx.AsyncClient Your own, for a proxy, mTLS or tests.
debug_logger off Receives one line per request and response. See Logging.

The constructor raises ValueError when there is no API key, when the server URL is not HTTPS (localhost is allowed) or when timeout_ms is not a positive integer.

Make one client per process and reuse it. Use it as a context manager (with or async with) to close the HTTP clients it opened.

Every method also takes server_url, timeout_ms and http_headers for that call, and every method except keys.rotate takes retries.

The same NoResponse object has synchronous methods and _async methods. There is no separate async client class.

from noresponse import NoResponse
with NoResponse() as nr:
assessment = nr.assessments.get("asm_01JA2Z8K6Q4V7W9XBCDEFGHJKM")
async def read() -> None:
async with NoResponse() as nr:
assessment = await nr.assessments.get_async("asm_01JA2Z8K6Q4V7W9XBCDEFGHJKM")
print(assessment.status)
Method Request Result
assessments.create(...) POST /v1/assessments Assessment
assessments.get(id) GET /v1/assessments/{id} Assessment
assessments.wait(id) GET /v1/assessments/{id}, repeated Assessment
assessments.query(...) POST /v1/assessments/query AssessmentPage
assessments.record_outcome(id, ...) POST /v1/assessments/{id}/outcomes AssessmentOutcome
keys.rotate(...) POST /v1/keys/rotate KeyRotation

Each method has an _async form: create_async, get_async, wait_async, query_async, record_outcome_async and rotate_async. Methods return the model itself, not an envelope. The response headers are on its meta attribute.

from noresponse import NoResponse
nr = NoResponse()
assessment = nr.assessments.create(
account_id="usr_8f2k1",
identifiers={"email": "[email protected]", "name": "Jordan Lee", "ip": "81.2.69.160"},
policy_context={"flow": "signup", "region": "GB"},
client_context={"timezone": "Europe/London", "platform": "web", "form_duration_ms": 8400},
response_mode="standard",
deadline_ms=400,
request_id="req-2026-10-21-0001",
idempotency_key="signup-usr_8f2k1-01",
)
Argument Notes
account_id Your opaque account id. Required.
identifiers A mapping of email, username or phone; name and ip refine the match. Required.
policy_context flow (signup, login, recovery or profile_change) and region.
client_context Browser and form signals such as timezone, platform and form_duration_ms.
auth_context A verified session, built with an auth adapter.
response_mode standard, or detailed to add signals and risk.details (needs the detailed scope).
deadline_ms How long the API may spend before it answers pending.
request_id Your own correlation id for the request.
idempotency_key Optional. See Idempotency.

The answer is the assessment whether the API answered 200 (complete or partial) or 202 (pending). status is complete, partial or pending. risk.recommendation is allow, step_up or review and risk is None while the status is pending, so apply your fallback first and then wait or take the webhook.

The idempotency key is a UUID when you leave it out, and it is sent again on every retry of that call. Pass your own, derived from your signup attempt, to make a retry from your side replay the same assessment: the API answers the latest version with meta.idempotent_replayed true and starts no new work. The same key with a different body answers 409. assessments.record_outcome takes an idempotency_key in the same way.

latest = nr.assessments.get("asm_01JA2Z8K6Q4V7W9XBCDEFGHJKM")
settled = nr.assessments.wait("asm_01JA2Z8K6Q4V7W9XBCDEFGHJKM", timeout=10.0)

get takes an optional response_mode. wait polls get until the status is no longer pending. It sleeps for the server’s Retry-After value, with a one-second floor, between polls. It raises WaitTimeoutError after timeout seconds (30 by default), and the error’s last attribute is the final pending assessment. WaitTimeoutError is an APITimeoutError. wait also takes response_mode. The result can still arrive later by webhook. In async code use get_async and wait_async.

wait and wait_async are also functions in the noresponse namespace: noresponse.wait(nr, assessment_id).

page = nr.assessments.query(account_id="usr_8f2k1", limit=20)
print(len(page.items), page.has_more, page.next_cursor)
next_page = page.next_page() # the next page, or None after the last
for assessment in page: # every assessment across every page
print(assessment.assessment_id, assessment.status)

query returns an AssessmentPage. items holds the assessments on this page, newest first, each at its latest version. has_more is true when another page exists. next_cursor is opaque; pass it back as cursor to continue from a page. limit defaults to 20. Iterating the page walks every item across every page and fetches pages as it goes.

In async code, take the page with query_async, then use async for and next_page_async():

from noresponse import NoResponse
async def history() -> None:
async with NoResponse() as nr:
page = await nr.assessments.query_async(account_id="usr_8f2k1")
async for assessment in page:
print(assessment.assessment_id)
# Or one page at a time: await page.next_page_async()
outcome = nr.assessments.record_outcome(
"asm_01JA2Z8K6Q4V7W9XBCDEFGHJKM",
outcome="confirmed_abuse", # or account_takeover, chargeback, false_positive, legitimate, unknown
observed_at="2026-11-04T10:15:00Z",
external_ref="case-4821",
notes="Credits spent on a relay within an hour of signup.",
idempotency_key="outcome-case-4821",
)
print(outcome.id, outcome.received_at)
rotation = nr.keys.rotate(overlap_seconds=3600)
print(rotation.key) # the new key, shown once
print(rotation.previous_key_expires_at) # when the key that signed the request stops working

The default overlap is 24 hours, with a minimum of 60 seconds and a maximum of 7 days. Rotation is not idempotent, so it is never retried. Use the new key from then on.

Every returned object carries meta. It is not part of the body and is left out of model_dump():

assessment = nr.assessments.get("asm_01JA2Z8K6Q4V7W9XBCDEFGHJKM")
print(assessment.meta.request_id) # X-Request-ID, to quote when you contact us
print(assessment.meta.retry_after) # seconds, when the API sent Retry-After
print(assessment.meta.location) # the polling path of a pending assessment
print(assessment.meta.idempotent_replayed) # True when the answer replays an earlier request
print(assessment.meta.headers) # every response header, with lower-case names

An assessment that arrives by webhook or inside a page has an empty meta.

nr.assessments.with_raw_response.get(...) returns an object with status_code, headers, request_id and http_response. Its parse() returns the generated envelope, whose result is the model. Use it when you need the status line or every header.

Every API error extends NoResponseError and has the same attributes whatever the status: status, type, title, detail, code, request_id, retry_after and, on a 400, invalid_params (the offending fields). The raw status_code, headers, body and raw_response are there too. str(error) carries the code, title, detail and request id.

Class When
BadRequestError 400: the body or a header is malformed; see invalid_params and code
AuthenticationError 401: missing, malformed, revoked or expired key
PermissionDeniedError 403: the key lacks the operation’s scope
NotFoundError 404: unknown, expired or foreign assessment
ConflictError 409: idempotency key reused with a different body, or a key already rotating
RateLimitError 429: see retry_after
APIStatusError any other 4xx or 5xx, including 503
ResponseValidationError a 2xx the client could not read (code is invalid_response)
APIConnectionError DNS, TLS or connection failure
APITimeoutError the request or a wait timed out; also an APIConnectionError and a TimeoutError

The two transport errors do not extend NoResponseError, because no response exists to describe.

from noresponse import APIConnectionError, NoResponse, NoResponseError, RateLimitError
nr = NoResponse()
try:
nr.assessments.create(account_id="usr_8f2k1", identifiers={"email": "[email protected]"})
except RateLimitError as error:
print("retry after", error.retry_after)
except APIConnectionError: # includes APITimeoutError
print("could not reach the API")
except NoResponseError as error:
print(error.status, error.code, error.request_id)

Connection errors, 408, 429 and every 5xx are retried with exponential backoff, from 500 ms doubling to 8 s between attempts, for at most 30 s in all. When the API sends Retry-After, the client sleeps that long instead. A POST sends the same Idempotency-Key on every attempt, so a retried create or outcome is recorded once. 409 and other 4xx are not retried.

Override the policy for the client with retry_config, or for one call with retries:

from noresponse import NoResponse
from noresponse.utils import BackoffStrategy, RetryConfig
nr = NoResponse()
nr.assessments.create(
account_id="usr_8f2k1",
identifiers={"email": email},
retries=RetryConfig("backoff", BackoffStrategy(200, 2000, 2, 10_000), True),
)
nr.assessments.create(account_id="usr_8f2k1", identifiers={"email": email}, retries=None) # no retries

Verify the exact bytes you received, then act on the typed event. secrets maps each whk_ key id we give you to its secret. Hold both during a rotation.

webhooks.py
import os
from fastapi import FastAPI, Request, Response
from fastapi.responses import JSONResponse
from noresponse import Assessment, AssessmentExpired, Webhook, WebhookVerificationError
SECRETS = {os.environ["NORESPONSE_WEBHOOK_KEY_ID"]: os.environ["NORESPONSE_WEBHOOK_SECRET"]}
app = FastAPI()
@app.post("/webhooks/noresponse")
async def noresponse_webhook(request: Request) -> Response:
try:
event = Webhook.construct_event(await request.body(), request.headers, SECRETS)
except WebhookVerificationError:
return Response(status_code=400)
if event.type == "endpoint.verification":
return JSONResponse(Webhook.verification_response(event))
if isinstance(event.data, AssessmentExpired):
forget(event.data.assessment_id) # GET now answers 404
elif isinstance(event.data, Assessment):
apply(event.data) # the full assessment, as assessments.get returns it
return Response(status_code=204)

construct_event(payload, headers, secrets, tolerance_seconds=300) takes the raw body (bytes or the exact text) and any header mapping or list of pairs. It raises WebhookVerificationError for a missing or repeated header, a delivery older than the tolerance, an unknown key id, a bad signature or an invalid payload. The error’s code says which: missing_header, duplicate_header, invalid_timestamp, unknown_key, invalid_signature or invalid_payload.

The event has id, type (assessment.updated or endpoint.verification), created_at and data. data is an Assessment, an AssessmentExpired or an EndpointVerification. event.expired is true for an expiry. verification_response(event) returns the dictionary to send back for an endpoint.verification event.

Deliveries are at least once and may arrive out of order. Deduplicate on event.id and keep the highest assessment_version for each assessment. An expiry is terminal, so remove the stored assessment.

The same functions are on Webhook, on nr.webhooks and in the noresponse namespace as construct_event and verification_response.

An adapter maps claims your backend has already verified into the auth_context of an assessment request. The SDK does not verify tokens. Pass verified claims, never a raw token. A claim that is missing, mistyped or out of range maps to absent, never to a stronger assurance.

Every adapter takes the verified claims and an optional mapping of overrides that uses the auth_context field names, for example email_verified or account_created_at, and returns an AuthContext. An adapter raises ValueError when the claims have no valid issuer and subject.

from typing import Any
from noresponse import (
AuthContext,
from_auth0,
from_clerk,
from_firebase,
from_oidc,
from_supabase,
from_workos,
)
claims: dict[str, Any] = {"iss": "https://api.workos.com", "sub": "user_01H"}
workos_context: AuthContext = from_workos(claims, {"email_verified": True})
clerk_context: AuthContext = from_clerk(claims, {"session_created_at": "2026-10-21T09:00:00Z"})
auth0_context: AuthContext = from_auth0(claims, {"account_created_at": "2025-03-02T08:30:00Z"})
supabase_context: AuthContext = from_supabase(claims, {"email_verified": True})
firebase_context: AuthContext = from_firebase(claims)
oidc_context: AuthContext = from_oidc("keycloak", claims, {"email_verified": True})
Adapter Claims to pass
from_workos Verified WorkOS AuthKit claims, with the user or session record in overrides.
from_clerk Verified Clerk session claims. Pass Session.created_at as session_created_at.
from_auth0 A verified ID token, userinfo or normalised session claims. Do not pass an opaque API access token.
from_supabase Verified Supabase Auth access token claims. Pass the user record’s contact verification in overrides.
from_firebase Verified claims from the Admin SDK verify_id_token. Custom claims never change the mapping.
from_oidc(provider, claims, overrides) Any other provider. provider is a lowercase slug of 2 to 32 characters: letters, digits, dots, underscores and hyphens, starting with a letter or digit.

Pass the result as auth_context to assessments.create. The auth-session guide shows each provider end to end.

Set NORESPONSE_LOG=debug to log to the standard noresponse logger, or pass debug_logger (anything with a debug method) to the client. Each request and response is one line with the method, URL, status and request id. The API key, other headers and bodies are kept out of the log.

Version 2 is a new client. Every call site changes, and the import name is now noresponse.

1.0.1 2.x
import noresponse_sdk import noresponse
Client(api_key=, base_url=, timeout=5.0, transport=, max_retries=, retry_base=) NoResponse(api_key, server_url=, timeout_ms=10_000, client=httpx.Client(transport=...), retry_config=)
client.assess(account_id=, identifiers={...}, idempotency_key=) nr.assessments.create(account_id=, identifiers={...})
client.assess_signup(account_id=, email=, name=, ip=, idempotency_key=) nr.assessments.create(account_id=, identifiers={"email":, "name":, "ip":}, policy_context={"flow": "signup"})
client.get_assessment(id, response_mode=) nr.assessments.get(id, response_mode=)
client.wait_for_assessment(id) nr.assessments.wait(id, timeout=)
client.query_assessments(account_id=, limit=, cursor=) nr.assessments.query(account_id=, limit=, cursor=)
client.record_outcome(id, outcome=, idempotency_key=) nr.assessments.record_outcome(id, outcome=)
(none) nr.keys.rotate(overlap_seconds=)
verify_webhook(raw_body, header_pairs, secrets) construct_event(payload, headers, secrets) or Webhook.construct_event(...)
webhook_assessment(event) event.data, already an Assessment
webhook_verification_response(event) (bytes) verification_response(event) (a dict)
NoResponseError(status, retry_after, problem, request_id) and error.retryable NoResponseError with status, retry_after, request_id, code, title and detail; error.problem and error.retryable are gone
noresponse_sdk.generated_models.Assessment (a TypedDict: assessment["status"]) noresponse.Assessment (a model: assessment.status)
from_workos, from_clerk, from_auth0, from_oidc (in noresponse_sdk) noresponse.from_workos and the others, imported from noresponse

Retries now cover connection errors, 408, 429 and every 5xx. Version 1.0.1 retried network errors, 429 and 503. Backoff runs from 500 ms to 8 s for at most 30 s in all. Python 3.10 or later is required, where 1.0.1 needed 3.11.

Documentation is at docs.noresponse.org. Report issues at github.com/noresponsehq/python/issues, and quote meta.request_id or error.request_id when you write to us.