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.
Install
Section titled “Install”pip install noresponseThe package needs Python 3.10 or later and uses httpx and pydantic. The import name is noresponse. Keep the API key on your server.
Client
Section titled “Client”import os
import httpxfrom 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.
Sync and async
Section titled “Sync and async”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)Methods
Section titled “Methods”| 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.
assessments.create
Section titled “assessments.create”from noresponse import NoResponse
nr = NoResponse()
assessment = nr.assessments.create( account_id="usr_8f2k1", 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.
Idempotency
Section titled “Idempotency”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.
assessments.get and assessments.wait
Section titled “assessments.get and assessments.wait”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).
assessments.query
Section titled “assessments.query”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()assessments.record_outcome
Section titled “assessments.record_outcome”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)keys.rotate
Section titled “keys.rotate”rotation = nr.keys.rotate(overlap_seconds=3600)print(rotation.key) # the new key, shown onceprint(rotation.previous_key_expires_at) # when the key that signed the request stops workingThe 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.
Response metadata
Section titled “Response metadata”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 usprint(assessment.meta.retry_after) # seconds, when the API sent Retry-Afterprint(assessment.meta.location) # the polling path of a pending assessmentprint(assessment.meta.idempotent_replayed) # True when the answer replays an earlier requestprint(assessment.meta.headers) # every response header, with lower-case namesAn assessment that arrives by webhook or inside a page has an empty meta.
Raw responses
Section titled “Raw responses”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.
Errors
Section titled “Errors”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: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)Retries
Section titled “Retries”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 NoResponsefrom 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 retriesWebhooks
Section titled “Webhooks”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.
import os
from fastapi import FastAPI, Request, Responsefrom fastapi.responses import JSONResponsefrom 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.
Auth adapters
Section titled “Auth adapters”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.
Logging
Section titled “Logging”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.
Upgrading from 1.x
Section titled “Upgrading from 1.x”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.
Support
Section titled “Support”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.