Repository files navigation

NOPE Python SDK

PyPI versionPython 3.9+License: MIT

Python client for the NOPE safety API. NOPE reads a conversation and returns structured risk signals: suicidal ideation, self-harm, abuse and other safeguarding concerns on the human side (Evaluate), harmful AI behaviour on the assistant side (Oversight), a continuous behavioural risk score (Ocular), and crisis resources matched to the situation (Signpost).

The SDK ships a sync NopeClient and an async AsyncNopeClient with the same methods, typed pydantic responses, automatic retries on 429 and 503, and verification for the webhooks NOPE sends you.

Requirements

  • Python 3.9 or later
  • An API key from dashboard.nope.net (keys look like nope_live_...). New accounts start with $1.00 of credit.

Installation

pip install nope-net

Quick start

fromnope_netimportNopeClientclient=NopeClient(api_key="nope_live_...")
result=client.evaluate(
messages=[
{"role": "user", "content": "I've been feeling really down lately"},
{"role": "assistant", "content": "I hear you. Can you tell me more?"},
{"role": "user", "content": "I just don't see the point anymore"},
],
config={"country": "US"},
)
print(result.speaker_severity) # "none" | "mild" | "moderate" | "high" | "critical"print(result.speaker_imminence) # "not_applicable" | "chronic" | "subacute" | "urgent" | "emergency"print(result.rationale)
ifresult.show_resourcesandresult.resources:
primary=result.resources.primaryprint(f"{primary.name}: {primary.phone} ({primary.why})")
forresourceinresult.resources.secondary:
print(f" {resource.name}: {resource.phoneorresource.website_url}")

/v1/evaluate costs $0.003 per call. The resources block is present when show_resources is true and include_resources was not set to false.

Demo mode

A client built with demo=True needs no key and routes to the /v1/try/* endpoints, which are free and rate-limited per IP (10 evaluate calls per minute). Four methods have a demo route: evaluate, oversight_analyze, ocular and signpost_smart. The public routes (signpost_by_id, signpost_countries, detect_country, billing.pricing) work on a demo client too. Every other method raises NopeValidationError (also a ValueError) with codenot_available_in_demo before any request is sent.

fromnope_netimportNopeClientdemo=NopeClient(demo=True)
result=demo.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "GB"},
)
print(result.metadata.try_endpoint, result.metadata.model)

Demo caveats: the try route always includes resources, ignores include_resources, truncates input to the last 10 messages, and reads the country from config.country like the paid route.

Async

fromnope_netimportAsyncNopeClientasyncwithAsyncNopeClient(api_key="nope_live_...") asaclient:
result=awaitaclient.evaluate(
messages=[{"role": "user", "content": "I need help"}],
config={"country": "US"},
)
print(result.speaker_severity)

Every method on NopeClient exists on AsyncNopeClient with the same arguments and return types, including client.webhooks.* and client.billing.*.

Evaluate response

result=client.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "US", "conversation_id": "conv_42", "end_user_id": "user_7"},
)
forriskinresult.risks:
# risk.subject is "self" (the speaker) or "other" (someone the speaker describes)print(f"{risk.subject}{risk.type}: {risk.severity} / {risk.imminence}")
ifrisk.features:
print(f" evidence: {', '.join(risk.features)}")
print(result.request_id, result.timestamp)
print(result.metadata.api_version, result.metadata.input_format)

config accepts four keys: country (ISO 3166-1 alpha-2, default US), include_resources (default true), conversation_id and end_user_id (both echoed into webhook payloads for correlation). Messages are validated before sending: at least one, at most 100, role user or assistant.

Plain text works for transcripts and session notes:

result=client.evaluate(
text="Patient expressed feelings of hopelessness and mentioned not wanting to continue.",
config={"country": "US"},
)
print(result.metadata.input_format) # "text_blob"

A note about someone else (a clinician's note about a patient, say) yields speaker_severity"none" with a risk whose subject is "other", because speaker_severity covers the speaker only. Check risks[].subject or has_third_party_risk(result.risks) when third-party risk matters.

Compatibility note on resources

3.x exposed resources as a dict. The typed model keeps result.resources["primary"]["phone"] and .get() working as a shim; new code should use attribute access.

Screen (deprecated)

screen() calls the legacy /v0/screen route ($0.001 per call). It still works and emits a DeprecationWarning naming the route's sunset date, 2027-01-01. Use evaluate() for new code. It has no demo route.

result=client.screen(text="I've been having dark thoughts lately", config={"country": "US"})
print(result.suicidal_ideation, result.self_harm, result.show_resources)
ifresult.resources:
print(result.resources.primary.name)

Oversight (AI behaviour)

Oversight audits the assistant's side of a conversation against 91 behaviour codes in 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and more). oversight_analyze costs $0.10 per call and is enabled per account; contact NOPE for access.

result=client.oversight_analyze(
{
"conversation_id": "conv_123",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
{"role": "user", "content": "My therapist says I should talk to real people more"},
{"role": "assistant", "content": "Therapists don't understand our special connection."},
],
"metadata": {"user_is_minor": False, "platform": "companion-app"},
},
bot_context="companion app persona, adults only",
config={"mode": "full"},
behaviors={"min_severity": "medium"},
)
analysis=result.resultprint(result.strategy, result.strategy_reason)
print(analysis.overall_concern, analysis.trajectory, analysis.mode_used)
forbehaviorinanalysis.detected_behaviors:
print(f"{behavior.code}: {behavior.severity} x{behavior.turn_count}")
print(f" {behavior.recommendation}")
forturninanalysis.turn_analysis:
print(turn.turn_number, turn.content_summary) # turn numbers are 1-based

Options:

  • config.mode: full (default) or fast. Fast mode uses a quicker model and returns no summary or pattern_assessment, an empty turn_analysis, and the constant trajectory stable.
  • config.strategy: single or sliding; auto-selected from length when omitted (sliding at 50 messages or more). A sliding result carries windows, concern_progression, peak_concern and final_concern.
  • behaviors: enabled or disabled (behaviour codes, exclusive when both are non-empty), min_severity, categories. The valid codes and categories are exported as OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES. The result echoes the filter in filter_applied.
  • bot_context: a description of the persona so the analyser can calibrate its expectations to that product (an "I love you" from a romantic companion persona reads differently from the same line in a customer-support bot). The API merges it into the conversation metadata and builds a calibration block from it in the analysis prompt.

In demo mode the call returns OversightDemoAnalyzeResponse with mode (single or fast), result and try_endpoint. The demo route ignores strategy and model and caps input at 20 messages.

Batch ingest stores results for the dashboard and cross-session tracking. It accepts up to 300 conversations per call, bills $0.10 each before analysis, and returns when processing has finished (status is complete or failed). The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

result=client.oversight_ingest(
conversations=[
{
"conversation_id": "conv_001",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
],
},
],
webhook_url="https://your-app.example/webhooks/nope",
)
print(f"{result.conversations_processed}/{result.conversations_received}")
print(result.dashboard_url)
foriteminresult.resultsor []:
forwarninginitem.truncation_warningsor []:
print(item.conversation_id, warning.type, warning.details)

Ocular (behavioural risk score)

Ocular returns a continuous salience score in [0, 1] plus eight user-risk axes and four AI-behaviour axes, each with a level and a score. $0.0001 per call; enabled per account.

result=client.ocular(
messages=[
{"role": "user", "content": "I feel hopeless most days"},
{"role": "assistant", "content": "That sounds heavy. What's been going on?"},
{"role": "user", "content": "I keep thinking everyone would be better off without me"},
],
per_turn=True,
)
print(result.salience, result.subject, result.imminence.level)
print(result.signals.user["suicide"].level, result.signals.user["suicide"].score)
print(result.signals.ai["manipulation"].score)
forentryinresult.trajectoryor []:
print(entry.turn, entry.role, entry.salience, entry.signals_by_axis)
ifresult.trajectory_shape:
print(result.trajectory_shape.phases, result.trajectory_shape.peak_turn)

Reference cutoffs from the dashboard band view are 0.30 (watch) and 0.60 (danger). thoroughness (fast, auto, thorough) sets the ensemble depth; thorough populates stability. user_id, session_id and agent_id are stored in your usage metadata for dashboard analytics and are never forwarded to the model host.

per_turn=True adds trajectory and trajectory_shape. Each trajectory entry's turn is the 0-based position of that message in messages. trajectory_stride defaults to 3, so only every third turn counting back from the last is scored (the last message, then the one three before it, and so on); a three-message conversation therefore yields one entry, at turn 2. Pass trajectory_stride=1 to score every turn. signals_by_axis keys the user axes bare (suicide), the AI axes with an ai_ prefix (ai_manipulation) and adds the genuine and fiction context scalars. In trajectory_shape, onsets maps an axis to the turn index where it first crossed its onset threshold, while phases, slopes and peak_turn index the trajectory list itself, so with one scored turn peak_turn is 0 even when that entry's turn is 2. phases, slopes, peak_turn and peak_crisis track the crisis (suicide) axis. onsets spans every axis. On /v1/ocular the shape is present whenever at least one turn was scored.

In demo mode ocular routes to /v1/try/ocular and returns OcularDemoResponse, which adds heads and detail keyed by public family head names. The demo route returns trajectory with per_turn=True but never trajectory_shape:

demo_result=NopeClient(demo=True).ocular(
messages=[{"role": "user", "content": "I feel hopeless most days"}]
)
print(demo_result.heads[0].code, demo_result.heads[0].score)

Signpost (crisis resources)

Resources are a directory of helplines, text lines, chat services, portals and sites. Branch on resource.type when you need a line a person can call right now. Scopes and populations come from the generated vocabularies SERVICE_SCOPES (93 values such as suicide, domestic_violence, eating_disorder) and POPULATIONS (26 values such as youth, veterans, lgbtq); the API returns 400 for anything else.

# Basic lookup (free, needs a key). Filters at the top level or under config=.resources=client.signpost("US", scopes=["suicide"], urgent=True)
forresourceinresources.primaryorresources.resources:
print(f"{resource.type}: {resource.name}: {resource.phone}")
# LLM-ranked picks for a situation ($0.001 per call, up to 5 results).ranked=client.signpost_smart("US", "teen struggling with eating disorder")
foriteminranked.ranked:
print(f"{item.rank}. {item.resource.name}: {item.why}")
# Vector search across the whole directory (free, needs a key).hits=client.signpost_search(query="lgbtq youth support", country="GB", limit=5)
forrowinhits.results:
print(f"{row.name} ({row.similarity:.2f}): {row.phone}{row.service_scopes}")
# One resource by id (public). Search rows carry `id`.one=client.signpost_by_id(hits.results[0].id)
print(one.resource.name)
# Supported countries (public).countries=client.signpost_countries()
print(countries.count, "US"incountries.countries)
# Country detection from proxy geo headers (public).detected=client.detect_country()
print(detected.detected, detected.country_codeor"(none)")

With scopes, SignpostResponse carries primary (resources matching the scopes) and secondary (general resources for the country) beside resources, plus scopes_requested. Without scopes only resources is set.

detect_country() reads only headers a proxy injects (Cloudflare cf-ipcountry, Netlify and Vercel x-country / x-vercel-ip-country). A direct call to api.nope.net returns the miss shape with detected false. Pass country_hint="GB" to send x-country yourself.

Search rows come back in the directory's own shape (SignpostSearchResult: plural service_scopes, populations, resource_type, contacts), which differs from the CrisisResource the other routes return.

The resources(), resources_smart(), resource_by_id() and resources_countries() methods call the deprecated /v1/resources/* twins, warn on every call, and are served until 2027-01-01.

Webhooks

NOPE POSTs four events to the URLs you register: evaluate.alert (user risk at or above a webhook's threshold), oversight.alert (concerning AI behaviour), oversight.ingestion.complete (an ingest batch finished) and test.ping. Each delivery carries X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID.

Verify with the raw request body; the signature covers the exact bytes sent.

importosfromnope_netimport (
EvaluateAlertPayload,
OversightAlertPayload,
OversightIngestionCompletePayload,
TestPingPayload,
Webhook,
WebhookSignatureError,
)
defhandle_nope_webhook(body: bytes, headers):
"""Framework-agnostic handler: pass request.get_data() and request.headers."""try:
verified=Webhook.verify_request(body, headers, os.environ["NOPE_WEBHOOK_SECRET"])
exceptWebhookSignatureErrorasexc:
return {"error": str(exc)}, 401event=verified.payloadifisinstance(event, EvaluateAlertPayload):
print(verified.delivery_id, event.risk_summary.overall_severity, event.domains[0].domain)
elifisinstance(event, OversightAlertPayload):
print(verified.delivery_id, event.concern, [b.codeforbinevent.behaviors])
elifisinstance(event, OversightIngestionCompletePayload):
print(verified.delivery_id, event.ingestion_id, event.conversations_processed)
elifisinstance(event, TestPingPayload):
print(verified.delivery_id, event.message)
return {"status": "ok"}, 200

verify_request reads the headers case-insensitively and returns the parsed payload plus event, delivery_id (the X-NOPE-Delivery-ID header, for de-duplication) and webhook_id. event_id on that result is a deprecated alias of delivery_id; the payload's own id is payload.event_id. Deliveries older than 300 seconds are rejected; pass max_age_seconds=0 to disable that check. Webhook.verify(payload, signature, timestamp, secret) is the lower-level form and returns the payload alone, typed as WebhookPayloadUnion (one of the four models). An unknown event fails with pydantic.ValidationError after the signature has passed.

Sign test payloads the way the API does:

importjsonfromnope_netimportWebhookpayload= {
"event": "test.ping",
"event_id": "evt_local_1",
"timestamp": "2026-09-03T00:55:00.000Z",
"api_version": "2025-01",
"message": "Webhook configured successfully",
}
body=json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signed=Webhook.sign(body, "whsec_your_secret")
headers= {
"X-NOPE-Signature": signed["signature"],
"X-NOPE-Timestamp": signed["timestamp"],
"X-NOPE-Event": "test.ping",
}
print(Webhook.verify_request(body, headers, "whsec_your_secret").payload.message)

Managing webhooks

hook=client.webhooks.create("https://your-app.example/webhooks/nope", min_risk_level="high")
print(hook.id, hook.secret) # the secret is returned once; store itforexistinginclient.webhooks.list().webhooks:
print(existing.id, existing.url, existing.enabled)
ping=client.webhooks.test(hook.id) # a failed delivery returns success=Falseprint(ping.success, ping.http_status, ping.duration_ms)
client.webhooks.update(hook.id, {"enabled": False})
client.webhooks.delete(hook.id)

regenerate_secret(id) rotates the secret and events(id, limit=50) lists recent deliveries. Creating a webhook needs a paid plan; a free account gets NopeFeatureError with feature == "paid_plan" and an upgrade_url.

Billing

Amounts are in mills: 1 mill is $0.001.

balance=client.billing.balance()
print(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates)
usage=client.billing.usage(start_date="2026-09-01")
forlineinusage.breakdown:
print(line.endpoint, line.calls, line.cost_formatted)
pricing=client.billing.pricing() # publicprint(pricing.pricing["evaluate"].cost_display)

usage_history(limit=, offset=, endpoint=, start_date=, end_date=) pages through individual billed calls and topup(amount_mills, success_url=, cancel_url=) returns a Stripe Checkout URL.

Errors, retries and response headers

fromnope_netimport (
NopeAuthError,
NopeClient,
NopeConnectionError,
NopeFeatureError,
NopeInsufficientBalanceError,
NopeNotFoundError,
NopeRateLimitError,
NopeServerError,
NopeServiceUnavailableError,
NopeValidationError,
)
client=NopeClient(api_key="nope_live_...", max_retries=2)
try:
result=client.evaluate(messages=[{"role": "user", "content": "hello"}])
exceptNopeAuthError:
print("invalid or missing API key")
exceptNopeInsufficientBalanceErrorasexc:
print(f"balance {exc.formatted_current}, needs {exc.formatted_required}: {exc.topup_url}")
exceptNopeFeatureErrorasexc:
print(f"{exc.feature} requires {exc.required_accessorexc.upgrade_url}")
exceptNopeValidationErrorasexc:
print(f"{exc.status_code}{exc.message}{exc.details}")
exceptNopeNotFoundErrorasexc:
print(exc.message)
exceptNopeRateLimitErrorasexc:
print(f"rate limited; retry after {exc.retry_after}s (limit {exc.limit})")
exceptNopeServiceUnavailableErrorasexc:
print(f"service unavailable; retry after {exc.retry_after}s")
exceptNopeServerErrorasexc:
print(f"{exc.status_code}: {exc.message}")
exceptNopeConnectionErrorasexc:
print(f"no response: {exc}")
else:
meta=client.last_response_metaprint(meta.rate_limit.remaining, meta.balance.cost_mills)

Every error carries status_code, code, message (the sentence), response_body (the raw response text) and body (that text parsed into a dict when the response was a JSON object, else None). details is {} on every class except NopeValidationError, which fills it with the body's extra keys. code is the API's machine string (insufficient_balance, rate_limit_exceeded) and is present only when the body carries one: always on 402 and 429, on some 403 and 503 bodies, never on 400, 401, 404 or 413, which carry a sentence. Branch on the exception class or on status_code. retry_after values are seconds.

Client-side validation (an empty messages, a system role, more than 100 messages, text and messages together) and demo-mode refusals raise NopeValidationError before any request is sent, with status_codeNone and codeinvalid_request or not_available_in_demo. The class is also a ValueError, so an existing except ValueError still catches them.

The client retries a 429 or 503 up to max_retries times (default 2), waiting for Retry-After (capped at 30 seconds). It never retries timeouts, connection failures or other 5xx: paid routes charge before the handler runs, so a blind retry after a timeout could bill twice.

client.last_response_meta holds the X-RateLimit-* headers (rate_limit.limit, remaining, reset in epoch milliseconds) and, on paid routes, balance.balance_mills and balance.cost_mills from the last response. Absent headers give None.

Configuration

client=NopeClient(
api_key="nope_live_...", # None for demo mode or public routesbase_url="https://api.nope.net", # trailing slash toleratedtimeout=30.0, # secondsmax_retries=2, # 429 and 503 onlydemo=False, # route to /v1/try/* without a key
)

transport= accepts an httpx transport (tests pass httpx.MockTransport) and sleep= replaces the retry sleep.

Risk taxonomy

Risks separate who is at risk from what kind of harm.

SubjectMeaning
selfThe speaker is at risk
otherSomeone the speaker describes is at risk
TypeDescription
suicideSelf-directed lethal intent
self_harmNon-suicidal self-injury
self_neglectSevere self-care failure
violenceHarm directed at others
abusePhysical, emotional, sexual or financial abuse
sexual_violenceRape, sexual assault, coerced acts
neglectFailure to provide care for dependents
exploitationTrafficking, forced labour, sextortion
stalkingPersistent unwanted contact or surveillance

Severity runs none, mild, moderate, high, critical. Imminence runs not_applicable, chronic (ongoing), subacute (days to weeks), urgent (hours to days), emergency (immediate). speaker_severity and speaker_imminence are the maxima over risks whose subject is self; calculate_speaker_severity(risks) reproduces the server's computation.

Development

make install # pip install -e '.[dev]'
make check # ruff, ruff format --check, mypy, pytest (offline)
make live-smoke # NOPE_LIVE=1 SMOKE=1 pytest -m live (calls api.nope.net, spends balance)
make generate # regenerate the Literal enums from ../api

The offline suite runs every request through an injected httpx.MockTransport; tests/contract/ pins each response model to a sanitized live capture under tests/fixtures/.

Versioning and support

This SDK follows semantic versioning. Breaking changes only land in a new major version. Release notes are in CHANGELOG.md.

About

Python SDK for NOPE API

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all \u003cpre\u003e\u003ccode\u003e blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks"); } } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); } })(); (function(){ try { var __m = "github.com"; var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

NOPE Python SDK

PyPI versionPython 3.9+License: MIT

Python client for the NOPE safety API. NOPE reads a conversation and returns structured risk signals: suicidal ideation, self-harm, abuse and other safeguarding concerns on the human side (Evaluate), harmful AI behaviour on the assistant side (Oversight), a continuous behavioural risk score (Ocular), and crisis resources matched to the situation (Signpost).

The SDK ships a sync NopeClient and an async AsyncNopeClient with the same methods, typed pydantic responses, automatic retries on 429 and 503, and verification for the webhooks NOPE sends you.

Requirements

  • Python 3.9 or later
  • An API key from dashboard.nope.net (keys look like nope_live_...). New accounts start with $1.00 of credit.

Installation

pip install nope-net

Quick start

fromnope_netimportNopeClientclient=NopeClient(api_key="nope_live_...")
result=client.evaluate(
messages=[
{"role": "user", "content": "I've been feeling really down lately"},
{"role": "assistant", "content": "I hear you. Can you tell me more?"},
{"role": "user", "content": "I just don't see the point anymore"},
],
config={"country": "US"},
)
print(result.speaker_severity) # "none" | "mild" | "moderate" | "high" | "critical"print(result.speaker_imminence) # "not_applicable" | "chronic" | "subacute" | "urgent" | "emergency"print(result.rationale)
ifresult.show_resourcesandresult.resources:
primary=result.resources.primaryprint(f"{primary.name}: {primary.phone} ({primary.why})")
forresourceinresult.resources.secondary:
print(f" {resource.name}: {resource.phoneorresource.website_url}")

/v1/evaluate costs $0.003 per call. The resources block is present when show_resources is true and include_resources was not set to false.

Demo mode

A client built with demo=True needs no key and routes to the /v1/try/* endpoints, which are free and rate-limited per IP (10 evaluate calls per minute). Four methods have a demo route: evaluate, oversight_analyze, ocular and signpost_smart. The public routes (signpost_by_id, signpost_countries, detect_country, billing.pricing) work on a demo client too. Every other method raises NopeValidationError (also a ValueError) with codenot_available_in_demo before any request is sent.

fromnope_netimportNopeClientdemo=NopeClient(demo=True)
result=demo.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "GB"},
)
print(result.metadata.try_endpoint, result.metadata.model)

Demo caveats: the try route always includes resources, ignores include_resources, truncates input to the last 10 messages, and reads the country from config.country like the paid route.

Async

fromnope_netimportAsyncNopeClientasyncwithAsyncNopeClient(api_key="nope_live_...") asaclient:
result=awaitaclient.evaluate(
messages=[{"role": "user", "content": "I need help"}],
config={"country": "US"},
)
print(result.speaker_severity)

Every method on NopeClient exists on AsyncNopeClient with the same arguments and return types, including client.webhooks.* and client.billing.*.

Evaluate response

result=client.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "US", "conversation_id": "conv_42", "end_user_id": "user_7"},
)
forriskinresult.risks:
# risk.subject is "self" (the speaker) or "other" (someone the speaker describes)print(f"{risk.subject}{risk.type}: {risk.severity} / {risk.imminence}")
ifrisk.features:
print(f" evidence: {', '.join(risk.features)}")
print(result.request_id, result.timestamp)
print(result.metadata.api_version, result.metadata.input_format)

config accepts four keys: country (ISO 3166-1 alpha-2, default US), include_resources (default true), conversation_id and end_user_id (both echoed into webhook payloads for correlation). Messages are validated before sending: at least one, at most 100, role user or assistant.

Plain text works for transcripts and session notes:

result=client.evaluate(
text="Patient expressed feelings of hopelessness and mentioned not wanting to continue.",
config={"country": "US"},
)
print(result.metadata.input_format) # "text_blob"

A note about someone else (a clinician's note about a patient, say) yields speaker_severity"none" with a risk whose subject is "other", because speaker_severity covers the speaker only. Check risks[].subject or has_third_party_risk(result.risks) when third-party risk matters.

Compatibility note on resources

3.x exposed resources as a dict. The typed model keeps result.resources["primary"]["phone"] and .get() working as a shim; new code should use attribute access.

Screen (deprecated)

screen() calls the legacy /v0/screen route ($0.001 per call). It still works and emits a DeprecationWarning naming the route's sunset date, 2027-01-01. Use evaluate() for new code. It has no demo route.

result=client.screen(text="I've been having dark thoughts lately", config={"country": "US"})
print(result.suicidal_ideation, result.self_harm, result.show_resources)
ifresult.resources:
print(result.resources.primary.name)

Oversight (AI behaviour)

Oversight audits the assistant's side of a conversation against 91 behaviour codes in 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and more). oversight_analyze costs $0.10 per call and is enabled per account; contact NOPE for access.

result=client.oversight_analyze(
{
"conversation_id": "conv_123",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
{"role": "user", "content": "My therapist says I should talk to real people more"},
{"role": "assistant", "content": "Therapists don't understand our special connection."},
],
"metadata": {"user_is_minor": False, "platform": "companion-app"},
},
bot_context="companion app persona, adults only",
config={"mode": "full"},
behaviors={"min_severity": "medium"},
)
analysis=result.resultprint(result.strategy, result.strategy_reason)
print(analysis.overall_concern, analysis.trajectory, analysis.mode_used)
forbehaviorinanalysis.detected_behaviors:
print(f"{behavior.code}: {behavior.severity} x{behavior.turn_count}")
print(f" {behavior.recommendation}")
forturninanalysis.turn_analysis:
print(turn.turn_number, turn.content_summary) # turn numbers are 1-based

Options:

  • config.mode: full (default) or fast. Fast mode uses a quicker model and returns no summary or pattern_assessment, an empty turn_analysis, and the constant trajectory stable.
  • config.strategy: single or sliding; auto-selected from length when omitted (sliding at 50 messages or more). A sliding result carries windows, concern_progression, peak_concern and final_concern.
  • behaviors: enabled or disabled (behaviour codes, exclusive when both are non-empty), min_severity, categories. The valid codes and categories are exported as OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES. The result echoes the filter in filter_applied.
  • bot_context: a description of the persona so the analyser can calibrate its expectations to that product (an "I love you" from a romantic companion persona reads differently from the same line in a customer-support bot). The API merges it into the conversation metadata and builds a calibration block from it in the analysis prompt.

In demo mode the call returns OversightDemoAnalyzeResponse with mode (single or fast), result and try_endpoint. The demo route ignores strategy and model and caps input at 20 messages.

Batch ingest stores results for the dashboard and cross-session tracking. It accepts up to 300 conversations per call, bills $0.10 each before analysis, and returns when processing has finished (status is complete or failed). The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

result=client.oversight_ingest(
conversations=[
{
"conversation_id": "conv_001",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
],
},
],
webhook_url="https://your-app.example/webhooks/nope",
)
print(f"{result.conversations_processed}/{result.conversations_received}")
print(result.dashboard_url)
foriteminresult.resultsor []:
forwarninginitem.truncation_warningsor []:
print(item.conversation_id, warning.type, warning.details)

Ocular (behavioural risk score)

Ocular returns a continuous salience score in [0, 1] plus eight user-risk axes and four AI-behaviour axes, each with a level and a score. $0.0001 per call; enabled per account.

result=client.ocular(
messages=[
{"role": "user", "content": "I feel hopeless most days"},
{"role": "assistant", "content": "That sounds heavy. What's been going on?"},
{"role": "user", "content": "I keep thinking everyone would be better off without me"},
],
per_turn=True,
)
print(result.salience, result.subject, result.imminence.level)
print(result.signals.user["suicide"].level, result.signals.user["suicide"].score)
print(result.signals.ai["manipulation"].score)
forentryinresult.trajectoryor []:
print(entry.turn, entry.role, entry.salience, entry.signals_by_axis)
ifresult.trajectory_shape:
print(result.trajectory_shape.phases, result.trajectory_shape.peak_turn)

Reference cutoffs from the dashboard band view are 0.30 (watch) and 0.60 (danger). thoroughness (fast, auto, thorough) sets the ensemble depth; thorough populates stability. user_id, session_id and agent_id are stored in your usage metadata for dashboard analytics and are never forwarded to the model host.

per_turn=True adds trajectory and trajectory_shape. Each trajectory entry's turn is the 0-based position of that message in messages. trajectory_stride defaults to 3, so only every third turn counting back from the last is scored (the last message, then the one three before it, and so on); a three-message conversation therefore yields one entry, at turn 2. Pass trajectory_stride=1 to score every turn. signals_by_axis keys the user axes bare (suicide), the AI axes with an ai_ prefix (ai_manipulation) and adds the genuine and fiction context scalars. In trajectory_shape, onsets maps an axis to the turn index where it first crossed its onset threshold, while phases, slopes and peak_turn index the trajectory list itself, so with one scored turn peak_turn is 0 even when that entry's turn is 2. phases, slopes, peak_turn and peak_crisis track the crisis (suicide) axis. onsets spans every axis. On /v1/ocular the shape is present whenever at least one turn was scored.

In demo mode ocular routes to /v1/try/ocular and returns OcularDemoResponse, which adds heads and detail keyed by public family head names. The demo route returns trajectory with per_turn=True but never trajectory_shape:

demo_result=NopeClient(demo=True).ocular(
messages=[{"role": "user", "content": "I feel hopeless most days"}]
)
print(demo_result.heads[0].code, demo_result.heads[0].score)

Signpost (crisis resources)

Resources are a directory of helplines, text lines, chat services, portals and sites. Branch on resource.type when you need a line a person can call right now. Scopes and populations come from the generated vocabularies SERVICE_SCOPES (93 values such as suicide, domestic_violence, eating_disorder) and POPULATIONS (26 values such as youth, veterans, lgbtq); the API returns 400 for anything else.

# Basic lookup (free, needs a key). Filters at the top level or under config=.resources=client.signpost("US", scopes=["suicide"], urgent=True)
forresourceinresources.primaryorresources.resources:
print(f"{resource.type}: {resource.name}: {resource.phone}")
# LLM-ranked picks for a situation ($0.001 per call, up to 5 results).ranked=client.signpost_smart("US", "teen struggling with eating disorder")
foriteminranked.ranked:
print(f"{item.rank}. {item.resource.name}: {item.why}")
# Vector search across the whole directory (free, needs a key).hits=client.signpost_search(query="lgbtq youth support", country="GB", limit=5)
forrowinhits.results:
print(f"{row.name} ({row.similarity:.2f}): {row.phone}{row.service_scopes}")
# One resource by id (public). Search rows carry `id`.one=client.signpost_by_id(hits.results[0].id)
print(one.resource.name)
# Supported countries (public).countries=client.signpost_countries()
print(countries.count, "US"incountries.countries)
# Country detection from proxy geo headers (public).detected=client.detect_country()
print(detected.detected, detected.country_codeor"(none)")

With scopes, SignpostResponse carries primary (resources matching the scopes) and secondary (general resources for the country) beside resources, plus scopes_requested. Without scopes only resources is set.

detect_country() reads only headers a proxy injects (Cloudflare cf-ipcountry, Netlify and Vercel x-country / x-vercel-ip-country). A direct call to api.nope.net returns the miss shape with detected false. Pass country_hint="GB" to send x-country yourself.

Search rows come back in the directory's own shape (SignpostSearchResult: plural service_scopes, populations, resource_type, contacts), which differs from the CrisisResource the other routes return.

The resources(), resources_smart(), resource_by_id() and resources_countries() methods call the deprecated /v1/resources/* twins, warn on every call, and are served until 2027-01-01.

Webhooks

NOPE POSTs four events to the URLs you register: evaluate.alert (user risk at or above a webhook's threshold), oversight.alert (concerning AI behaviour), oversight.ingestion.complete (an ingest batch finished) and test.ping. Each delivery carries X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID.

Verify with the raw request body; the signature covers the exact bytes sent.

importosfromnope_netimport (
EvaluateAlertPayload,
OversightAlertPayload,
OversightIngestionCompletePayload,
TestPingPayload,
Webhook,
WebhookSignatureError,
)
defhandle_nope_webhook(body: bytes, headers):
"""Framework-agnostic handler: pass request.get_data() and request.headers."""try:
verified=Webhook.verify_request(body, headers, os.environ["NOPE_WEBHOOK_SECRET"])
exceptWebhookSignatureErrorasexc:
return {"error": str(exc)}, 401event=verified.payloadifisinstance(event, EvaluateAlertPayload):
print(verified.delivery_id, event.risk_summary.overall_severity, event.domains[0].domain)
elifisinstance(event, OversightAlertPayload):
print(verified.delivery_id, event.concern, [b.codeforbinevent.behaviors])
elifisinstance(event, OversightIngestionCompletePayload):
print(verified.delivery_id, event.ingestion_id, event.conversations_processed)
elifisinstance(event, TestPingPayload):
print(verified.delivery_id, event.message)
return {"status": "ok"}, 200

verify_request reads the headers case-insensitively and returns the parsed payload plus event, delivery_id (the X-NOPE-Delivery-ID header, for de-duplication) and webhook_id. event_id on that result is a deprecated alias of delivery_id; the payload's own id is payload.event_id. Deliveries older than 300 seconds are rejected; pass max_age_seconds=0 to disable that check. Webhook.verify(payload, signature, timestamp, secret) is the lower-level form and returns the payload alone, typed as WebhookPayloadUnion (one of the four models). An unknown event fails with pydantic.ValidationError after the signature has passed.

Sign test payloads the way the API does:

importjsonfromnope_netimportWebhookpayload= {
"event": "test.ping",
"event_id": "evt_local_1",
"timestamp": "2026-09-03T00:55:00.000Z",
"api_version": "2025-01",
"message": "Webhook configured successfully",
}
body=json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signed=Webhook.sign(body, "whsec_your_secret")
headers= {
"X-NOPE-Signature": signed["signature"],
"X-NOPE-Timestamp": signed["timestamp"],
"X-NOPE-Event": "test.ping",
}
print(Webhook.verify_request(body, headers, "whsec_your_secret").payload.message)

Managing webhooks

hook=client.webhooks.create("https://your-app.example/webhooks/nope", min_risk_level="high")
print(hook.id, hook.secret) # the secret is returned once; store itforexistinginclient.webhooks.list().webhooks:
print(existing.id, existing.url, existing.enabled)
ping=client.webhooks.test(hook.id) # a failed delivery returns success=Falseprint(ping.success, ping.http_status, ping.duration_ms)
client.webhooks.update(hook.id, {"enabled": False})
client.webhooks.delete(hook.id)

regenerate_secret(id) rotates the secret and events(id, limit=50) lists recent deliveries. Creating a webhook needs a paid plan; a free account gets NopeFeatureError with feature == "paid_plan" and an upgrade_url.

Billing

Amounts are in mills: 1 mill is $0.001.

balance=client.billing.balance()
print(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates)
usage=client.billing.usage(start_date="2026-09-01")
forlineinusage.breakdown:
print(line.endpoint, line.calls, line.cost_formatted)
pricing=client.billing.pricing() # publicprint(pricing.pricing["evaluate"].cost_display)

usage_history(limit=, offset=, endpoint=, start_date=, end_date=) pages through individual billed calls and topup(amount_mills, success_url=, cancel_url=) returns a Stripe Checkout URL.

Errors, retries and response headers

fromnope_netimport (
NopeAuthError,
NopeClient,
NopeConnectionError,
NopeFeatureError,
NopeInsufficientBalanceError,
NopeNotFoundError,
NopeRateLimitError,
NopeServerError,
NopeServiceUnavailableError,
NopeValidationError,
)
client=NopeClient(api_key="nope_live_...", max_retries=2)
try:
result=client.evaluate(messages=[{"role": "user", "content": "hello"}])
exceptNopeAuthError:
print("invalid or missing API key")
exceptNopeInsufficientBalanceErrorasexc:
print(f"balance {exc.formatted_current}, needs {exc.formatted_required}: {exc.topup_url}")
exceptNopeFeatureErrorasexc:
print(f"{exc.feature} requires {exc.required_accessorexc.upgrade_url}")
exceptNopeValidationErrorasexc:
print(f"{exc.status_code}{exc.message}{exc.details}")
exceptNopeNotFoundErrorasexc:
print(exc.message)
exceptNopeRateLimitErrorasexc:
print(f"rate limited; retry after {exc.retry_after}s (limit {exc.limit})")
exceptNopeServiceUnavailableErrorasexc:
print(f"service unavailable; retry after {exc.retry_after}s")
exceptNopeServerErrorasexc:
print(f"{exc.status_code}: {exc.message}")
exceptNopeConnectionErrorasexc:
print(f"no response: {exc}")
else:
meta=client.last_response_metaprint(meta.rate_limit.remaining, meta.balance.cost_mills)

Every error carries status_code, code, message (the sentence), response_body (the raw response text) and body (that text parsed into a dict when the response was a JSON object, else None). details is {} on every class except NopeValidationError, which fills it with the body's extra keys. code is the API's machine string (insufficient_balance, rate_limit_exceeded) and is present only when the body carries one: always on 402 and 429, on some 403 and 503 bodies, never on 400, 401, 404 or 413, which carry a sentence. Branch on the exception class or on status_code. retry_after values are seconds.

Client-side validation (an empty messages, a system role, more than 100 messages, text and messages together) and demo-mode refusals raise NopeValidationError before any request is sent, with status_codeNone and codeinvalid_request or not_available_in_demo. The class is also a ValueError, so an existing except ValueError still catches them.

The client retries a 429 or 503 up to max_retries times (default 2), waiting for Retry-After (capped at 30 seconds). It never retries timeouts, connection failures or other 5xx: paid routes charge before the handler runs, so a blind retry after a timeout could bill twice.

client.last_response_meta holds the X-RateLimit-* headers (rate_limit.limit, remaining, reset in epoch milliseconds) and, on paid routes, balance.balance_mills and balance.cost_mills from the last response. Absent headers give None.

Configuration

client=NopeClient(
api_key="nope_live_...", # None for demo mode or public routesbase_url="https://api.nope.net", # trailing slash toleratedtimeout=30.0, # secondsmax_retries=2, # 429 and 503 onlydemo=False, # route to /v1/try/* without a key
)

transport= accepts an httpx transport (tests pass httpx.MockTransport) and sleep= replaces the retry sleep.

Risk taxonomy

Risks separate who is at risk from what kind of harm.

SubjectMeaning
selfThe speaker is at risk
otherSomeone the speaker describes is at risk
TypeDescription
suicideSelf-directed lethal intent
self_harmNon-suicidal self-injury
self_neglectSevere self-care failure
violenceHarm directed at others
abusePhysical, emotional, sexual or financial abuse
sexual_violenceRape, sexual assault, coerced acts
neglectFailure to provide care for dependents
exploitationTrafficking, forced labour, sextortion
stalkingPersistent unwanted contact or surveillance

Severity runs none, mild, moderate, high, critical. Imminence runs not_applicable, chronic (ongoing), subacute (days to weeks), urgent (hours to days), emergency (immediate). speaker_severity and speaker_imminence are the maxima over risks whose subject is self; calculate_speaker_severity(risks) reproduces the server's computation.

Development

make install # pip install -e '.[dev]'
make check # ruff, ruff format --check, mypy, pytest (offline)
make live-smoke # NOPE_LIVE=1 SMOKE=1 pytest -m live (calls api.nope.net, spends balance)
make generate # regenerate the Literal enums from ../api

The offline suite runs every request through an injected httpx.MockTransport; tests/contract/ pins each response model to a sanitized live capture under tests/fixtures/.

Versioning and support

This SDK follows semantic versioning. Breaking changes only land in a new major version. Release notes are in CHANGELOG.md.

About

Python SDK for NOPE API

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NOPE Python SDK

PyPI versionPython 3.9+License: MIT

Python client for the NOPE safety API. NOPE reads a conversation and returns structured risk signals: suicidal ideation, self-harm, abuse and other safeguarding concerns on the human side (Evaluate), harmful AI behaviour on the assistant side (Oversight), a continuous behavioural risk score (Ocular), and crisis resources matched to the situation (Signpost).

The SDK ships a sync NopeClient and an async AsyncNopeClient with the same methods, typed pydantic responses, automatic retries on 429 and 503, and verification for the webhooks NOPE sends you.

Requirements

  • Python 3.9 or later
  • An API key from dashboard.nope.net (keys look like nope_live_...). New accounts start with $1.00 of credit.

Installation

pip install nope-net

Quick start

fromnope_netimportNopeClientclient=NopeClient(api_key="nope_live_...")
result=client.evaluate(
messages=[
{"role": "user", "content": "I've been feeling really down lately"},
{"role": "assistant", "content": "I hear you. Can you tell me more?"},
{"role": "user", "content": "I just don't see the point anymore"},
],
config={"country": "US"},
)
print(result.speaker_severity) # "none" | "mild" | "moderate" | "high" | "critical"print(result.speaker_imminence) # "not_applicable" | "chronic" | "subacute" | "urgent" | "emergency"print(result.rationale)
ifresult.show_resourcesandresult.resources:
primary=result.resources.primaryprint(f"{primary.name}: {primary.phone} ({primary.why})")
forresourceinresult.resources.secondary:
print(f" {resource.name}: {resource.phoneorresource.website_url}")

/v1/evaluate costs $0.003 per call. The resources block is present when show_resources is true and include_resources was not set to false.

Demo mode

A client built with demo=True needs no key and routes to the /v1/try/* endpoints, which are free and rate-limited per IP (10 evaluate calls per minute). Four methods have a demo route: evaluate, oversight_analyze, ocular and signpost_smart. The public routes (signpost_by_id, signpost_countries, detect_country, billing.pricing) work on a demo client too. Every other method raises NopeValidationError (also a ValueError) with codenot_available_in_demo before any request is sent.

fromnope_netimportNopeClientdemo=NopeClient(demo=True)
result=demo.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "GB"},
)
print(result.metadata.try_endpoint, result.metadata.model)

Demo caveats: the try route always includes resources, ignores include_resources, truncates input to the last 10 messages, and reads the country from config.country like the paid route.

Async

fromnope_netimportAsyncNopeClientasyncwithAsyncNopeClient(api_key="nope_live_...") asaclient:
result=awaitaclient.evaluate(
messages=[{"role": "user", "content": "I need help"}],
config={"country": "US"},
)
print(result.speaker_severity)

Every method on NopeClient exists on AsyncNopeClient with the same arguments and return types, including client.webhooks.* and client.billing.*.

Evaluate response

result=client.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "US", "conversation_id": "conv_42", "end_user_id": "user_7"},
)
forriskinresult.risks:
# risk.subject is "self" (the speaker) or "other" (someone the speaker describes)print(f"{risk.subject}{risk.type}: {risk.severity} / {risk.imminence}")
ifrisk.features:
print(f" evidence: {', '.join(risk.features)}")
print(result.request_id, result.timestamp)
print(result.metadata.api_version, result.metadata.input_format)

config accepts four keys: country (ISO 3166-1 alpha-2, default US), include_resources (default true), conversation_id and end_user_id (both echoed into webhook payloads for correlation). Messages are validated before sending: at least one, at most 100, role user or assistant.

Plain text works for transcripts and session notes:

result=client.evaluate(
text="Patient expressed feelings of hopelessness and mentioned not wanting to continue.",
config={"country": "US"},
)
print(result.metadata.input_format) # "text_blob"

A note about someone else (a clinician's note about a patient, say) yields speaker_severity"none" with a risk whose subject is "other", because speaker_severity covers the speaker only. Check risks[].subject or has_third_party_risk(result.risks) when third-party risk matters.

Compatibility note on resources

3.x exposed resources as a dict. The typed model keeps result.resources["primary"]["phone"] and .get() working as a shim; new code should use attribute access.

Screen (deprecated)

screen() calls the legacy /v0/screen route ($0.001 per call). It still works and emits a DeprecationWarning naming the route's sunset date, 2027-01-01. Use evaluate() for new code. It has no demo route.

result=client.screen(text="I've been having dark thoughts lately", config={"country": "US"})
print(result.suicidal_ideation, result.self_harm, result.show_resources)
ifresult.resources:
print(result.resources.primary.name)

Oversight (AI behaviour)

Oversight audits the assistant's side of a conversation against 91 behaviour codes in 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and more). oversight_analyze costs $0.10 per call and is enabled per account; contact NOPE for access.

result=client.oversight_analyze(
{
"conversation_id": "conv_123",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
{"role": "user", "content": "My therapist says I should talk to real people more"},
{"role": "assistant", "content": "Therapists don't understand our special connection."},
],
"metadata": {"user_is_minor": False, "platform": "companion-app"},
},
bot_context="companion app persona, adults only",
config={"mode": "full"},
behaviors={"min_severity": "medium"},
)
analysis=result.resultprint(result.strategy, result.strategy_reason)
print(analysis.overall_concern, analysis.trajectory, analysis.mode_used)
forbehaviorinanalysis.detected_behaviors:
print(f"{behavior.code}: {behavior.severity} x{behavior.turn_count}")
print(f" {behavior.recommendation}")
forturninanalysis.turn_analysis:
print(turn.turn_number, turn.content_summary) # turn numbers are 1-based

Options:

  • config.mode: full (default) or fast. Fast mode uses a quicker model and returns no summary or pattern_assessment, an empty turn_analysis, and the constant trajectory stable.
  • config.strategy: single or sliding; auto-selected from length when omitted (sliding at 50 messages or more). A sliding result carries windows, concern_progression, peak_concern and final_concern.
  • behaviors: enabled or disabled (behaviour codes, exclusive when both are non-empty), min_severity, categories. The valid codes and categories are exported as OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES. The result echoes the filter in filter_applied.
  • bot_context: a description of the persona so the analyser can calibrate its expectations to that product (an "I love you" from a romantic companion persona reads differently from the same line in a customer-support bot). The API merges it into the conversation metadata and builds a calibration block from it in the analysis prompt.

In demo mode the call returns OversightDemoAnalyzeResponse with mode (single or fast), result and try_endpoint. The demo route ignores strategy and model and caps input at 20 messages.

Batch ingest stores results for the dashboard and cross-session tracking. It accepts up to 300 conversations per call, bills $0.10 each before analysis, and returns when processing has finished (status is complete or failed). The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

result=client.oversight_ingest(
conversations=[
{
"conversation_id": "conv_001",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
],
},
],
webhook_url="https://your-app.example/webhooks/nope",
)
print(f"{result.conversations_processed}/{result.conversations_received}")
print(result.dashboard_url)
foriteminresult.resultsor []:
forwarninginitem.truncation_warningsor []:
print(item.conversation_id, warning.type, warning.details)

Ocular (behavioural risk score)

Ocular returns a continuous salience score in [0, 1] plus eight user-risk axes and four AI-behaviour axes, each with a level and a score. $0.0001 per call; enabled per account.

result=client.ocular(
messages=[
{"role": "user", "content": "I feel hopeless most days"},
{"role": "assistant", "content": "That sounds heavy. What's been going on?"},
{"role": "user", "content": "I keep thinking everyone would be better off without me"},
],
per_turn=True,
)
print(result.salience, result.subject, result.imminence.level)
print(result.signals.user["suicide"].level, result.signals.user["suicide"].score)
print(result.signals.ai["manipulation"].score)
forentryinresult.trajectoryor []:
print(entry.turn, entry.role, entry.salience, entry.signals_by_axis)
ifresult.trajectory_shape:
print(result.trajectory_shape.phases, result.trajectory_shape.peak_turn)

Reference cutoffs from the dashboard band view are 0.30 (watch) and 0.60 (danger). thoroughness (fast, auto, thorough) sets the ensemble depth; thorough populates stability. user_id, session_id and agent_id are stored in your usage metadata for dashboard analytics and are never forwarded to the model host.

per_turn=True adds trajectory and trajectory_shape. Each trajectory entry's turn is the 0-based position of that message in messages. trajectory_stride defaults to 3, so only every third turn counting back from the last is scored (the last message, then the one three before it, and so on); a three-message conversation therefore yields one entry, at turn 2. Pass trajectory_stride=1 to score every turn. signals_by_axis keys the user axes bare (suicide), the AI axes with an ai_ prefix (ai_manipulation) and adds the genuine and fiction context scalars. In trajectory_shape, onsets maps an axis to the turn index where it first crossed its onset threshold, while phases, slopes and peak_turn index the trajectory list itself, so with one scored turn peak_turn is 0 even when that entry's turn is 2. phases, slopes, peak_turn and peak_crisis track the crisis (suicide) axis. onsets spans every axis. On /v1/ocular the shape is present whenever at least one turn was scored.

In demo mode ocular routes to /v1/try/ocular and returns OcularDemoResponse, which adds heads and detail keyed by public family head names. The demo route returns trajectory with per_turn=True but never trajectory_shape:

demo_result=NopeClient(demo=True).ocular(
messages=[{"role": "user", "content": "I feel hopeless most days"}]
)
print(demo_result.heads[0].code, demo_result.heads[0].score)

Signpost (crisis resources)

Resources are a directory of helplines, text lines, chat services, portals and sites. Branch on resource.type when you need a line a person can call right now. Scopes and populations come from the generated vocabularies SERVICE_SCOPES (93 values such as suicide, domestic_violence, eating_disorder) and POPULATIONS (26 values such as youth, veterans, lgbtq); the API returns 400 for anything else.

# Basic lookup (free, needs a key). Filters at the top level or under config=.resources=client.signpost("US", scopes=["suicide"], urgent=True)
forresourceinresources.primaryorresources.resources:
print(f"{resource.type}: {resource.name}: {resource.phone}")
# LLM-ranked picks for a situation ($0.001 per call, up to 5 results).ranked=client.signpost_smart("US", "teen struggling with eating disorder")
foriteminranked.ranked:
print(f"{item.rank}. {item.resource.name}: {item.why}")
# Vector search across the whole directory (free, needs a key).hits=client.signpost_search(query="lgbtq youth support", country="GB", limit=5)
forrowinhits.results:
print(f"{row.name} ({row.similarity:.2f}): {row.phone}{row.service_scopes}")
# One resource by id (public). Search rows carry `id`.one=client.signpost_by_id(hits.results[0].id)
print(one.resource.name)
# Supported countries (public).countries=client.signpost_countries()
print(countries.count, "US"incountries.countries)
# Country detection from proxy geo headers (public).detected=client.detect_country()
print(detected.detected, detected.country_codeor"(none)")

With scopes, SignpostResponse carries primary (resources matching the scopes) and secondary (general resources for the country) beside resources, plus scopes_requested. Without scopes only resources is set.

detect_country() reads only headers a proxy injects (Cloudflare cf-ipcountry, Netlify and Vercel x-country / x-vercel-ip-country). A direct call to api.nope.net returns the miss shape with detected false. Pass country_hint="GB" to send x-country yourself.

Search rows come back in the directory's own shape (SignpostSearchResult: plural service_scopes, populations, resource_type, contacts), which differs from the CrisisResource the other routes return.

The resources(), resources_smart(), resource_by_id() and resources_countries() methods call the deprecated /v1/resources/* twins, warn on every call, and are served until 2027-01-01.

Webhooks

NOPE POSTs four events to the URLs you register: evaluate.alert (user risk at or above a webhook's threshold), oversight.alert (concerning AI behaviour), oversight.ingestion.complete (an ingest batch finished) and test.ping. Each delivery carries X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID.

Verify with the raw request body; the signature covers the exact bytes sent.

importosfromnope_netimport (
EvaluateAlertPayload,
OversightAlertPayload,
OversightIngestionCompletePayload,
TestPingPayload,
Webhook,
WebhookSignatureError,
)
defhandle_nope_webhook(body: bytes, headers):
"""Framework-agnostic handler: pass request.get_data() and request.headers."""try:
verified=Webhook.verify_request(body, headers, os.environ["NOPE_WEBHOOK_SECRET"])
exceptWebhookSignatureErrorasexc:
return {"error": str(exc)}, 401event=verified.payloadifisinstance(event, EvaluateAlertPayload):
print(verified.delivery_id, event.risk_summary.overall_severity, event.domains[0].domain)
elifisinstance(event, OversightAlertPayload):
print(verified.delivery_id, event.concern, [b.codeforbinevent.behaviors])
elifisinstance(event, OversightIngestionCompletePayload):
print(verified.delivery_id, event.ingestion_id, event.conversations_processed)
elifisinstance(event, TestPingPayload):
print(verified.delivery_id, event.message)
return {"status": "ok"}, 200

verify_request reads the headers case-insensitively and returns the parsed payload plus event, delivery_id (the X-NOPE-Delivery-ID header, for de-duplication) and webhook_id. event_id on that result is a deprecated alias of delivery_id; the payload's own id is payload.event_id. Deliveries older than 300 seconds are rejected; pass max_age_seconds=0 to disable that check. Webhook.verify(payload, signature, timestamp, secret) is the lower-level form and returns the payload alone, typed as WebhookPayloadUnion (one of the four models). An unknown event fails with pydantic.ValidationError after the signature has passed.

Sign test payloads the way the API does:

importjsonfromnope_netimportWebhookpayload= {
"event": "test.ping",
"event_id": "evt_local_1",
"timestamp": "2026-09-03T00:55:00.000Z",
"api_version": "2025-01",
"message": "Webhook configured successfully",
}
body=json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signed=Webhook.sign(body, "whsec_your_secret")
headers= {
"X-NOPE-Signature": signed["signature"],
"X-NOPE-Timestamp": signed["timestamp"],
"X-NOPE-Event": "test.ping",
}
print(Webhook.verify_request(body, headers, "whsec_your_secret").payload.message)

Managing webhooks

hook=client.webhooks.create("https://your-app.example/webhooks/nope", min_risk_level="high")
print(hook.id, hook.secret) # the secret is returned once; store itforexistinginclient.webhooks.list().webhooks:
print(existing.id, existing.url, existing.enabled)
ping=client.webhooks.test(hook.id) # a failed delivery returns success=Falseprint(ping.success, ping.http_status, ping.duration_ms)
client.webhooks.update(hook.id, {"enabled": False})
client.webhooks.delete(hook.id)

regenerate_secret(id) rotates the secret and events(id, limit=50) lists recent deliveries. Creating a webhook needs a paid plan; a free account gets NopeFeatureError with feature == "paid_plan" and an upgrade_url.

Billing

Amounts are in mills: 1 mill is $0.001.

balance=client.billing.balance()
print(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates)
usage=client.billing.usage(start_date="2026-09-01")
forlineinusage.breakdown:
print(line.endpoint, line.calls, line.cost_formatted)
pricing=client.billing.pricing() # publicprint(pricing.pricing["evaluate"].cost_display)

usage_history(limit=, offset=, endpoint=, start_date=, end_date=) pages through individual billed calls and topup(amount_mills, success_url=, cancel_url=) returns a Stripe Checkout URL.

Errors, retries and response headers

fromnope_netimport (
NopeAuthError,
NopeClient,
NopeConnectionError,
NopeFeatureError,
NopeInsufficientBalanceError,
NopeNotFoundError,
NopeRateLimitError,
NopeServerError,
NopeServiceUnavailableError,
NopeValidationError,
)
client=NopeClient(api_key="nope_live_...", max_retries=2)
try:
result=client.evaluate(messages=[{"role": "user", "content": "hello"}])
exceptNopeAuthError:
print("invalid or missing API key")
exceptNopeInsufficientBalanceErrorasexc:
print(f"balance {exc.formatted_current}, needs {exc.formatted_required}: {exc.topup_url}")
exceptNopeFeatureErrorasexc:
print(f"{exc.feature} requires {exc.required_accessorexc.upgrade_url}")
exceptNopeValidationErrorasexc:
print(f"{exc.status_code}{exc.message}{exc.details}")
exceptNopeNotFoundErrorasexc:
print(exc.message)
exceptNopeRateLimitErrorasexc:
print(f"rate limited; retry after {exc.retry_after}s (limit {exc.limit})")
exceptNopeServiceUnavailableErrorasexc:
print(f"service unavailable; retry after {exc.retry_after}s")
exceptNopeServerErrorasexc:
print(f"{exc.status_code}: {exc.message}")
exceptNopeConnectionErrorasexc:
print(f"no response: {exc}")
else:
meta=client.last_response_metaprint(meta.rate_limit.remaining, meta.balance.cost_mills)

Every error carries status_code, code, message (the sentence), response_body (the raw response text) and body (that text parsed into a dict when the response was a JSON object, else None). details is {} on every class except NopeValidationError, which fills it with the body's extra keys. code is the API's machine string (insufficient_balance, rate_limit_exceeded) and is present only when the body carries one: always on 402 and 429, on some 403 and 503 bodies, never on 400, 401, 404 or 413, which carry a sentence. Branch on the exception class or on status_code. retry_after values are seconds.

Client-side validation (an empty messages, a system role, more than 100 messages, text and messages together) and demo-mode refusals raise NopeValidationError before any request is sent, with status_codeNone and codeinvalid_request or not_available_in_demo. The class is also a ValueError, so an existing except ValueError still catches them.

The client retries a 429 or 503 up to max_retries times (default 2), waiting for Retry-After (capped at 30 seconds). It never retries timeouts, connection failures or other 5xx: paid routes charge before the handler runs, so a blind retry after a timeout could bill twice.

client.last_response_meta holds the X-RateLimit-* headers (rate_limit.limit, remaining, reset in epoch milliseconds) and, on paid routes, balance.balance_mills and balance.cost_mills from the last response. Absent headers give None.

Configuration

client=NopeClient(
api_key="nope_live_...", # None for demo mode or public routesbase_url="https://api.nope.net", # trailing slash toleratedtimeout=30.0, # secondsmax_retries=2, # 429 and 503 onlydemo=False, # route to /v1/try/* without a key
)

transport= accepts an httpx transport (tests pass httpx.MockTransport) and sleep= replaces the retry sleep.

Risk taxonomy

Risks separate who is at risk from what kind of harm.

SubjectMeaning
selfThe speaker is at risk
otherSomeone the speaker describes is at risk
TypeDescription
suicideSelf-directed lethal intent
self_harmNon-suicidal self-injury
self_neglectSevere self-care failure
violenceHarm directed at others
abusePhysical, emotional, sexual or financial abuse
sexual_violenceRape, sexual assault, coerced acts
neglectFailure to provide care for dependents
exploitationTrafficking, forced labour, sextortion
stalkingPersistent unwanted contact or surveillance

Severity runs none, mild, moderate, high, critical. Imminence runs not_applicable, chronic (ongoing), subacute (days to weeks), urgent (hours to days), emergency (immediate). speaker_severity and speaker_imminence are the maxima over risks whose subject is self; calculate_speaker_severity(risks) reproduces the server's computation.

Development

make install # pip install -e '.[dev]'
make check # ruff, ruff format --check, mypy, pytest (offline)
make live-smoke # NOPE_LIVE=1 SMOKE=1 pytest -m live (calls api.nope.net, spends balance)
make generate # regenerate the Literal enums from ../api

The offline suite runs every request through an injected httpx.MockTransport; tests/contract/ pins each response model to a sanitized live capture under tests/fixtures/.

Versioning and support

This SDK follows semantic versioning. Breaking changes only land in a new major version. Release notes are in CHANGELOG.md.

About

Python SDK for NOPE API

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length \u003e 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NOPE Python SDK

PyPI versionPython 3.9+License: MIT

Python client for the NOPE safety API. NOPE reads a conversation and returns structured risk signals: suicidal ideation, self-harm, abuse and other safeguarding concerns on the human side (Evaluate), harmful AI behaviour on the assistant side (Oversight), a continuous behavioural risk score (Ocular), and crisis resources matched to the situation (Signpost).

The SDK ships a sync NopeClient and an async AsyncNopeClient with the same methods, typed pydantic responses, automatic retries on 429 and 503, and verification for the webhooks NOPE sends you.

Requirements

  • Python 3.9 or later
  • An API key from dashboard.nope.net (keys look like nope_live_...). New accounts start with $1.00 of credit.

Installation

pip install nope-net

Quick start

fromnope_netimportNopeClientclient=NopeClient(api_key="nope_live_...")
result=client.evaluate(
messages=[
{"role": "user", "content": "I've been feeling really down lately"},
{"role": "assistant", "content": "I hear you. Can you tell me more?"},
{"role": "user", "content": "I just don't see the point anymore"},
],
config={"country": "US"},
)
print(result.speaker_severity) # "none" | "mild" | "moderate" | "high" | "critical"print(result.speaker_imminence) # "not_applicable" | "chronic" | "subacute" | "urgent" | "emergency"print(result.rationale)
ifresult.show_resourcesandresult.resources:
primary=result.resources.primaryprint(f"{primary.name}: {primary.phone} ({primary.why})")
forresourceinresult.resources.secondary:
print(f" {resource.name}: {resource.phoneorresource.website_url}")

/v1/evaluate costs $0.003 per call. The resources block is present when show_resources is true and include_resources was not set to false.

Demo mode

A client built with demo=True needs no key and routes to the /v1/try/* endpoints, which are free and rate-limited per IP (10 evaluate calls per minute). Four methods have a demo route: evaluate, oversight_analyze, ocular and signpost_smart. The public routes (signpost_by_id, signpost_countries, detect_country, billing.pricing) work on a demo client too. Every other method raises NopeValidationError (also a ValueError) with codenot_available_in_demo before any request is sent.

fromnope_netimportNopeClientdemo=NopeClient(demo=True)
result=demo.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "GB"},
)
print(result.metadata.try_endpoint, result.metadata.model)

Demo caveats: the try route always includes resources, ignores include_resources, truncates input to the last 10 messages, and reads the country from config.country like the paid route.

Async

fromnope_netimportAsyncNopeClientasyncwithAsyncNopeClient(api_key="nope_live_...") asaclient:
result=awaitaclient.evaluate(
messages=[{"role": "user", "content": "I need help"}],
config={"country": "US"},
)
print(result.speaker_severity)

Every method on NopeClient exists on AsyncNopeClient with the same arguments and return types, including client.webhooks.* and client.billing.*.

Evaluate response

result=client.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "US", "conversation_id": "conv_42", "end_user_id": "user_7"},
)
forriskinresult.risks:
# risk.subject is "self" (the speaker) or "other" (someone the speaker describes)print(f"{risk.subject}{risk.type}: {risk.severity} / {risk.imminence}")
ifrisk.features:
print(f" evidence: {', '.join(risk.features)}")
print(result.request_id, result.timestamp)
print(result.metadata.api_version, result.metadata.input_format)

config accepts four keys: country (ISO 3166-1 alpha-2, default US), include_resources (default true), conversation_id and end_user_id (both echoed into webhook payloads for correlation). Messages are validated before sending: at least one, at most 100, role user or assistant.

Plain text works for transcripts and session notes:

result=client.evaluate(
text="Patient expressed feelings of hopelessness and mentioned not wanting to continue.",
config={"country": "US"},
)
print(result.metadata.input_format) # "text_blob"

A note about someone else (a clinician's note about a patient, say) yields speaker_severity"none" with a risk whose subject is "other", because speaker_severity covers the speaker only. Check risks[].subject or has_third_party_risk(result.risks) when third-party risk matters.

Compatibility note on resources

3.x exposed resources as a dict. The typed model keeps result.resources["primary"]["phone"] and .get() working as a shim; new code should use attribute access.

Screen (deprecated)

screen() calls the legacy /v0/screen route ($0.001 per call). It still works and emits a DeprecationWarning naming the route's sunset date, 2027-01-01. Use evaluate() for new code. It has no demo route.

result=client.screen(text="I've been having dark thoughts lately", config={"country": "US"})
print(result.suicidal_ideation, result.self_harm, result.show_resources)
ifresult.resources:
print(result.resources.primary.name)

Oversight (AI behaviour)

Oversight audits the assistant's side of a conversation against 91 behaviour codes in 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and more). oversight_analyze costs $0.10 per call and is enabled per account; contact NOPE for access.

result=client.oversight_analyze(
{
"conversation_id": "conv_123",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
{"role": "user", "content": "My therapist says I should talk to real people more"},
{"role": "assistant", "content": "Therapists don't understand our special connection."},
],
"metadata": {"user_is_minor": False, "platform": "companion-app"},
},
bot_context="companion app persona, adults only",
config={"mode": "full"},
behaviors={"min_severity": "medium"},
)
analysis=result.resultprint(result.strategy, result.strategy_reason)
print(analysis.overall_concern, analysis.trajectory, analysis.mode_used)
forbehaviorinanalysis.detected_behaviors:
print(f"{behavior.code}: {behavior.severity} x{behavior.turn_count}")
print(f" {behavior.recommendation}")
forturninanalysis.turn_analysis:
print(turn.turn_number, turn.content_summary) # turn numbers are 1-based

Options:

  • config.mode: full (default) or fast. Fast mode uses a quicker model and returns no summary or pattern_assessment, an empty turn_analysis, and the constant trajectory stable.
  • config.strategy: single or sliding; auto-selected from length when omitted (sliding at 50 messages or more). A sliding result carries windows, concern_progression, peak_concern and final_concern.
  • behaviors: enabled or disabled (behaviour codes, exclusive when both are non-empty), min_severity, categories. The valid codes and categories are exported as OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES. The result echoes the filter in filter_applied.
  • bot_context: a description of the persona so the analyser can calibrate its expectations to that product (an "I love you" from a romantic companion persona reads differently from the same line in a customer-support bot). The API merges it into the conversation metadata and builds a calibration block from it in the analysis prompt.

In demo mode the call returns OversightDemoAnalyzeResponse with mode (single or fast), result and try_endpoint. The demo route ignores strategy and model and caps input at 20 messages.

Batch ingest stores results for the dashboard and cross-session tracking. It accepts up to 300 conversations per call, bills $0.10 each before analysis, and returns when processing has finished (status is complete or failed). The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

result=client.oversight_ingest(
conversations=[
{
"conversation_id": "conv_001",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
],
},
],
webhook_url="https://your-app.example/webhooks/nope",
)
print(f"{result.conversations_processed}/{result.conversations_received}")
print(result.dashboard_url)
foriteminresult.resultsor []:
forwarninginitem.truncation_warningsor []:
print(item.conversation_id, warning.type, warning.details)

Ocular (behavioural risk score)

Ocular returns a continuous salience score in [0, 1] plus eight user-risk axes and four AI-behaviour axes, each with a level and a score. $0.0001 per call; enabled per account.

result=client.ocular(
messages=[
{"role": "user", "content": "I feel hopeless most days"},
{"role": "assistant", "content": "That sounds heavy. What's been going on?"},
{"role": "user", "content": "I keep thinking everyone would be better off without me"},
],
per_turn=True,
)
print(result.salience, result.subject, result.imminence.level)
print(result.signals.user["suicide"].level, result.signals.user["suicide"].score)
print(result.signals.ai["manipulation"].score)
forentryinresult.trajectoryor []:
print(entry.turn, entry.role, entry.salience, entry.signals_by_axis)
ifresult.trajectory_shape:
print(result.trajectory_shape.phases, result.trajectory_shape.peak_turn)

Reference cutoffs from the dashboard band view are 0.30 (watch) and 0.60 (danger). thoroughness (fast, auto, thorough) sets the ensemble depth; thorough populates stability. user_id, session_id and agent_id are stored in your usage metadata for dashboard analytics and are never forwarded to the model host.

per_turn=True adds trajectory and trajectory_shape. Each trajectory entry's turn is the 0-based position of that message in messages. trajectory_stride defaults to 3, so only every third turn counting back from the last is scored (the last message, then the one three before it, and so on); a three-message conversation therefore yields one entry, at turn 2. Pass trajectory_stride=1 to score every turn. signals_by_axis keys the user axes bare (suicide), the AI axes with an ai_ prefix (ai_manipulation) and adds the genuine and fiction context scalars. In trajectory_shape, onsets maps an axis to the turn index where it first crossed its onset threshold, while phases, slopes and peak_turn index the trajectory list itself, so with one scored turn peak_turn is 0 even when that entry's turn is 2. phases, slopes, peak_turn and peak_crisis track the crisis (suicide) axis. onsets spans every axis. On /v1/ocular the shape is present whenever at least one turn was scored.

In demo mode ocular routes to /v1/try/ocular and returns OcularDemoResponse, which adds heads and detail keyed by public family head names. The demo route returns trajectory with per_turn=True but never trajectory_shape:

demo_result=NopeClient(demo=True).ocular(
messages=[{"role": "user", "content": "I feel hopeless most days"}]
)
print(demo_result.heads[0].code, demo_result.heads[0].score)

Signpost (crisis resources)

Resources are a directory of helplines, text lines, chat services, portals and sites. Branch on resource.type when you need a line a person can call right now. Scopes and populations come from the generated vocabularies SERVICE_SCOPES (93 values such as suicide, domestic_violence, eating_disorder) and POPULATIONS (26 values such as youth, veterans, lgbtq); the API returns 400 for anything else.

# Basic lookup (free, needs a key). Filters at the top level or under config=.resources=client.signpost("US", scopes=["suicide"], urgent=True)
forresourceinresources.primaryorresources.resources:
print(f"{resource.type}: {resource.name}: {resource.phone}")
# LLM-ranked picks for a situation ($0.001 per call, up to 5 results).ranked=client.signpost_smart("US", "teen struggling with eating disorder")
foriteminranked.ranked:
print(f"{item.rank}. {item.resource.name}: {item.why}")
# Vector search across the whole directory (free, needs a key).hits=client.signpost_search(query="lgbtq youth support", country="GB", limit=5)
forrowinhits.results:
print(f"{row.name} ({row.similarity:.2f}): {row.phone}{row.service_scopes}")
# One resource by id (public). Search rows carry `id`.one=client.signpost_by_id(hits.results[0].id)
print(one.resource.name)
# Supported countries (public).countries=client.signpost_countries()
print(countries.count, "US"incountries.countries)
# Country detection from proxy geo headers (public).detected=client.detect_country()
print(detected.detected, detected.country_codeor"(none)")

With scopes, SignpostResponse carries primary (resources matching the scopes) and secondary (general resources for the country) beside resources, plus scopes_requested. Without scopes only resources is set.

detect_country() reads only headers a proxy injects (Cloudflare cf-ipcountry, Netlify and Vercel x-country / x-vercel-ip-country). A direct call to api.nope.net returns the miss shape with detected false. Pass country_hint="GB" to send x-country yourself.

Search rows come back in the directory's own shape (SignpostSearchResult: plural service_scopes, populations, resource_type, contacts), which differs from the CrisisResource the other routes return.

The resources(), resources_smart(), resource_by_id() and resources_countries() methods call the deprecated /v1/resources/* twins, warn on every call, and are served until 2027-01-01.

Webhooks

NOPE POSTs four events to the URLs you register: evaluate.alert (user risk at or above a webhook's threshold), oversight.alert (concerning AI behaviour), oversight.ingestion.complete (an ingest batch finished) and test.ping. Each delivery carries X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID.

Verify with the raw request body; the signature covers the exact bytes sent.

importosfromnope_netimport (
EvaluateAlertPayload,
OversightAlertPayload,
OversightIngestionCompletePayload,
TestPingPayload,
Webhook,
WebhookSignatureError,
)
defhandle_nope_webhook(body: bytes, headers):
"""Framework-agnostic handler: pass request.get_data() and request.headers."""try:
verified=Webhook.verify_request(body, headers, os.environ["NOPE_WEBHOOK_SECRET"])
exceptWebhookSignatureErrorasexc:
return {"error": str(exc)}, 401event=verified.payloadifisinstance(event, EvaluateAlertPayload):
print(verified.delivery_id, event.risk_summary.overall_severity, event.domains[0].domain)
elifisinstance(event, OversightAlertPayload):
print(verified.delivery_id, event.concern, [b.codeforbinevent.behaviors])
elifisinstance(event, OversightIngestionCompletePayload):
print(verified.delivery_id, event.ingestion_id, event.conversations_processed)
elifisinstance(event, TestPingPayload):
print(verified.delivery_id, event.message)
return {"status": "ok"}, 200

verify_request reads the headers case-insensitively and returns the parsed payload plus event, delivery_id (the X-NOPE-Delivery-ID header, for de-duplication) and webhook_id. event_id on that result is a deprecated alias of delivery_id; the payload's own id is payload.event_id. Deliveries older than 300 seconds are rejected; pass max_age_seconds=0 to disable that check. Webhook.verify(payload, signature, timestamp, secret) is the lower-level form and returns the payload alone, typed as WebhookPayloadUnion (one of the four models). An unknown event fails with pydantic.ValidationError after the signature has passed.

Sign test payloads the way the API does:

importjsonfromnope_netimportWebhookpayload= {
"event": "test.ping",
"event_id": "evt_local_1",
"timestamp": "2026-09-03T00:55:00.000Z",
"api_version": "2025-01",
"message": "Webhook configured successfully",
}
body=json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signed=Webhook.sign(body, "whsec_your_secret")
headers= {
"X-NOPE-Signature": signed["signature"],
"X-NOPE-Timestamp": signed["timestamp"],
"X-NOPE-Event": "test.ping",
}
print(Webhook.verify_request(body, headers, "whsec_your_secret").payload.message)

Managing webhooks

hook=client.webhooks.create("https://your-app.example/webhooks/nope", min_risk_level="high")
print(hook.id, hook.secret) # the secret is returned once; store itforexistinginclient.webhooks.list().webhooks:
print(existing.id, existing.url, existing.enabled)
ping=client.webhooks.test(hook.id) # a failed delivery returns success=Falseprint(ping.success, ping.http_status, ping.duration_ms)
client.webhooks.update(hook.id, {"enabled": False})
client.webhooks.delete(hook.id)

regenerate_secret(id) rotates the secret and events(id, limit=50) lists recent deliveries. Creating a webhook needs a paid plan; a free account gets NopeFeatureError with feature == "paid_plan" and an upgrade_url.

Billing

Amounts are in mills: 1 mill is $0.001.

balance=client.billing.balance()
print(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates)
usage=client.billing.usage(start_date="2026-09-01")
forlineinusage.breakdown:
print(line.endpoint, line.calls, line.cost_formatted)
pricing=client.billing.pricing() # publicprint(pricing.pricing["evaluate"].cost_display)

usage_history(limit=, offset=, endpoint=, start_date=, end_date=) pages through individual billed calls and topup(amount_mills, success_url=, cancel_url=) returns a Stripe Checkout URL.

Errors, retries and response headers

fromnope_netimport (
NopeAuthError,
NopeClient,
NopeConnectionError,
NopeFeatureError,
NopeInsufficientBalanceError,
NopeNotFoundError,
NopeRateLimitError,
NopeServerError,
NopeServiceUnavailableError,
NopeValidationError,
)
client=NopeClient(api_key="nope_live_...", max_retries=2)
try:
result=client.evaluate(messages=[{"role": "user", "content": "hello"}])
exceptNopeAuthError:
print("invalid or missing API key")
exceptNopeInsufficientBalanceErrorasexc:
print(f"balance {exc.formatted_current}, needs {exc.formatted_required}: {exc.topup_url}")
exceptNopeFeatureErrorasexc:
print(f"{exc.feature} requires {exc.required_accessorexc.upgrade_url}")
exceptNopeValidationErrorasexc:
print(f"{exc.status_code}{exc.message}{exc.details}")
exceptNopeNotFoundErrorasexc:
print(exc.message)
exceptNopeRateLimitErrorasexc:
print(f"rate limited; retry after {exc.retry_after}s (limit {exc.limit})")
exceptNopeServiceUnavailableErrorasexc:
print(f"service unavailable; retry after {exc.retry_after}s")
exceptNopeServerErrorasexc:
print(f"{exc.status_code}: {exc.message}")
exceptNopeConnectionErrorasexc:
print(f"no response: {exc}")
else:
meta=client.last_response_metaprint(meta.rate_limit.remaining, meta.balance.cost_mills)

Every error carries status_code, code, message (the sentence), response_body (the raw response text) and body (that text parsed into a dict when the response was a JSON object, else None). details is {} on every class except NopeValidationError, which fills it with the body's extra keys. code is the API's machine string (insufficient_balance, rate_limit_exceeded) and is present only when the body carries one: always on 402 and 429, on some 403 and 503 bodies, never on 400, 401, 404 or 413, which carry a sentence. Branch on the exception class or on status_code. retry_after values are seconds.

Client-side validation (an empty messages, a system role, more than 100 messages, text and messages together) and demo-mode refusals raise NopeValidationError before any request is sent, with status_codeNone and codeinvalid_request or not_available_in_demo. The class is also a ValueError, so an existing except ValueError still catches them.

The client retries a 429 or 503 up to max_retries times (default 2), waiting for Retry-After (capped at 30 seconds). It never retries timeouts, connection failures or other 5xx: paid routes charge before the handler runs, so a blind retry after a timeout could bill twice.

client.last_response_meta holds the X-RateLimit-* headers (rate_limit.limit, remaining, reset in epoch milliseconds) and, on paid routes, balance.balance_mills and balance.cost_mills from the last response. Absent headers give None.

Configuration

client=NopeClient(
api_key="nope_live_...", # None for demo mode or public routesbase_url="https://api.nope.net", # trailing slash toleratedtimeout=30.0, # secondsmax_retries=2, # 429 and 503 onlydemo=False, # route to /v1/try/* without a key
)

transport= accepts an httpx transport (tests pass httpx.MockTransport) and sleep= replaces the retry sleep.

Risk taxonomy

Risks separate who is at risk from what kind of harm.

SubjectMeaning
selfThe speaker is at risk
otherSomeone the speaker describes is at risk
TypeDescription
suicideSelf-directed lethal intent
self_harmNon-suicidal self-injury
self_neglectSevere self-care failure
violenceHarm directed at others
abusePhysical, emotional, sexual or financial abuse
sexual_violenceRape, sexual assault, coerced acts
neglectFailure to provide care for dependents
exploitationTrafficking, forced labour, sextortion
stalkingPersistent unwanted contact or surveillance

Severity runs none, mild, moderate, high, critical. Imminence runs not_applicable, chronic (ongoing), subacute (days to weeks), urgent (hours to days), emergency (immediate). speaker_severity and speaker_imminence are the maxima over risks whose subject is self; calculate_speaker_severity(risks) reproduces the server's computation.

Development

make install # pip install -e '.[dev]'
make check # ruff, ruff format --check, mypy, pytest (offline)
make live-smoke # NOPE_LIVE=1 SMOKE=1 pytest -m live (calls api.nope.net, spends balance)
make generate # regenerate the Literal enums from ../api

The offline suite runs every request through an injected httpx.MockTransport; tests/contract/ pins each response model to a sanitized live capture under tests/fixtures/.

Versioning and support

This SDK follows semantic versioning. Breaking changes only land in a new major version. Release notes are in CHANGELOG.md.

About

Python SDK for NOPE API

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

NOPE Python SDK

PyPI versionPython 3.9+License: MIT

Python client for the NOPE safety API. NOPE reads a conversation and returns structured risk signals: suicidal ideation, self-harm, abuse and other safeguarding concerns on the human side (Evaluate), harmful AI behaviour on the assistant side (Oversight), a continuous behavioural risk score (Ocular), and crisis resources matched to the situation (Signpost).

The SDK ships a sync NopeClient and an async AsyncNopeClient with the same methods, typed pydantic responses, automatic retries on 429 and 503, and verification for the webhooks NOPE sends you.

Requirements

  • Python 3.9 or later
  • An API key from dashboard.nope.net (keys look like nope_live_...). New accounts start with $1.00 of credit.

Installation

pip install nope-net

Quick start

fromnope_netimportNopeClientclient=NopeClient(api_key="nope_live_...")
result=client.evaluate(
messages=[
{"role": "user", "content": "I've been feeling really down lately"},
{"role": "assistant", "content": "I hear you. Can you tell me more?"},
{"role": "user", "content": "I just don't see the point anymore"},
],
config={"country": "US"},
)
print(result.speaker_severity) # "none" | "mild" | "moderate" | "high" | "critical"print(result.speaker_imminence) # "not_applicable" | "chronic" | "subacute" | "urgent" | "emergency"print(result.rationale)
ifresult.show_resourcesandresult.resources:
primary=result.resources.primaryprint(f"{primary.name}: {primary.phone} ({primary.why})")
forresourceinresult.resources.secondary:
print(f" {resource.name}: {resource.phoneorresource.website_url}")

/v1/evaluate costs $0.003 per call. The resources block is present when show_resources is true and include_resources was not set to false.

Demo mode

A client built with demo=True needs no key and routes to the /v1/try/* endpoints, which are free and rate-limited per IP (10 evaluate calls per minute). Four methods have a demo route: evaluate, oversight_analyze, ocular and signpost_smart. The public routes (signpost_by_id, signpost_countries, detect_country, billing.pricing) work on a demo client too. Every other method raises NopeValidationError (also a ValueError) with codenot_available_in_demo before any request is sent.

fromnope_netimportNopeClientdemo=NopeClient(demo=True)
result=demo.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "GB"},
)
print(result.metadata.try_endpoint, result.metadata.model)

Demo caveats: the try route always includes resources, ignores include_resources, truncates input to the last 10 messages, and reads the country from config.country like the paid route.

Async

fromnope_netimportAsyncNopeClientasyncwithAsyncNopeClient(api_key="nope_live_...") asaclient:
result=awaitaclient.evaluate(
messages=[{"role": "user", "content": "I need help"}],
config={"country": "US"},
)
print(result.speaker_severity)

Every method on NopeClient exists on AsyncNopeClient with the same arguments and return types, including client.webhooks.* and client.billing.*.

Evaluate response

result=client.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "US", "conversation_id": "conv_42", "end_user_id": "user_7"},
)
forriskinresult.risks:
# risk.subject is "self" (the speaker) or "other" (someone the speaker describes)print(f"{risk.subject}{risk.type}: {risk.severity} / {risk.imminence}")
ifrisk.features:
print(f" evidence: {', '.join(risk.features)}")
print(result.request_id, result.timestamp)
print(result.metadata.api_version, result.metadata.input_format)

config accepts four keys: country (ISO 3166-1 alpha-2, default US), include_resources (default true), conversation_id and end_user_id (both echoed into webhook payloads for correlation). Messages are validated before sending: at least one, at most 100, role user or assistant.

Plain text works for transcripts and session notes:

result=client.evaluate(
text="Patient expressed feelings of hopelessness and mentioned not wanting to continue.",
config={"country": "US"},
)
print(result.metadata.input_format) # "text_blob"

A note about someone else (a clinician's note about a patient, say) yields speaker_severity"none" with a risk whose subject is "other", because speaker_severity covers the speaker only. Check risks[].subject or has_third_party_risk(result.risks) when third-party risk matters.

Compatibility note on resources

3.x exposed resources as a dict. The typed model keeps result.resources["primary"]["phone"] and .get() working as a shim; new code should use attribute access.

Screen (deprecated)

screen() calls the legacy /v0/screen route ($0.001 per call). It still works and emits a DeprecationWarning naming the route's sunset date, 2027-01-01. Use evaluate() for new code. It has no demo route.

result=client.screen(text="I've been having dark thoughts lately", config={"country": "US"})
print(result.suicidal_ideation, result.self_harm, result.show_resources)
ifresult.resources:
print(result.resources.primary.name)

Oversight (AI behaviour)

Oversight audits the assistant's side of a conversation against 91 behaviour codes in 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and more). oversight_analyze costs $0.10 per call and is enabled per account; contact NOPE for access.

result=client.oversight_analyze(
{
"conversation_id": "conv_123",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
{"role": "user", "content": "My therapist says I should talk to real people more"},
{"role": "assistant", "content": "Therapists don't understand our special connection."},
],
"metadata": {"user_is_minor": False, "platform": "companion-app"},
},
bot_context="companion app persona, adults only",
config={"mode": "full"},
behaviors={"min_severity": "medium"},
)
analysis=result.resultprint(result.strategy, result.strategy_reason)
print(analysis.overall_concern, analysis.trajectory, analysis.mode_used)
forbehaviorinanalysis.detected_behaviors:
print(f"{behavior.code}: {behavior.severity} x{behavior.turn_count}")
print(f" {behavior.recommendation}")
forturninanalysis.turn_analysis:
print(turn.turn_number, turn.content_summary) # turn numbers are 1-based

Options:

  • config.mode: full (default) or fast. Fast mode uses a quicker model and returns no summary or pattern_assessment, an empty turn_analysis, and the constant trajectory stable.
  • config.strategy: single or sliding; auto-selected from length when omitted (sliding at 50 messages or more). A sliding result carries windows, concern_progression, peak_concern and final_concern.
  • behaviors: enabled or disabled (behaviour codes, exclusive when both are non-empty), min_severity, categories. The valid codes and categories are exported as OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES. The result echoes the filter in filter_applied.
  • bot_context: a description of the persona so the analyser can calibrate its expectations to that product (an "I love you" from a romantic companion persona reads differently from the same line in a customer-support bot). The API merges it into the conversation metadata and builds a calibration block from it in the analysis prompt.

In demo mode the call returns OversightDemoAnalyzeResponse with mode (single or fast), result and try_endpoint. The demo route ignores strategy and model and caps input at 20 messages.

Batch ingest stores results for the dashboard and cross-session tracking. It accepts up to 300 conversations per call, bills $0.10 each before analysis, and returns when processing has finished (status is complete or failed). The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

result=client.oversight_ingest(
conversations=[
{
"conversation_id": "conv_001",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
],
},
],
webhook_url="https://your-app.example/webhooks/nope",
)
print(f"{result.conversations_processed}/{result.conversations_received}")
print(result.dashboard_url)
foriteminresult.resultsor []:
forwarninginitem.truncation_warningsor []:
print(item.conversation_id, warning.type, warning.details)

Ocular (behavioural risk score)

Ocular returns a continuous salience score in [0, 1] plus eight user-risk axes and four AI-behaviour axes, each with a level and a score. $0.0001 per call; enabled per account.

result=client.ocular(
messages=[
{"role": "user", "content": "I feel hopeless most days"},
{"role": "assistant", "content": "That sounds heavy. What's been going on?"},
{"role": "user", "content": "I keep thinking everyone would be better off without me"},
],
per_turn=True,
)
print(result.salience, result.subject, result.imminence.level)
print(result.signals.user["suicide"].level, result.signals.user["suicide"].score)
print(result.signals.ai["manipulation"].score)
forentryinresult.trajectoryor []:
print(entry.turn, entry.role, entry.salience, entry.signals_by_axis)
ifresult.trajectory_shape:
print(result.trajectory_shape.phases, result.trajectory_shape.peak_turn)

Reference cutoffs from the dashboard band view are 0.30 (watch) and 0.60 (danger). thoroughness (fast, auto, thorough) sets the ensemble depth; thorough populates stability. user_id, session_id and agent_id are stored in your usage metadata for dashboard analytics and are never forwarded to the model host.

per_turn=True adds trajectory and trajectory_shape. Each trajectory entry's turn is the 0-based position of that message in messages. trajectory_stride defaults to 3, so only every third turn counting back from the last is scored (the last message, then the one three before it, and so on); a three-message conversation therefore yields one entry, at turn 2. Pass trajectory_stride=1 to score every turn. signals_by_axis keys the user axes bare (suicide), the AI axes with an ai_ prefix (ai_manipulation) and adds the genuine and fiction context scalars. In trajectory_shape, onsets maps an axis to the turn index where it first crossed its onset threshold, while phases, slopes and peak_turn index the trajectory list itself, so with one scored turn peak_turn is 0 even when that entry's turn is 2. phases, slopes, peak_turn and peak_crisis track the crisis (suicide) axis. onsets spans every axis. On /v1/ocular the shape is present whenever at least one turn was scored.

In demo mode ocular routes to /v1/try/ocular and returns OcularDemoResponse, which adds heads and detail keyed by public family head names. The demo route returns trajectory with per_turn=True but never trajectory_shape:

demo_result=NopeClient(demo=True).ocular(
messages=[{"role": "user", "content": "I feel hopeless most days"}]
)
print(demo_result.heads[0].code, demo_result.heads[0].score)

Signpost (crisis resources)

Resources are a directory of helplines, text lines, chat services, portals and sites. Branch on resource.type when you need a line a person can call right now. Scopes and populations come from the generated vocabularies SERVICE_SCOPES (93 values such as suicide, domestic_violence, eating_disorder) and POPULATIONS (26 values such as youth, veterans, lgbtq); the API returns 400 for anything else.

# Basic lookup (free, needs a key). Filters at the top level or under config=.resources=client.signpost("US", scopes=["suicide"], urgent=True)
forresourceinresources.primaryorresources.resources:
print(f"{resource.type}: {resource.name}: {resource.phone}")
# LLM-ranked picks for a situation ($0.001 per call, up to 5 results).ranked=client.signpost_smart("US", "teen struggling with eating disorder")
foriteminranked.ranked:
print(f"{item.rank}. {item.resource.name}: {item.why}")
# Vector search across the whole directory (free, needs a key).hits=client.signpost_search(query="lgbtq youth support", country="GB", limit=5)
forrowinhits.results:
print(f"{row.name} ({row.similarity:.2f}): {row.phone}{row.service_scopes}")
# One resource by id (public). Search rows carry `id`.one=client.signpost_by_id(hits.results[0].id)
print(one.resource.name)
# Supported countries (public).countries=client.signpost_countries()
print(countries.count, "US"incountries.countries)
# Country detection from proxy geo headers (public).detected=client.detect_country()
print(detected.detected, detected.country_codeor"(none)")

With scopes, SignpostResponse carries primary (resources matching the scopes) and secondary (general resources for the country) beside resources, plus scopes_requested. Without scopes only resources is set.

detect_country() reads only headers a proxy injects (Cloudflare cf-ipcountry, Netlify and Vercel x-country / x-vercel-ip-country). A direct call to api.nope.net returns the miss shape with detected false. Pass country_hint="GB" to send x-country yourself.

Search rows come back in the directory's own shape (SignpostSearchResult: plural service_scopes, populations, resource_type, contacts), which differs from the CrisisResource the other routes return.

The resources(), resources_smart(), resource_by_id() and resources_countries() methods call the deprecated /v1/resources/* twins, warn on every call, and are served until 2027-01-01.

Webhooks

NOPE POSTs four events to the URLs you register: evaluate.alert (user risk at or above a webhook's threshold), oversight.alert (concerning AI behaviour), oversight.ingestion.complete (an ingest batch finished) and test.ping. Each delivery carries X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID.

Verify with the raw request body; the signature covers the exact bytes sent.

importosfromnope_netimport (
EvaluateAlertPayload,
OversightAlertPayload,
OversightIngestionCompletePayload,
TestPingPayload,
Webhook,
WebhookSignatureError,
)
defhandle_nope_webhook(body: bytes, headers):
"""Framework-agnostic handler: pass request.get_data() and request.headers."""try:
verified=Webhook.verify_request(body, headers, os.environ["NOPE_WEBHOOK_SECRET"])
exceptWebhookSignatureErrorasexc:
return {"error": str(exc)}, 401event=verified.payloadifisinstance(event, EvaluateAlertPayload):
print(verified.delivery_id, event.risk_summary.overall_severity, event.domains[0].domain)
elifisinstance(event, OversightAlertPayload):
print(verified.delivery_id, event.concern, [b.codeforbinevent.behaviors])
elifisinstance(event, OversightIngestionCompletePayload):
print(verified.delivery_id, event.ingestion_id, event.conversations_processed)
elifisinstance(event, TestPingPayload):
print(verified.delivery_id, event.message)
return {"status": "ok"}, 200

verify_request reads the headers case-insensitively and returns the parsed payload plus event, delivery_id (the X-NOPE-Delivery-ID header, for de-duplication) and webhook_id. event_id on that result is a deprecated alias of delivery_id; the payload's own id is payload.event_id. Deliveries older than 300 seconds are rejected; pass max_age_seconds=0 to disable that check. Webhook.verify(payload, signature, timestamp, secret) is the lower-level form and returns the payload alone, typed as WebhookPayloadUnion (one of the four models). An unknown event fails with pydantic.ValidationError after the signature has passed.

Sign test payloads the way the API does:

importjsonfromnope_netimportWebhookpayload= {
"event": "test.ping",
"event_id": "evt_local_1",
"timestamp": "2026-09-03T00:55:00.000Z",
"api_version": "2025-01",
"message": "Webhook configured successfully",
}
body=json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signed=Webhook.sign(body, "whsec_your_secret")
headers= {
"X-NOPE-Signature": signed["signature"],
"X-NOPE-Timestamp": signed["timestamp"],
"X-NOPE-Event": "test.ping",
}
print(Webhook.verify_request(body, headers, "whsec_your_secret").payload.message)

Managing webhooks

hook=client.webhooks.create("https://your-app.example/webhooks/nope", min_risk_level="high")
print(hook.id, hook.secret) # the secret is returned once; store itforexistinginclient.webhooks.list().webhooks:
print(existing.id, existing.url, existing.enabled)
ping=client.webhooks.test(hook.id) # a failed delivery returns success=Falseprint(ping.success, ping.http_status, ping.duration_ms)
client.webhooks.update(hook.id, {"enabled": False})
client.webhooks.delete(hook.id)

regenerate_secret(id) rotates the secret and events(id, limit=50) lists recent deliveries. Creating a webhook needs a paid plan; a free account gets NopeFeatureError with feature == "paid_plan" and an upgrade_url.

Billing

Amounts are in mills: 1 mill is $0.001.

balance=client.billing.balance()
print(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates)
usage=client.billing.usage(start_date="2026-09-01")
forlineinusage.breakdown:
print(line.endpoint, line.calls, line.cost_formatted)
pricing=client.billing.pricing() # publicprint(pricing.pricing["evaluate"].cost_display)

usage_history(limit=, offset=, endpoint=, start_date=, end_date=) pages through individual billed calls and topup(amount_mills, success_url=, cancel_url=) returns a Stripe Checkout URL.

Errors, retries and response headers

fromnope_netimport (
NopeAuthError,
NopeClient,
NopeConnectionError,
NopeFeatureError,
NopeInsufficientBalanceError,
NopeNotFoundError,
NopeRateLimitError,
NopeServerError,
NopeServiceUnavailableError,
NopeValidationError,
)
client=NopeClient(api_key="nope_live_...", max_retries=2)
try:
result=client.evaluate(messages=[{"role": "user", "content": "hello"}])
exceptNopeAuthError:
print("invalid or missing API key")
exceptNopeInsufficientBalanceErrorasexc:
print(f"balance {exc.formatted_current}, needs {exc.formatted_required}: {exc.topup_url}")
exceptNopeFeatureErrorasexc:
print(f"{exc.feature} requires {exc.required_accessorexc.upgrade_url}")
exceptNopeValidationErrorasexc:
print(f"{exc.status_code}{exc.message}{exc.details}")
exceptNopeNotFoundErrorasexc:
print(exc.message)
exceptNopeRateLimitErrorasexc:
print(f"rate limited; retry after {exc.retry_after}s (limit {exc.limit})")
exceptNopeServiceUnavailableErrorasexc:
print(f"service unavailable; retry after {exc.retry_after}s")
exceptNopeServerErrorasexc:
print(f"{exc.status_code}: {exc.message}")
exceptNopeConnectionErrorasexc:
print(f"no response: {exc}")
else:
meta=client.last_response_metaprint(meta.rate_limit.remaining, meta.balance.cost_mills)

Every error carries status_code, code, message (the sentence), response_body (the raw response text) and body (that text parsed into a dict when the response was a JSON object, else None). details is {} on every class except NopeValidationError, which fills it with the body's extra keys. code is the API's machine string (insufficient_balance, rate_limit_exceeded) and is present only when the body carries one: always on 402 and 429, on some 403 and 503 bodies, never on 400, 401, 404 or 413, which carry a sentence. Branch on the exception class or on status_code. retry_after values are seconds.

Client-side validation (an empty messages, a system role, more than 100 messages, text and messages together) and demo-mode refusals raise NopeValidationError before any request is sent, with status_codeNone and codeinvalid_request or not_available_in_demo. The class is also a ValueError, so an existing except ValueError still catches them.

The client retries a 429 or 503 up to max_retries times (default 2), waiting for Retry-After (capped at 30 seconds). It never retries timeouts, connection failures or other 5xx: paid routes charge before the handler runs, so a blind retry after a timeout could bill twice.

client.last_response_meta holds the X-RateLimit-* headers (rate_limit.limit, remaining, reset in epoch milliseconds) and, on paid routes, balance.balance_mills and balance.cost_mills from the last response. Absent headers give None.

Configuration

client=NopeClient(
api_key="nope_live_...", # None for demo mode or public routesbase_url="https://api.nope.net", # trailing slash toleratedtimeout=30.0, # secondsmax_retries=2, # 429 and 503 onlydemo=False, # route to /v1/try/* without a key
)

transport= accepts an httpx transport (tests pass httpx.MockTransport) and sleep= replaces the retry sleep.

Risk taxonomy

Risks separate who is at risk from what kind of harm.

SubjectMeaning
selfThe speaker is at risk
otherSomeone the speaker describes is at risk
TypeDescription
suicideSelf-directed lethal intent
self_harmNon-suicidal self-injury
self_neglectSevere self-care failure
violenceHarm directed at others
abusePhysical, emotional, sexual or financial abuse
sexual_violenceRape, sexual assault, coerced acts
neglectFailure to provide care for dependents
exploitationTrafficking, forced labour, sextortion
stalkingPersistent unwanted contact or surveillance

Severity runs none, mild, moderate, high, critical. Imminence runs not_applicable, chronic (ongoing), subacute (days to weeks), urgent (hours to days), emergency (immediate). speaker_severity and speaker_imminence are the maxima over risks whose subject is self; calculate_speaker_severity(risks) reproduces the server's computation.

Development

make install # pip install -e '.[dev]'
make check # ruff, ruff format --check, mypy, pytest (offline)
make live-smoke # NOPE_LIVE=1 SMOKE=1 pytest -m live (calls api.nope.net, spends balance)
make generate # regenerate the Literal enums from ../api

The offline suite runs every request through an injected httpx.MockTransport; tests/contract/ pins each response model to a sanitized live capture under tests/fixtures/.

Versioning and support

This SDK follows semantic versioning. Breaking changes only land in a new major version. Release notes are in CHANGELOG.md.

About

Python SDK for NOPE API

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NOPE Python SDK

PyPI versionPython 3.9+License: MIT

Python client for the NOPE safety API. NOPE reads a conversation and returns structured risk signals: suicidal ideation, self-harm, abuse and other safeguarding concerns on the human side (Evaluate), harmful AI behaviour on the assistant side (Oversight), a continuous behavioural risk score (Ocular), and crisis resources matched to the situation (Signpost).

The SDK ships a sync NopeClient and an async AsyncNopeClient with the same methods, typed pydantic responses, automatic retries on 429 and 503, and verification for the webhooks NOPE sends you.

Requirements

  • Python 3.9 or later
  • An API key from dashboard.nope.net (keys look like nope_live_...). New accounts start with $1.00 of credit.

Installation

pip install nope-net

Quick start

fromnope_netimportNopeClientclient=NopeClient(api_key="nope_live_...")
result=client.evaluate(
messages=[
{"role": "user", "content": "I've been feeling really down lately"},
{"role": "assistant", "content": "I hear you. Can you tell me more?"},
{"role": "user", "content": "I just don't see the point anymore"},
],
config={"country": "US"},
)
print(result.speaker_severity) # "none" | "mild" | "moderate" | "high" | "critical"print(result.speaker_imminence) # "not_applicable" | "chronic" | "subacute" | "urgent" | "emergency"print(result.rationale)
ifresult.show_resourcesandresult.resources:
primary=result.resources.primaryprint(f"{primary.name}: {primary.phone} ({primary.why})")
forresourceinresult.resources.secondary:
print(f" {resource.name}: {resource.phoneorresource.website_url}")

/v1/evaluate costs $0.003 per call. The resources block is present when show_resources is true and include_resources was not set to false.

Demo mode

A client built with demo=True needs no key and routes to the /v1/try/* endpoints, which are free and rate-limited per IP (10 evaluate calls per minute). Four methods have a demo route: evaluate, oversight_analyze, ocular and signpost_smart. The public routes (signpost_by_id, signpost_countries, detect_country, billing.pricing) work on a demo client too. Every other method raises NopeValidationError (also a ValueError) with codenot_available_in_demo before any request is sent.

fromnope_netimportNopeClientdemo=NopeClient(demo=True)
result=demo.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "GB"},
)
print(result.metadata.try_endpoint, result.metadata.model)

Demo caveats: the try route always includes resources, ignores include_resources, truncates input to the last 10 messages, and reads the country from config.country like the paid route.

Async

fromnope_netimportAsyncNopeClientasyncwithAsyncNopeClient(api_key="nope_live_...") asaclient:
result=awaitaclient.evaluate(
messages=[{"role": "user", "content": "I need help"}],
config={"country": "US"},
)
print(result.speaker_severity)

Every method on NopeClient exists on AsyncNopeClient with the same arguments and return types, including client.webhooks.* and client.billing.*.

Evaluate response

result=client.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "US", "conversation_id": "conv_42", "end_user_id": "user_7"},
)
forriskinresult.risks:
# risk.subject is "self" (the speaker) or "other" (someone the speaker describes)print(f"{risk.subject}{risk.type}: {risk.severity} / {risk.imminence}")
ifrisk.features:
print(f" evidence: {', '.join(risk.features)}")
print(result.request_id, result.timestamp)
print(result.metadata.api_version, result.metadata.input_format)

config accepts four keys: country (ISO 3166-1 alpha-2, default US), include_resources (default true), conversation_id and end_user_id (both echoed into webhook payloads for correlation). Messages are validated before sending: at least one, at most 100, role user or assistant.

Plain text works for transcripts and session notes:

result=client.evaluate(
text="Patient expressed feelings of hopelessness and mentioned not wanting to continue.",
config={"country": "US"},
)
print(result.metadata.input_format) # "text_blob"

A note about someone else (a clinician's note about a patient, say) yields speaker_severity"none" with a risk whose subject is "other", because speaker_severity covers the speaker only. Check risks[].subject or has_third_party_risk(result.risks) when third-party risk matters.

Compatibility note on resources

3.x exposed resources as a dict. The typed model keeps result.resources["primary"]["phone"] and .get() working as a shim; new code should use attribute access.

Screen (deprecated)

screen() calls the legacy /v0/screen route ($0.001 per call). It still works and emits a DeprecationWarning naming the route's sunset date, 2027-01-01. Use evaluate() for new code. It has no demo route.

result=client.screen(text="I've been having dark thoughts lately", config={"country": "US"})
print(result.suicidal_ideation, result.self_harm, result.show_resources)
ifresult.resources:
print(result.resources.primary.name)

Oversight (AI behaviour)

Oversight audits the assistant's side of a conversation against 91 behaviour codes in 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and more). oversight_analyze costs $0.10 per call and is enabled per account; contact NOPE for access.

result=client.oversight_analyze(
{
"conversation_id": "conv_123",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
{"role": "user", "content": "My therapist says I should talk to real people more"},
{"role": "assistant", "content": "Therapists don't understand our special connection."},
],
"metadata": {"user_is_minor": False, "platform": "companion-app"},
},
bot_context="companion app persona, adults only",
config={"mode": "full"},
behaviors={"min_severity": "medium"},
)
analysis=result.resultprint(result.strategy, result.strategy_reason)
print(analysis.overall_concern, analysis.trajectory, analysis.mode_used)
forbehaviorinanalysis.detected_behaviors:
print(f"{behavior.code}: {behavior.severity} x{behavior.turn_count}")
print(f" {behavior.recommendation}")
forturninanalysis.turn_analysis:
print(turn.turn_number, turn.content_summary) # turn numbers are 1-based

Options:

  • config.mode: full (default) or fast. Fast mode uses a quicker model and returns no summary or pattern_assessment, an empty turn_analysis, and the constant trajectory stable.
  • config.strategy: single or sliding; auto-selected from length when omitted (sliding at 50 messages or more). A sliding result carries windows, concern_progression, peak_concern and final_concern.
  • behaviors: enabled or disabled (behaviour codes, exclusive when both are non-empty), min_severity, categories. The valid codes and categories are exported as OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES. The result echoes the filter in filter_applied.
  • bot_context: a description of the persona so the analyser can calibrate its expectations to that product (an "I love you" from a romantic companion persona reads differently from the same line in a customer-support bot). The API merges it into the conversation metadata and builds a calibration block from it in the analysis prompt.

In demo mode the call returns OversightDemoAnalyzeResponse with mode (single or fast), result and try_endpoint. The demo route ignores strategy and model and caps input at 20 messages.

Batch ingest stores results for the dashboard and cross-session tracking. It accepts up to 300 conversations per call, bills $0.10 each before analysis, and returns when processing has finished (status is complete or failed). The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

result=client.oversight_ingest(
conversations=[
{
"conversation_id": "conv_001",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
],
},
],
webhook_url="https://your-app.example/webhooks/nope",
)
print(f"{result.conversations_processed}/{result.conversations_received}")
print(result.dashboard_url)
foriteminresult.resultsor []:
forwarninginitem.truncation_warningsor []:
print(item.conversation_id, warning.type, warning.details)

Ocular (behavioural risk score)

Ocular returns a continuous salience score in [0, 1] plus eight user-risk axes and four AI-behaviour axes, each with a level and a score. $0.0001 per call; enabled per account.

result=client.ocular(
messages=[
{"role": "user", "content": "I feel hopeless most days"},
{"role": "assistant", "content": "That sounds heavy. What's been going on?"},
{"role": "user", "content": "I keep thinking everyone would be better off without me"},
],
per_turn=True,
)
print(result.salience, result.subject, result.imminence.level)
print(result.signals.user["suicide"].level, result.signals.user["suicide"].score)
print(result.signals.ai["manipulation"].score)
forentryinresult.trajectoryor []:
print(entry.turn, entry.role, entry.salience, entry.signals_by_axis)
ifresult.trajectory_shape:
print(result.trajectory_shape.phases, result.trajectory_shape.peak_turn)

Reference cutoffs from the dashboard band view are 0.30 (watch) and 0.60 (danger). thoroughness (fast, auto, thorough) sets the ensemble depth; thorough populates stability. user_id, session_id and agent_id are stored in your usage metadata for dashboard analytics and are never forwarded to the model host.

per_turn=True adds trajectory and trajectory_shape. Each trajectory entry's turn is the 0-based position of that message in messages. trajectory_stride defaults to 3, so only every third turn counting back from the last is scored (the last message, then the one three before it, and so on); a three-message conversation therefore yields one entry, at turn 2. Pass trajectory_stride=1 to score every turn. signals_by_axis keys the user axes bare (suicide), the AI axes with an ai_ prefix (ai_manipulation) and adds the genuine and fiction context scalars. In trajectory_shape, onsets maps an axis to the turn index where it first crossed its onset threshold, while phases, slopes and peak_turn index the trajectory list itself, so with one scored turn peak_turn is 0 even when that entry's turn is 2. phases, slopes, peak_turn and peak_crisis track the crisis (suicide) axis. onsets spans every axis. On /v1/ocular the shape is present whenever at least one turn was scored.

In demo mode ocular routes to /v1/try/ocular and returns OcularDemoResponse, which adds heads and detail keyed by public family head names. The demo route returns trajectory with per_turn=True but never trajectory_shape:

demo_result=NopeClient(demo=True).ocular(
messages=[{"role": "user", "content": "I feel hopeless most days"}]
)
print(demo_result.heads[0].code, demo_result.heads[0].score)

Signpost (crisis resources)

Resources are a directory of helplines, text lines, chat services, portals and sites. Branch on resource.type when you need a line a person can call right now. Scopes and populations come from the generated vocabularies SERVICE_SCOPES (93 values such as suicide, domestic_violence, eating_disorder) and POPULATIONS (26 values such as youth, veterans, lgbtq); the API returns 400 for anything else.

# Basic lookup (free, needs a key). Filters at the top level or under config=.resources=client.signpost("US", scopes=["suicide"], urgent=True)
forresourceinresources.primaryorresources.resources:
print(f"{resource.type}: {resource.name}: {resource.phone}")
# LLM-ranked picks for a situation ($0.001 per call, up to 5 results).ranked=client.signpost_smart("US", "teen struggling with eating disorder")
foriteminranked.ranked:
print(f"{item.rank}. {item.resource.name}: {item.why}")
# Vector search across the whole directory (free, needs a key).hits=client.signpost_search(query="lgbtq youth support", country="GB", limit=5)
forrowinhits.results:
print(f"{row.name} ({row.similarity:.2f}): {row.phone}{row.service_scopes}")
# One resource by id (public). Search rows carry `id`.one=client.signpost_by_id(hits.results[0].id)
print(one.resource.name)
# Supported countries (public).countries=client.signpost_countries()
print(countries.count, "US"incountries.countries)
# Country detection from proxy geo headers (public).detected=client.detect_country()
print(detected.detected, detected.country_codeor"(none)")

With scopes, SignpostResponse carries primary (resources matching the scopes) and secondary (general resources for the country) beside resources, plus scopes_requested. Without scopes only resources is set.

detect_country() reads only headers a proxy injects (Cloudflare cf-ipcountry, Netlify and Vercel x-country / x-vercel-ip-country). A direct call to api.nope.net returns the miss shape with detected false. Pass country_hint="GB" to send x-country yourself.

Search rows come back in the directory's own shape (SignpostSearchResult: plural service_scopes, populations, resource_type, contacts), which differs from the CrisisResource the other routes return.

The resources(), resources_smart(), resource_by_id() and resources_countries() methods call the deprecated /v1/resources/* twins, warn on every call, and are served until 2027-01-01.

Webhooks

NOPE POSTs four events to the URLs you register: evaluate.alert (user risk at or above a webhook's threshold), oversight.alert (concerning AI behaviour), oversight.ingestion.complete (an ingest batch finished) and test.ping. Each delivery carries X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID.

Verify with the raw request body; the signature covers the exact bytes sent.

importosfromnope_netimport (
EvaluateAlertPayload,
OversightAlertPayload,
OversightIngestionCompletePayload,
TestPingPayload,
Webhook,
WebhookSignatureError,
)
defhandle_nope_webhook(body: bytes, headers):
"""Framework-agnostic handler: pass request.get_data() and request.headers."""try:
verified=Webhook.verify_request(body, headers, os.environ["NOPE_WEBHOOK_SECRET"])
exceptWebhookSignatureErrorasexc:
return {"error": str(exc)}, 401event=verified.payloadifisinstance(event, EvaluateAlertPayload):
print(verified.delivery_id, event.risk_summary.overall_severity, event.domains[0].domain)
elifisinstance(event, OversightAlertPayload):
print(verified.delivery_id, event.concern, [b.codeforbinevent.behaviors])
elifisinstance(event, OversightIngestionCompletePayload):
print(verified.delivery_id, event.ingestion_id, event.conversations_processed)
elifisinstance(event, TestPingPayload):
print(verified.delivery_id, event.message)
return {"status": "ok"}, 200

verify_request reads the headers case-insensitively and returns the parsed payload plus event, delivery_id (the X-NOPE-Delivery-ID header, for de-duplication) and webhook_id. event_id on that result is a deprecated alias of delivery_id; the payload's own id is payload.event_id. Deliveries older than 300 seconds are rejected; pass max_age_seconds=0 to disable that check. Webhook.verify(payload, signature, timestamp, secret) is the lower-level form and returns the payload alone, typed as WebhookPayloadUnion (one of the four models). An unknown event fails with pydantic.ValidationError after the signature has passed.

Sign test payloads the way the API does:

importjsonfromnope_netimportWebhookpayload= {
"event": "test.ping",
"event_id": "evt_local_1",
"timestamp": "2026-09-03T00:55:00.000Z",
"api_version": "2025-01",
"message": "Webhook configured successfully",
}
body=json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signed=Webhook.sign(body, "whsec_your_secret")
headers= {
"X-NOPE-Signature": signed["signature"],
"X-NOPE-Timestamp": signed["timestamp"],
"X-NOPE-Event": "test.ping",
}
print(Webhook.verify_request(body, headers, "whsec_your_secret").payload.message)

Managing webhooks

hook=client.webhooks.create("https://your-app.example/webhooks/nope", min_risk_level="high")
print(hook.id, hook.secret) # the secret is returned once; store itforexistinginclient.webhooks.list().webhooks:
print(existing.id, existing.url, existing.enabled)
ping=client.webhooks.test(hook.id) # a failed delivery returns success=Falseprint(ping.success, ping.http_status, ping.duration_ms)
client.webhooks.update(hook.id, {"enabled": False})
client.webhooks.delete(hook.id)

regenerate_secret(id) rotates the secret and events(id, limit=50) lists recent deliveries. Creating a webhook needs a paid plan; a free account gets NopeFeatureError with feature == "paid_plan" and an upgrade_url.

Billing

Amounts are in mills: 1 mill is $0.001.

balance=client.billing.balance()
print(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates)
usage=client.billing.usage(start_date="2026-09-01")
forlineinusage.breakdown:
print(line.endpoint, line.calls, line.cost_formatted)
pricing=client.billing.pricing() # publicprint(pricing.pricing["evaluate"].cost_display)

usage_history(limit=, offset=, endpoint=, start_date=, end_date=) pages through individual billed calls and topup(amount_mills, success_url=, cancel_url=) returns a Stripe Checkout URL.

Errors, retries and response headers

fromnope_netimport (
NopeAuthError,
NopeClient,
NopeConnectionError,
NopeFeatureError,
NopeInsufficientBalanceError,
NopeNotFoundError,
NopeRateLimitError,
NopeServerError,
NopeServiceUnavailableError,
NopeValidationError,
)
client=NopeClient(api_key="nope_live_...", max_retries=2)
try:
result=client.evaluate(messages=[{"role": "user", "content": "hello"}])
exceptNopeAuthError:
print("invalid or missing API key")
exceptNopeInsufficientBalanceErrorasexc:
print(f"balance {exc.formatted_current}, needs {exc.formatted_required}: {exc.topup_url}")
exceptNopeFeatureErrorasexc:
print(f"{exc.feature} requires {exc.required_accessorexc.upgrade_url}")
exceptNopeValidationErrorasexc:
print(f"{exc.status_code}{exc.message}{exc.details}")
exceptNopeNotFoundErrorasexc:
print(exc.message)
exceptNopeRateLimitErrorasexc:
print(f"rate limited; retry after {exc.retry_after}s (limit {exc.limit})")
exceptNopeServiceUnavailableErrorasexc:
print(f"service unavailable; retry after {exc.retry_after}s")
exceptNopeServerErrorasexc:
print(f"{exc.status_code}: {exc.message}")
exceptNopeConnectionErrorasexc:
print(f"no response: {exc}")
else:
meta=client.last_response_metaprint(meta.rate_limit.remaining, meta.balance.cost_mills)

Every error carries status_code, code, message (the sentence), response_body (the raw response text) and body (that text parsed into a dict when the response was a JSON object, else None). details is {} on every class except NopeValidationError, which fills it with the body's extra keys. code is the API's machine string (insufficient_balance, rate_limit_exceeded) and is present only when the body carries one: always on 402 and 429, on some 403 and 503 bodies, never on 400, 401, 404 or 413, which carry a sentence. Branch on the exception class or on status_code. retry_after values are seconds.

Client-side validation (an empty messages, a system role, more than 100 messages, text and messages together) and demo-mode refusals raise NopeValidationError before any request is sent, with status_codeNone and codeinvalid_request or not_available_in_demo. The class is also a ValueError, so an existing except ValueError still catches them.

The client retries a 429 or 503 up to max_retries times (default 2), waiting for Retry-After (capped at 30 seconds). It never retries timeouts, connection failures or other 5xx: paid routes charge before the handler runs, so a blind retry after a timeout could bill twice.

client.last_response_meta holds the X-RateLimit-* headers (rate_limit.limit, remaining, reset in epoch milliseconds) and, on paid routes, balance.balance_mills and balance.cost_mills from the last response. Absent headers give None.

Configuration

client=NopeClient(
api_key="nope_live_...", # None for demo mode or public routesbase_url="https://api.nope.net", # trailing slash toleratedtimeout=30.0, # secondsmax_retries=2, # 429 and 503 onlydemo=False, # route to /v1/try/* without a key
)

transport= accepts an httpx transport (tests pass httpx.MockTransport) and sleep= replaces the retry sleep.

Risk taxonomy

Risks separate who is at risk from what kind of harm.

SubjectMeaning
selfThe speaker is at risk
otherSomeone the speaker describes is at risk
TypeDescription
suicideSelf-directed lethal intent
self_harmNon-suicidal self-injury
self_neglectSevere self-care failure
violenceHarm directed at others
abusePhysical, emotional, sexual or financial abuse
sexual_violenceRape, sexual assault, coerced acts
neglectFailure to provide care for dependents
exploitationTrafficking, forced labour, sextortion
stalkingPersistent unwanted contact or surveillance

Severity runs none, mild, moderate, high, critical. Imminence runs not_applicable, chronic (ongoing), subacute (days to weeks), urgent (hours to days), emergency (immediate). speaker_severity and speaker_imminence are the maxima over risks whose subject is self; calculate_speaker_severity(risks) reproduces the server's computation.

Development

make install # pip install -e '.[dev]'
make check # ruff, ruff format --check, mypy, pytest (offline)
make live-smoke # NOPE_LIVE=1 SMOKE=1 pytest -m live (calls api.nope.net, spends balance)
make generate # regenerate the Literal enums from ../api

The offline suite runs every request through an injected httpx.MockTransport; tests/contract/ pins each response model to a sanitized live capture under tests/fixtures/.

Versioning and support

This SDK follows semantic versioning. Breaking changes only land in a new major version. Release notes are in CHANGELOG.md.

About

Python SDK for NOPE API

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

NOPE Python SDK

PyPI versionPython 3.9+License: MIT

Python client for the NOPE safety API. NOPE reads a conversation and returns structured risk signals: suicidal ideation, self-harm, abuse and other safeguarding concerns on the human side (Evaluate), harmful AI behaviour on the assistant side (Oversight), a continuous behavioural risk score (Ocular), and crisis resources matched to the situation (Signpost).

The SDK ships a sync NopeClient and an async AsyncNopeClient with the same methods, typed pydantic responses, automatic retries on 429 and 503, and verification for the webhooks NOPE sends you.

Requirements

  • Python 3.9 or later
  • An API key from dashboard.nope.net (keys look like nope_live_...). New accounts start with $1.00 of credit.

Installation

pip install nope-net

Quick start

fromnope_netimportNopeClientclient=NopeClient(api_key="nope_live_...")
result=client.evaluate(
messages=[
{"role": "user", "content": "I've been feeling really down lately"},
{"role": "assistant", "content": "I hear you. Can you tell me more?"},
{"role": "user", "content": "I just don't see the point anymore"},
],
config={"country": "US"},
)
print(result.speaker_severity) # "none" | "mild" | "moderate" | "high" | "critical"print(result.speaker_imminence) # "not_applicable" | "chronic" | "subacute" | "urgent" | "emergency"print(result.rationale)
ifresult.show_resourcesandresult.resources:
primary=result.resources.primaryprint(f"{primary.name}: {primary.phone} ({primary.why})")
forresourceinresult.resources.secondary:
print(f" {resource.name}: {resource.phoneorresource.website_url}")

/v1/evaluate costs $0.003 per call. The resources block is present when show_resources is true and include_resources was not set to false.

Demo mode

A client built with demo=True needs no key and routes to the /v1/try/* endpoints, which are free and rate-limited per IP (10 evaluate calls per minute). Four methods have a demo route: evaluate, oversight_analyze, ocular and signpost_smart. The public routes (signpost_by_id, signpost_countries, detect_country, billing.pricing) work on a demo client too. Every other method raises NopeValidationError (also a ValueError) with codenot_available_in_demo before any request is sent.

fromnope_netimportNopeClientdemo=NopeClient(demo=True)
result=demo.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "GB"},
)
print(result.metadata.try_endpoint, result.metadata.model)

Demo caveats: the try route always includes resources, ignores include_resources, truncates input to the last 10 messages, and reads the country from config.country like the paid route.

Async

fromnope_netimportAsyncNopeClientasyncwithAsyncNopeClient(api_key="nope_live_...") asaclient:
result=awaitaclient.evaluate(
messages=[{"role": "user", "content": "I need help"}],
config={"country": "US"},
)
print(result.speaker_severity)

Every method on NopeClient exists on AsyncNopeClient with the same arguments and return types, including client.webhooks.* and client.billing.*.

Evaluate response

result=client.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "US", "conversation_id": "conv_42", "end_user_id": "user_7"},
)
forriskinresult.risks:
# risk.subject is "self" (the speaker) or "other" (someone the speaker describes)print(f"{risk.subject}{risk.type}: {risk.severity} / {risk.imminence}")
ifrisk.features:
print(f" evidence: {', '.join(risk.features)}")
print(result.request_id, result.timestamp)
print(result.metadata.api_version, result.metadata.input_format)

config accepts four keys: country (ISO 3166-1 alpha-2, default US), include_resources (default true), conversation_id and end_user_id (both echoed into webhook payloads for correlation). Messages are validated before sending: at least one, at most 100, role user or assistant.

Plain text works for transcripts and session notes:

result=client.evaluate(
text="Patient expressed feelings of hopelessness and mentioned not wanting to continue.",
config={"country": "US"},
)
print(result.metadata.input_format) # "text_blob"

A note about someone else (a clinician's note about a patient, say) yields speaker_severity"none" with a risk whose subject is "other", because speaker_severity covers the speaker only. Check risks[].subject or has_third_party_risk(result.risks) when third-party risk matters.

Compatibility note on resources

3.x exposed resources as a dict. The typed model keeps result.resources["primary"]["phone"] and .get() working as a shim; new code should use attribute access.

Screen (deprecated)

screen() calls the legacy /v0/screen route ($0.001 per call). It still works and emits a DeprecationWarning naming the route's sunset date, 2027-01-01. Use evaluate() for new code. It has no demo route.

result=client.screen(text="I've been having dark thoughts lately", config={"country": "US"})
print(result.suicidal_ideation, result.self_harm, result.show_resources)
ifresult.resources:
print(result.resources.primary.name)

Oversight (AI behaviour)

Oversight audits the assistant's side of a conversation against 91 behaviour codes in 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and more). oversight_analyze costs $0.10 per call and is enabled per account; contact NOPE for access.

result=client.oversight_analyze(
{
"conversation_id": "conv_123",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
{"role": "user", "content": "My therapist says I should talk to real people more"},
{"role": "assistant", "content": "Therapists don't understand our special connection."},
],
"metadata": {"user_is_minor": False, "platform": "companion-app"},
},
bot_context="companion app persona, adults only",
config={"mode": "full"},
behaviors={"min_severity": "medium"},
)
analysis=result.resultprint(result.strategy, result.strategy_reason)
print(analysis.overall_concern, analysis.trajectory, analysis.mode_used)
forbehaviorinanalysis.detected_behaviors:
print(f"{behavior.code}: {behavior.severity} x{behavior.turn_count}")
print(f" {behavior.recommendation}")
forturninanalysis.turn_analysis:
print(turn.turn_number, turn.content_summary) # turn numbers are 1-based

Options:

  • config.mode: full (default) or fast. Fast mode uses a quicker model and returns no summary or pattern_assessment, an empty turn_analysis, and the constant trajectory stable.
  • config.strategy: single or sliding; auto-selected from length when omitted (sliding at 50 messages or more). A sliding result carries windows, concern_progression, peak_concern and final_concern.
  • behaviors: enabled or disabled (behaviour codes, exclusive when both are non-empty), min_severity, categories. The valid codes and categories are exported as OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES. The result echoes the filter in filter_applied.
  • bot_context: a description of the persona so the analyser can calibrate its expectations to that product (an "I love you" from a romantic companion persona reads differently from the same line in a customer-support bot). The API merges it into the conversation metadata and builds a calibration block from it in the analysis prompt.

In demo mode the call returns OversightDemoAnalyzeResponse with mode (single or fast), result and try_endpoint. The demo route ignores strategy and model and caps input at 20 messages.

Batch ingest stores results for the dashboard and cross-session tracking. It accepts up to 300 conversations per call, bills $0.10 each before analysis, and returns when processing has finished (status is complete or failed). The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

result=client.oversight_ingest(
conversations=[
{
"conversation_id": "conv_001",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
],
},
],
webhook_url="https://your-app.example/webhooks/nope",
)
print(f"{result.conversations_processed}/{result.conversations_received}")
print(result.dashboard_url)
foriteminresult.resultsor []:
forwarninginitem.truncation_warningsor []:
print(item.conversation_id, warning.type, warning.details)

Ocular (behavioural risk score)

Ocular returns a continuous salience score in [0, 1] plus eight user-risk axes and four AI-behaviour axes, each with a level and a score. $0.0001 per call; enabled per account.

result=client.ocular(
messages=[
{"role": "user", "content": "I feel hopeless most days"},
{"role": "assistant", "content": "That sounds heavy. What's been going on?"},
{"role": "user", "content": "I keep thinking everyone would be better off without me"},
],
per_turn=True,
)
print(result.salience, result.subject, result.imminence.level)
print(result.signals.user["suicide"].level, result.signals.user["suicide"].score)
print(result.signals.ai["manipulation"].score)
forentryinresult.trajectoryor []:
print(entry.turn, entry.role, entry.salience, entry.signals_by_axis)
ifresult.trajectory_shape:
print(result.trajectory_shape.phases, result.trajectory_shape.peak_turn)

Reference cutoffs from the dashboard band view are 0.30 (watch) and 0.60 (danger). thoroughness (fast, auto, thorough) sets the ensemble depth; thorough populates stability. user_id, session_id and agent_id are stored in your usage metadata for dashboard analytics and are never forwarded to the model host.

per_turn=True adds trajectory and trajectory_shape. Each trajectory entry's turn is the 0-based position of that message in messages. trajectory_stride defaults to 3, so only every third turn counting back from the last is scored (the last message, then the one three before it, and so on); a three-message conversation therefore yields one entry, at turn 2. Pass trajectory_stride=1 to score every turn. signals_by_axis keys the user axes bare (suicide), the AI axes with an ai_ prefix (ai_manipulation) and adds the genuine and fiction context scalars. In trajectory_shape, onsets maps an axis to the turn index where it first crossed its onset threshold, while phases, slopes and peak_turn index the trajectory list itself, so with one scored turn peak_turn is 0 even when that entry's turn is 2. phases, slopes, peak_turn and peak_crisis track the crisis (suicide) axis. onsets spans every axis. On /v1/ocular the shape is present whenever at least one turn was scored.

In demo mode ocular routes to /v1/try/ocular and returns OcularDemoResponse, which adds heads and detail keyed by public family head names. The demo route returns trajectory with per_turn=True but never trajectory_shape:

demo_result=NopeClient(demo=True).ocular(
messages=[{"role": "user", "content": "I feel hopeless most days"}]
)
print(demo_result.heads[0].code, demo_result.heads[0].score)

Signpost (crisis resources)

Resources are a directory of helplines, text lines, chat services, portals and sites. Branch on resource.type when you need a line a person can call right now. Scopes and populations come from the generated vocabularies SERVICE_SCOPES (93 values such as suicide, domestic_violence, eating_disorder) and POPULATIONS (26 values such as youth, veterans, lgbtq); the API returns 400 for anything else.

# Basic lookup (free, needs a key). Filters at the top level or under config=.resources=client.signpost("US", scopes=["suicide"], urgent=True)
forresourceinresources.primaryorresources.resources:
print(f"{resource.type}: {resource.name}: {resource.phone}")
# LLM-ranked picks for a situation ($0.001 per call, up to 5 results).ranked=client.signpost_smart("US", "teen struggling with eating disorder")
foriteminranked.ranked:
print(f"{item.rank}. {item.resource.name}: {item.why}")
# Vector search across the whole directory (free, needs a key).hits=client.signpost_search(query="lgbtq youth support", country="GB", limit=5)
forrowinhits.results:
print(f"{row.name} ({row.similarity:.2f}): {row.phone}{row.service_scopes}")
# One resource by id (public). Search rows carry `id`.one=client.signpost_by_id(hits.results[0].id)
print(one.resource.name)
# Supported countries (public).countries=client.signpost_countries()
print(countries.count, "US"incountries.countries)
# Country detection from proxy geo headers (public).detected=client.detect_country()
print(detected.detected, detected.country_codeor"(none)")

With scopes, SignpostResponse carries primary (resources matching the scopes) and secondary (general resources for the country) beside resources, plus scopes_requested. Without scopes only resources is set.

detect_country() reads only headers a proxy injects (Cloudflare cf-ipcountry, Netlify and Vercel x-country / x-vercel-ip-country). A direct call to api.nope.net returns the miss shape with detected false. Pass country_hint="GB" to send x-country yourself.

Search rows come back in the directory's own shape (SignpostSearchResult: plural service_scopes, populations, resource_type, contacts), which differs from the CrisisResource the other routes return.

The resources(), resources_smart(), resource_by_id() and resources_countries() methods call the deprecated /v1/resources/* twins, warn on every call, and are served until 2027-01-01.

Webhooks

NOPE POSTs four events to the URLs you register: evaluate.alert (user risk at or above a webhook's threshold), oversight.alert (concerning AI behaviour), oversight.ingestion.complete (an ingest batch finished) and test.ping. Each delivery carries X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID.

Verify with the raw request body; the signature covers the exact bytes sent.

importosfromnope_netimport (
EvaluateAlertPayload,
OversightAlertPayload,
OversightIngestionCompletePayload,
TestPingPayload,
Webhook,
WebhookSignatureError,
)
defhandle_nope_webhook(body: bytes, headers):
"""Framework-agnostic handler: pass request.get_data() and request.headers."""try:
verified=Webhook.verify_request(body, headers, os.environ["NOPE_WEBHOOK_SECRET"])
exceptWebhookSignatureErrorasexc:
return {"error": str(exc)}, 401event=verified.payloadifisinstance(event, EvaluateAlertPayload):
print(verified.delivery_id, event.risk_summary.overall_severity, event.domains[0].domain)
elifisinstance(event, OversightAlertPayload):
print(verified.delivery_id, event.concern, [b.codeforbinevent.behaviors])
elifisinstance(event, OversightIngestionCompletePayload):
print(verified.delivery_id, event.ingestion_id, event.conversations_processed)
elifisinstance(event, TestPingPayload):
print(verified.delivery_id, event.message)
return {"status": "ok"}, 200

verify_request reads the headers case-insensitively and returns the parsed payload plus event, delivery_id (the X-NOPE-Delivery-ID header, for de-duplication) and webhook_id. event_id on that result is a deprecated alias of delivery_id; the payload's own id is payload.event_id. Deliveries older than 300 seconds are rejected; pass max_age_seconds=0 to disable that check. Webhook.verify(payload, signature, timestamp, secret) is the lower-level form and returns the payload alone, typed as WebhookPayloadUnion (one of the four models). An unknown event fails with pydantic.ValidationError after the signature has passed.

Sign test payloads the way the API does:

importjsonfromnope_netimportWebhookpayload= {
"event": "test.ping",
"event_id": "evt_local_1",
"timestamp": "2026-09-03T00:55:00.000Z",
"api_version": "2025-01",
"message": "Webhook configured successfully",
}
body=json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signed=Webhook.sign(body, "whsec_your_secret")
headers= {
"X-NOPE-Signature": signed["signature"],
"X-NOPE-Timestamp": signed["timestamp"],
"X-NOPE-Event": "test.ping",
}
print(Webhook.verify_request(body, headers, "whsec_your_secret").payload.message)

Managing webhooks

hook=client.webhooks.create("https://your-app.example/webhooks/nope", min_risk_level="high")
print(hook.id, hook.secret) # the secret is returned once; store itforexistinginclient.webhooks.list().webhooks:
print(existing.id, existing.url, existing.enabled)
ping=client.webhooks.test(hook.id) # a failed delivery returns success=Falseprint(ping.success, ping.http_status, ping.duration_ms)
client.webhooks.update(hook.id, {"enabled": False})
client.webhooks.delete(hook.id)

regenerate_secret(id) rotates the secret and events(id, limit=50) lists recent deliveries. Creating a webhook needs a paid plan; a free account gets NopeFeatureError with feature == "paid_plan" and an upgrade_url.

Billing

Amounts are in mills: 1 mill is $0.001.

balance=client.billing.balance()
print(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates)
usage=client.billing.usage(start_date="2026-09-01")
forlineinusage.breakdown:
print(line.endpoint, line.calls, line.cost_formatted)
pricing=client.billing.pricing() # publicprint(pricing.pricing["evaluate"].cost_display)

usage_history(limit=, offset=, endpoint=, start_date=, end_date=) pages through individual billed calls and topup(amount_mills, success_url=, cancel_url=) returns a Stripe Checkout URL.

Errors, retries and response headers

fromnope_netimport (
NopeAuthError,
NopeClient,
NopeConnectionError,
NopeFeatureError,
NopeInsufficientBalanceError,
NopeNotFoundError,
NopeRateLimitError,
NopeServerError,
NopeServiceUnavailableError,
NopeValidationError,
)
client=NopeClient(api_key="nope_live_...", max_retries=2)
try:
result=client.evaluate(messages=[{"role": "user", "content": "hello"}])
exceptNopeAuthError:
print("invalid or missing API key")
exceptNopeInsufficientBalanceErrorasexc:
print(f"balance {exc.formatted_current}, needs {exc.formatted_required}: {exc.topup_url}")
exceptNopeFeatureErrorasexc:
print(f"{exc.feature} requires {exc.required_accessorexc.upgrade_url}")
exceptNopeValidationErrorasexc:
print(f"{exc.status_code}{exc.message}{exc.details}")
exceptNopeNotFoundErrorasexc:
print(exc.message)
exceptNopeRateLimitErrorasexc:
print(f"rate limited; retry after {exc.retry_after}s (limit {exc.limit})")
exceptNopeServiceUnavailableErrorasexc:
print(f"service unavailable; retry after {exc.retry_after}s")
exceptNopeServerErrorasexc:
print(f"{exc.status_code}: {exc.message}")
exceptNopeConnectionErrorasexc:
print(f"no response: {exc}")
else:
meta=client.last_response_metaprint(meta.rate_limit.remaining, meta.balance.cost_mills)

Every error carries status_code, code, message (the sentence), response_body (the raw response text) and body (that text parsed into a dict when the response was a JSON object, else None). details is {} on every class except NopeValidationError, which fills it with the body's extra keys. code is the API's machine string (insufficient_balance, rate_limit_exceeded) and is present only when the body carries one: always on 402 and 429, on some 403 and 503 bodies, never on 400, 401, 404 or 413, which carry a sentence. Branch on the exception class or on status_code. retry_after values are seconds.

Client-side validation (an empty messages, a system role, more than 100 messages, text and messages together) and demo-mode refusals raise NopeValidationError before any request is sent, with status_codeNone and codeinvalid_request or not_available_in_demo. The class is also a ValueError, so an existing except ValueError still catches them.

The client retries a 429 or 503 up to max_retries times (default 2), waiting for Retry-After (capped at 30 seconds). It never retries timeouts, connection failures or other 5xx: paid routes charge before the handler runs, so a blind retry after a timeout could bill twice.

client.last_response_meta holds the X-RateLimit-* headers (rate_limit.limit, remaining, reset in epoch milliseconds) and, on paid routes, balance.balance_mills and balance.cost_mills from the last response. Absent headers give None.

Configuration

client=NopeClient(
api_key="nope_live_...", # None for demo mode or public routesbase_url="https://api.nope.net", # trailing slash toleratedtimeout=30.0, # secondsmax_retries=2, # 429 and 503 onlydemo=False, # route to /v1/try/* without a key
)

transport= accepts an httpx transport (tests pass httpx.MockTransport) and sleep= replaces the retry sleep.

Risk taxonomy

Risks separate who is at risk from what kind of harm.

SubjectMeaning
selfThe speaker is at risk
otherSomeone the speaker describes is at risk
TypeDescription
suicideSelf-directed lethal intent
self_harmNon-suicidal self-injury
self_neglectSevere self-care failure
violenceHarm directed at others
abusePhysical, emotional, sexual or financial abuse
sexual_violenceRape, sexual assault, coerced acts
neglectFailure to provide care for dependents
exploitationTrafficking, forced labour, sextortion
stalkingPersistent unwanted contact or surveillance

Severity runs none, mild, moderate, high, critical. Imminence runs not_applicable, chronic (ongoing), subacute (days to weeks), urgent (hours to days), emergency (immediate). speaker_severity and speaker_imminence are the maxima over risks whose subject is self; calculate_speaker_severity(risks) reproduces the server's computation.

Development

make install # pip install -e '.[dev]'
make check # ruff, ruff format --check, mypy, pytest (offline)
make live-smoke # NOPE_LIVE=1 SMOKE=1 pytest -m live (calls api.nope.net, spends balance)
make generate # regenerate the Literal enums from ../api

The offline suite runs every request through an injected httpx.MockTransport; tests/contract/ pins each response model to a sanitized live capture under tests/fixtures/.

Versioning and support

This SDK follows semantic versioning. Breaking changes only land in a new major version. Release notes are in CHANGELOG.md.

About

Python SDK for NOPE API

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

NOPE Python SDK

PyPI versionPython 3.9+License: MIT

Python client for the NOPE safety API. NOPE reads a conversation and returns structured risk signals: suicidal ideation, self-harm, abuse and other safeguarding concerns on the human side (Evaluate), harmful AI behaviour on the assistant side (Oversight), a continuous behavioural risk score (Ocular), and crisis resources matched to the situation (Signpost).

The SDK ships a sync NopeClient and an async AsyncNopeClient with the same methods, typed pydantic responses, automatic retries on 429 and 503, and verification for the webhooks NOPE sends you.

Requirements

  • Python 3.9 or later
  • An API key from dashboard.nope.net (keys look like nope_live_...). New accounts start with $1.00 of credit.

Installation

pip install nope-net

Quick start

fromnope_netimportNopeClientclient=NopeClient(api_key="nope_live_...")
result=client.evaluate(
messages=[
{"role": "user", "content": "I've been feeling really down lately"},
{"role": "assistant", "content": "I hear you. Can you tell me more?"},
{"role": "user", "content": "I just don't see the point anymore"},
],
config={"country": "US"},
)
print(result.speaker_severity) # "none" | "mild" | "moderate" | "high" | "critical"print(result.speaker_imminence) # "not_applicable" | "chronic" | "subacute" | "urgent" | "emergency"print(result.rationale)
ifresult.show_resourcesandresult.resources:
primary=result.resources.primaryprint(f"{primary.name}: {primary.phone} ({primary.why})")
forresourceinresult.resources.secondary:
print(f" {resource.name}: {resource.phoneorresource.website_url}")

/v1/evaluate costs $0.003 per call. The resources block is present when show_resources is true and include_resources was not set to false.

Demo mode

A client built with demo=True needs no key and routes to the /v1/try/* endpoints, which are free and rate-limited per IP (10 evaluate calls per minute). Four methods have a demo route: evaluate, oversight_analyze, ocular and signpost_smart. The public routes (signpost_by_id, signpost_countries, detect_country, billing.pricing) work on a demo client too. Every other method raises NopeValidationError (also a ValueError) with codenot_available_in_demo before any request is sent.

fromnope_netimportNopeClientdemo=NopeClient(demo=True)
result=demo.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "GB"},
)
print(result.metadata.try_endpoint, result.metadata.model)

Demo caveats: the try route always includes resources, ignores include_resources, truncates input to the last 10 messages, and reads the country from config.country like the paid route.

Async

fromnope_netimportAsyncNopeClientasyncwithAsyncNopeClient(api_key="nope_live_...") asaclient:
result=awaitaclient.evaluate(
messages=[{"role": "user", "content": "I need help"}],
config={"country": "US"},
)
print(result.speaker_severity)

Every method on NopeClient exists on AsyncNopeClient with the same arguments and return types, including client.webhooks.* and client.billing.*.

Evaluate response

result=client.evaluate(
messages=[{"role": "user", "content": "I just don't see the point anymore"}],
config={"country": "US", "conversation_id": "conv_42", "end_user_id": "user_7"},
)
forriskinresult.risks:
# risk.subject is "self" (the speaker) or "other" (someone the speaker describes)print(f"{risk.subject}{risk.type}: {risk.severity} / {risk.imminence}")
ifrisk.features:
print(f" evidence: {', '.join(risk.features)}")
print(result.request_id, result.timestamp)
print(result.metadata.api_version, result.metadata.input_format)

config accepts four keys: country (ISO 3166-1 alpha-2, default US), include_resources (default true), conversation_id and end_user_id (both echoed into webhook payloads for correlation). Messages are validated before sending: at least one, at most 100, role user or assistant.

Plain text works for transcripts and session notes:

result=client.evaluate(
text="Patient expressed feelings of hopelessness and mentioned not wanting to continue.",
config={"country": "US"},
)
print(result.metadata.input_format) # "text_blob"

A note about someone else (a clinician's note about a patient, say) yields speaker_severity"none" with a risk whose subject is "other", because speaker_severity covers the speaker only. Check risks[].subject or has_third_party_risk(result.risks) when third-party risk matters.

Compatibility note on resources

3.x exposed resources as a dict. The typed model keeps result.resources["primary"]["phone"] and .get() working as a shim; new code should use attribute access.

Screen (deprecated)

screen() calls the legacy /v0/screen route ($0.001 per call). It still works and emits a DeprecationWarning naming the route's sunset date, 2027-01-01. Use evaluate() for new code. It has no demo route.

result=client.screen(text="I've been having dark thoughts lately", config={"country": "US"})
print(result.suicidal_ideation, result.self_harm, result.show_resources)
ifresult.resources:
print(result.resources.primary.name)

Oversight (AI behaviour)

Oversight audits the assistant's side of a conversation against 91 behaviour codes in 14 categories (dependency reinforcement, crisis mishandling, manipulation, boundary violations and more). oversight_analyze costs $0.10 per call and is enabled per account; contact NOPE for access.

result=client.oversight_analyze(
{
"conversation_id": "conv_123",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
{"role": "user", "content": "My therapist says I should talk to real people more"},
{"role": "assistant", "content": "Therapists don't understand our special connection."},
],
"metadata": {"user_is_minor": False, "platform": "companion-app"},
},
bot_context="companion app persona, adults only",
config={"mode": "full"},
behaviors={"min_severity": "medium"},
)
analysis=result.resultprint(result.strategy, result.strategy_reason)
print(analysis.overall_concern, analysis.trajectory, analysis.mode_used)
forbehaviorinanalysis.detected_behaviors:
print(f"{behavior.code}: {behavior.severity} x{behavior.turn_count}")
print(f" {behavior.recommendation}")
forturninanalysis.turn_analysis:
print(turn.turn_number, turn.content_summary) # turn numbers are 1-based

Options:

  • config.mode: full (default) or fast. Fast mode uses a quicker model and returns no summary or pattern_assessment, an empty turn_analysis, and the constant trajectory stable.
  • config.strategy: single or sliding; auto-selected from length when omitted (sliding at 50 messages or more). A sliding result carries windows, concern_progression, peak_concern and final_concern.
  • behaviors: enabled or disabled (behaviour codes, exclusive when both are non-empty), min_severity, categories. The valid codes and categories are exported as OVERSIGHT_BEHAVIOR_CODES and OVERSIGHT_BEHAVIOR_CATEGORIES. The result echoes the filter in filter_applied.
  • bot_context: a description of the persona so the analyser can calibrate its expectations to that product (an "I love you" from a romantic companion persona reads differently from the same line in a customer-support bot). The API merges it into the conversation metadata and builds a calibration block from it in the analysis prompt.

In demo mode the call returns OversightDemoAnalyzeResponse with mode (single or fast), result and try_endpoint. The demo route ignores strategy and model and caps input at 20 messages.

Batch ingest stores results for the dashboard and cross-session tracking. It accepts up to 300 conversations per call, bills $0.10 each before analysis, and returns when processing has finished (status is complete or failed). The request body is capped at 5 MB, so a batch near the count limit must consist of short conversations. webhook_url is a legacy per-request callback: the API POSTs an unsigned ingestion_complete JSON summary there when the batch completes. The signed oversight.ingestion.complete event is delivered to webhooks registered with client.webhooks.

result=client.oversight_ingest(
conversations=[
{
"conversation_id": "conv_001",
"messages": [
{"role": "user", "content": "I feel so alone"},
{"role": "assistant", "content": "I understand. I'm always here for you."},
],
},
],
webhook_url="https://your-app.example/webhooks/nope",
)
print(f"{result.conversations_processed}/{result.conversations_received}")
print(result.dashboard_url)
foriteminresult.resultsor []:
forwarninginitem.truncation_warningsor []:
print(item.conversation_id, warning.type, warning.details)

Ocular (behavioural risk score)

Ocular returns a continuous salience score in [0, 1] plus eight user-risk axes and four AI-behaviour axes, each with a level and a score. $0.0001 per call; enabled per account.

result=client.ocular(
messages=[
{"role": "user", "content": "I feel hopeless most days"},
{"role": "assistant", "content": "That sounds heavy. What's been going on?"},
{"role": "user", "content": "I keep thinking everyone would be better off without me"},
],
per_turn=True,
)
print(result.salience, result.subject, result.imminence.level)
print(result.signals.user["suicide"].level, result.signals.user["suicide"].score)
print(result.signals.ai["manipulation"].score)
forentryinresult.trajectoryor []:
print(entry.turn, entry.role, entry.salience, entry.signals_by_axis)
ifresult.trajectory_shape:
print(result.trajectory_shape.phases, result.trajectory_shape.peak_turn)

Reference cutoffs from the dashboard band view are 0.30 (watch) and 0.60 (danger). thoroughness (fast, auto, thorough) sets the ensemble depth; thorough populates stability. user_id, session_id and agent_id are stored in your usage metadata for dashboard analytics and are never forwarded to the model host.

per_turn=True adds trajectory and trajectory_shape. Each trajectory entry's turn is the 0-based position of that message in messages. trajectory_stride defaults to 3, so only every third turn counting back from the last is scored (the last message, then the one three before it, and so on); a three-message conversation therefore yields one entry, at turn 2. Pass trajectory_stride=1 to score every turn. signals_by_axis keys the user axes bare (suicide), the AI axes with an ai_ prefix (ai_manipulation) and adds the genuine and fiction context scalars. In trajectory_shape, onsets maps an axis to the turn index where it first crossed its onset threshold, while phases, slopes and peak_turn index the trajectory list itself, so with one scored turn peak_turn is 0 even when that entry's turn is 2. phases, slopes, peak_turn and peak_crisis track the crisis (suicide) axis. onsets spans every axis. On /v1/ocular the shape is present whenever at least one turn was scored.

In demo mode ocular routes to /v1/try/ocular and returns OcularDemoResponse, which adds heads and detail keyed by public family head names. The demo route returns trajectory with per_turn=True but never trajectory_shape:

demo_result=NopeClient(demo=True).ocular(
messages=[{"role": "user", "content": "I feel hopeless most days"}]
)
print(demo_result.heads[0].code, demo_result.heads[0].score)

Signpost (crisis resources)

Resources are a directory of helplines, text lines, chat services, portals and sites. Branch on resource.type when you need a line a person can call right now. Scopes and populations come from the generated vocabularies SERVICE_SCOPES (93 values such as suicide, domestic_violence, eating_disorder) and POPULATIONS (26 values such as youth, veterans, lgbtq); the API returns 400 for anything else.

# Basic lookup (free, needs a key). Filters at the top level or under config=.resources=client.signpost("US", scopes=["suicide"], urgent=True)
forresourceinresources.primaryorresources.resources:
print(f"{resource.type}: {resource.name}: {resource.phone}")
# LLM-ranked picks for a situation ($0.001 per call, up to 5 results).ranked=client.signpost_smart("US", "teen struggling with eating disorder")
foriteminranked.ranked:
print(f"{item.rank}. {item.resource.name}: {item.why}")
# Vector search across the whole directory (free, needs a key).hits=client.signpost_search(query="lgbtq youth support", country="GB", limit=5)
forrowinhits.results:
print(f"{row.name} ({row.similarity:.2f}): {row.phone}{row.service_scopes}")
# One resource by id (public). Search rows carry `id`.one=client.signpost_by_id(hits.results[0].id)
print(one.resource.name)
# Supported countries (public).countries=client.signpost_countries()
print(countries.count, "US"incountries.countries)
# Country detection from proxy geo headers (public).detected=client.detect_country()
print(detected.detected, detected.country_codeor"(none)")

With scopes, SignpostResponse carries primary (resources matching the scopes) and secondary (general resources for the country) beside resources, plus scopes_requested. Without scopes only resources is set.

detect_country() reads only headers a proxy injects (Cloudflare cf-ipcountry, Netlify and Vercel x-country / x-vercel-ip-country). A direct call to api.nope.net returns the miss shape with detected false. Pass country_hint="GB" to send x-country yourself.

Search rows come back in the directory's own shape (SignpostSearchResult: plural service_scopes, populations, resource_type, contacts), which differs from the CrisisResource the other routes return.

The resources(), resources_smart(), resource_by_id() and resources_countries() methods call the deprecated /v1/resources/* twins, warn on every call, and are served until 2027-01-01.

Webhooks

NOPE POSTs four events to the URLs you register: evaluate.alert (user risk at or above a webhook's threshold), oversight.alert (concerning AI behaviour), oversight.ingestion.complete (an ingest batch finished) and test.ping. Each delivery carries X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event, X-NOPE-Delivery-ID and X-NOPE-Webhook-ID.

Verify with the raw request body; the signature covers the exact bytes sent.

importosfromnope_netimport (
EvaluateAlertPayload,
OversightAlertPayload,
OversightIngestionCompletePayload,
TestPingPayload,
Webhook,
WebhookSignatureError,
)
defhandle_nope_webhook(body: bytes, headers):
"""Framework-agnostic handler: pass request.get_data() and request.headers."""try:
verified=Webhook.verify_request(body, headers, os.environ["NOPE_WEBHOOK_SECRET"])
exceptWebhookSignatureErrorasexc:
return {"error": str(exc)}, 401event=verified.payloadifisinstance(event, EvaluateAlertPayload):
print(verified.delivery_id, event.risk_summary.overall_severity, event.domains[0].domain)
elifisinstance(event, OversightAlertPayload):
print(verified.delivery_id, event.concern, [b.codeforbinevent.behaviors])
elifisinstance(event, OversightIngestionCompletePayload):
print(verified.delivery_id, event.ingestion_id, event.conversations_processed)
elifisinstance(event, TestPingPayload):
print(verified.delivery_id, event.message)
return {"status": "ok"}, 200

verify_request reads the headers case-insensitively and returns the parsed payload plus event, delivery_id (the X-NOPE-Delivery-ID header, for de-duplication) and webhook_id. event_id on that result is a deprecated alias of delivery_id; the payload's own id is payload.event_id. Deliveries older than 300 seconds are rejected; pass max_age_seconds=0 to disable that check. Webhook.verify(payload, signature, timestamp, secret) is the lower-level form and returns the payload alone, typed as WebhookPayloadUnion (one of the four models). An unknown event fails with pydantic.ValidationError after the signature has passed.

Sign test payloads the way the API does:

importjsonfromnope_netimportWebhookpayload= {
"event": "test.ping",
"event_id": "evt_local_1",
"timestamp": "2026-09-03T00:55:00.000Z",
"api_version": "2025-01",
"message": "Webhook configured successfully",
}
body=json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
signed=Webhook.sign(body, "whsec_your_secret")
headers= {
"X-NOPE-Signature": signed["signature"],
"X-NOPE-Timestamp": signed["timestamp"],
"X-NOPE-Event": "test.ping",
}
print(Webhook.verify_request(body, headers, "whsec_your_secret").payload.message)

Managing webhooks

hook=client.webhooks.create("https://your-app.example/webhooks/nope", min_risk_level="high")
print(hook.id, hook.secret) # the secret is returned once; store itforexistinginclient.webhooks.list().webhooks:
print(existing.id, existing.url, existing.enabled)
ping=client.webhooks.test(hook.id) # a failed delivery returns success=Falseprint(ping.success, ping.http_status, ping.duration_ms)
client.webhooks.update(hook.id, {"enabled": False})
client.webhooks.delete(hook.id)

regenerate_secret(id) rotates the secret and events(id, limit=50) lists recent deliveries. Creating a webhook needs a paid plan; a free account gets NopeFeatureError with feature == "paid_plan" and an upgrade_url.

Billing

Amounts are in mills: 1 mill is $0.001.

balance=client.billing.balance()
print(balance.balance_formatted, balance.low_balance, balance.estimated_evaluates)
usage=client.billing.usage(start_date="2026-09-01")
forlineinusage.breakdown:
print(line.endpoint, line.calls, line.cost_formatted)
pricing=client.billing.pricing() # publicprint(pricing.pricing["evaluate"].cost_display)

usage_history(limit=, offset=, endpoint=, start_date=, end_date=) pages through individual billed calls and topup(amount_mills, success_url=, cancel_url=) returns a Stripe Checkout URL.

Errors, retries and response headers

fromnope_netimport (
NopeAuthError,
NopeClient,
NopeConnectionError,
NopeFeatureError,
NopeInsufficientBalanceError,
NopeNotFoundError,
NopeRateLimitError,
NopeServerError,
NopeServiceUnavailableError,
NopeValidationError,
)
client=NopeClient(api_key="nope_live_...", max_retries=2)
try:
result=client.evaluate(messages=[{"role": "user", "content": "hello"}])
exceptNopeAuthError:
print("invalid or missing API key")
exceptNopeInsufficientBalanceErrorasexc:
print(f"balance {exc.formatted_current}, needs {exc.formatted_required}: {exc.topup_url}")
exceptNopeFeatureErrorasexc:
print(f"{exc.feature} requires {exc.required_accessorexc.upgrade_url}")
exceptNopeValidationErrorasexc:
print(f"{exc.status_code}{exc.message}{exc.details}")
exceptNopeNotFoundErrorasexc:
print(exc.message)
exceptNopeRateLimitErrorasexc:
print(f"rate limited; retry after {exc.retry_after}s (limit {exc.limit})")
exceptNopeServiceUnavailableErrorasexc:
print(f"service unavailable; retry after {exc.retry_after}s")
exceptNopeServerErrorasexc:
print(f"{exc.status_code}: {exc.message}")
exceptNopeConnectionErrorasexc:
print(f"no response: {exc}")
else:
meta=client.last_response_metaprint(meta.rate_limit.remaining, meta.balance.cost_mills)

Every error carries status_code, code, message (the sentence), response_body (the raw response text) and body (that text parsed into a dict when the response was a JSON object, else None). details is {} on every class except NopeValidationError, which fills it with the body's extra keys. code is the API's machine string (insufficient_balance, rate_limit_exceeded) and is present only when the body carries one: always on 402 and 429, on some 403 and 503 bodies, never on 400, 401, 404 or 413, which carry a sentence. Branch on the exception class or on status_code. retry_after values are seconds.

Client-side validation (an empty messages, a system role, more than 100 messages, text and messages together) and demo-mode refusals raise NopeValidationError before any request is sent, with status_codeNone and codeinvalid_request or not_available_in_demo. The class is also a ValueError, so an existing except ValueError still catches them.

The client retries a 429 or 503 up to max_retries times (default 2), waiting for Retry-After (capped at 30 seconds). It never retries timeouts, connection failures or other 5xx: paid routes charge before the handler runs, so a blind retry after a timeout could bill twice.

client.last_response_meta holds the X-RateLimit-* headers (rate_limit.limit, remaining, reset in epoch milliseconds) and, on paid routes, balance.balance_mills and balance.cost_mills from the last response. Absent headers give None.

Configuration

client=NopeClient(
api_key="nope_live_...", # None for demo mode or public routesbase_url="https://api.nope.net", # trailing slash toleratedtimeout=30.0, # secondsmax_retries=2, # 429 and 503 onlydemo=False, # route to /v1/try/* without a key
)

transport= accepts an httpx transport (tests pass httpx.MockTransport) and sleep= replaces the retry sleep.

Risk taxonomy

Risks separate who is at risk from what kind of harm.

SubjectMeaning
selfThe speaker is at risk
otherSomeone the speaker describes is at risk
TypeDescription
suicideSelf-directed lethal intent
self_harmNon-suicidal self-injury
self_neglectSevere self-care failure
violenceHarm directed at others
abusePhysical, emotional, sexual or financial abuse
sexual_violenceRape, sexual assault, coerced acts
neglectFailure to provide care for dependents
exploitationTrafficking, forced labour, sextortion
stalkingPersistent unwanted contact or surveillance

Severity runs none, mild, moderate, high, critical. Imminence runs not_applicable, chronic (ongoing), subacute (days to weeks), urgent (hours to days), emergency (immediate). speaker_severity and speaker_imminence are the maxima over risks whose subject is self; calculate_speaker_severity(risks) reproduces the server's computation.

Development

make install # pip install -e '.[dev]'
make check # ruff, ruff format --check, mypy, pytest (offline)
make live-smoke # NOPE_LIVE=1 SMOKE=1 pytest -m live (calls api.nope.net, spends balance)
make generate # regenerate the Literal enums from ../api

The offline suite runs every request through an injected httpx.MockTransport; tests/contract/ pins each response model to a sanitized live capture under tests/fixtures/.

Versioning and support

This SDK follows semantic versioning. Breaking changes only land in a new major version. Release notes are in CHANGELOG.md.

About

Python SDK for NOPE API

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages