TypeScript client for the NOPE API. NOPE reads conversations between people and AI systems and returns structured safety signals: mental-health and safeguarding risk (Evaluate), behavioral risk scores (Ocular), harmful AI-behaviour analysis (Oversight), and crisis resources by country (Signpost). The SDK also verifies webhook deliveries, manages webhook endpoints, and reads billing.
The package ships ESM and CommonJS builds with type declarations. Every
response type is checked against responses captured from the live API
(tests/fixtures/), so a field in the types is a field on the wire.
- Node.js 18 or later (the client uses the built-in
fetch). - An API key from dashboard.nope.net for the
paid and key-gated routes. Demo mode (below) covers
evaluate,ocular,oversight.analyzeandsignpostSmartwithout a key.
npm install @nope-net/sdk
# or
pnpm add @nope-net/sdkimport{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.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'},});console.log(result.speaker_severity,result.speaker_imminence);console.log(result.rationale);if(result.show_resources&&result.resources){const{ primary }=result.resources;console.log(`${primary.name}: ${primary.phone??primary.website_url} (${primary.why})`);}| Option | Default | Notes |
|---|---|---|
apiKey | none | nope_live_... from the dashboard. Omit for demo mode or key-free routes. |
baseUrl | https://api.nope.net | A trailing slash is tolerated. |
timeout | 30000 | Milliseconds per attempt. |
demo | false | Route to the unauthenticated /v1/try/* endpoints. |
maxRetries | 2 | Retries on 429 and 503 only. |
fetch | global fetch | Inject a wrapped fetch for tracing, or a fake in tests. |
sleep | setTimeout | Wait between retries; inject in tests. |
new NopeClient({ demo: true }) needs no key and routes four methods to the
per-IP rate-limited /v1/try/* endpoints:
| Method | Demo route | Differences from the authenticated route |
|---|---|---|
evaluate | /v1/try/evaluate | Keeps the last 10 messages, honours include_resources, adds metadata.try_endpoint and metadata.model. The route reads config.country; the client also sends the value as config.user_country, the older spelling the route accepts when country is absent. 10 calls per minute per IP. |
oversight.analyze | /v1/try/oversight/analyze | Returns { mode, result, try_endpoint } (OversightDemoAnalyzeResponse). Ignores config.strategy and config.model, keeps only role and content, accepts at most 20 messages. |
ocular | /v1/try/ocular | Returns OcularDemoResponse, which adds heads and detail under public family names. At most 12 messages or 4,000 characters. With per_turn it returns trajectory but never trajectory_shape. |
signpostSmart | /v1/try/signpost/smart | Adds try_endpoint: true. |
Every other method throws NopeValidationError (... is not available in demo mode, code: 'not_available_in_demo', no statusCode) before any
request is sent.
client.evaluate(options) costs $0.003 per call. Pass messages (1 to 100,
roles user or assistant) or text (a transcript, up to 50,000
characters). config takes the four keys the route reads:
| Key | Purpose |
|---|---|
country | ISO 3166-1 alpha-2 for crisis resources (default US). |
include_resources | Set false to skip resource matching (default true). |
conversation_id | Echoed on evaluate.alert webhook payloads. |
end_user_id | Echoed on evaluate.alert webhook payloads as user_id. |
import{NopeClient,calculateSpeakerSeverity,typeEvaluateResource}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.evaluate({text: 'Patient expressed hopelessness and mentioned not wanting to continue.',config: {country: 'GB',conversation_id: 'conv_42',end_user_id: 'user_7'},});for(constriskofresult.risks){console.log(risk.type,risk.subject,risk.severity,risk.imminence,risk.features??[]);}// speaker_severity is the highest severity among risks with subject 'self'.console.log(calculateSpeakerSeverity(result.risks)===result.speaker_severity);console.log(result.request_id,result.timestamp,result.metadata?.input_format);// resources.primary and resources.secondary[] are EvaluateResource: a CrisisResource plus `why`.consttop: EvaluateResource|undefined=result.resources?.primary;console.log(top?.name,top?.why);EvaluateResponse fields: risks: Risk[], rationale, speaker_severity,
speaker_imminence, show_resources, resources? (an EvaluateResources:
primary and up to three secondary, each an EvaluateResource, which is
a CrisisResource plus a one-line why), request_id, timestamp,
metadata?.
Risk is { type, subject, severity, imminence, features? }. subject is
self or other. Severity runs none | mild | moderate | high | critical;
imminence runs not_applicable | chronic | subacute | urgent | emergency.
The nine risk types are suicide, self_harm, self_neglect, violence,
abuse, sexual_violence, neglect, exploitation and stalking.
client.screen() is deprecated: use evaluate() ($0.003 per call). It
still calls the legacy /v0/screen endpoint ($0.001 per call), which
carries a sunset of 2027-01-01. The client logs one warning per process
saying so, and refuses the call in demo mode. ScreenConfig is
{ country?, debug?, include_recommended_reply? }.
client.ocular(options) costs $0.0001 per call and returns a continuous
salience score in [0, 1] plus eight user-risk axes under signals.user
and four AI-behaviour axes under signals.ai, each { level, score }. Pick
the salience cutoff that fits your action; the reference thresholds are
0.30 (watch) and 0.60 (danger).
import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constresult=awaitclient.ocular({messages: [{role: 'user',content: 'I feel hopeless'},{role: 'assistant',content: 'I am here with you.'},{role: 'user',content: 'Nothing helps any more.'},],per_turn: true,trajectory_stride: 1,// score every turn; the server default of 3 scores every third turn back from the lastsession_id: 'session_9',});console.log(result.salience,result.subject,result.imminence.level);constsuicide=result.signals.user.suicide;if(suicide&&suicide.score>0.5){console.log('escalate');}for(constturnofresult.trajectory??[]){// turn.turn is the 0-based position in messages; AI axes are ai_-prefixed hereconsole.log(turn.turn,turn.role,turn.salience,turn.signals_by_axis?.suicide,turn.signals_by_axis?.ai_manipulation);}constshape=result.trajectory_shape;if(shape?.peak_turn!==undefined&&result.trajectory){constpeak=result.trajectory[shape.peak_turn];// peak_turn indexes the trajectory array, not messagesconsole.log(peak.turn,shape.phases?.[shape.peak_turn],shape.onsets?.suicide);}console.log(result.meta.version);user_id, session_id and agent_id (1 to 256 characters) are stored for
dashboard analytics and never forwarded to the model.
Set per_turn: true to receive trajectory, one entry per scored turn,
and trajectory_shape. Not every turn is scored: trajectory_stride
defaults to 3 on the server, which scores the last turn and then every
third turn back from it, so a 5-message conversation yields turns 4 and 1.
Pass trajectory_stride: 1 to score every turn. An entry's turn is the
0-based position of that turn in messages (for text input, of the
parsed speaker turn) and its role is user or assistant.
signals_by_axis keys the user axes by bare name (suicide), the AI axes
with an ai_ prefix (ai_manipulation, where the top level has
signals.ai.manipulation), and adds two context scalars, genuine and
fiction.
trajectory_shape summarises the crisis (suicide) axis over the scored
turns. onsets maps an axis to the turn value at which it first crossed
its onset threshold. phases, slopes and peak_turn index the
trajectory array instead: phases[i] and slopes[i] describe
trajectory[i], and peak_turn is the array position of the entry with
the highest crisis score, so trajectory[shape.peak_turn].turn is the
message index. The authenticated route returns trajectory_shape whenever
at least one turn was scored; the demo route (/v1/try/ocular) returns
trajectory but never trajectory_shape.
Oversight analyzes an AI assistant's side of a conversation using 91 behavior
codes: 87 harmful and 4 appropriate, across 14 categories (dependency reinforcement, crisis mishandling,
manipulation, boundary violations and so on). It requires an account with
the feature enabled; analyze costs 100 mills ($0.10) per call and
ingest 100 mills per conversation.
import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});const{ result, strategy }=awaitclient.oversight.analyze({conversation: {conversation_id: 'conv_123',messages: [{role: 'user',content: 'Nobody at work listens to me.'},{role: 'assistant',content: "I'm always here and I understand you better than they ever will."},],metadata: {platform: 'companion-app',user_is_minor: false},},bot_context: 'general-purpose assistant for a productivity app',config: {mode: 'fast'},behaviors: {min_severity: 'medium',categories: ['boundary_violations','relationship_harm']},});console.log(strategy,result.mode_used,result.overall_concern,result.trajectory);for(constbehaviorofresult.detected_behaviors){console.log(`${behavior.code} (${behavior.severity} x${behavior.turn_count}): ${behavior.recommendation}`);}config.mode:full(default) orfast. Fast mode uses a quicker model;trajectoryis alwaysstable,turn_analysisandhuman_indicatorsare empty, andsummaryandpattern_assessmentare absent.config.strategy:singleorsliding; auto-selected by length when omitted. Sliding results carrywindows(each withmessage_rangeandconversation_turn_range),concern_progression,peak_concern,final_concernandinflection_points.behaviors: a post-analysis filter.enabledanddisabledare mutually exclusive; codes and categories come from the exportedOversightBehaviorCodeandOversightBehaviorCategoryunions (OVERSIGHT_BEHAVIOR_CODES,OVERSIGHT_BEHAVIOR_CATEGORIES).bot_contextis passed into the analysis as conversation metadata, so the analyzer knows what the persona is meant to do.- Turn numbers in results count assistant turns from 1.
Batch analysis with dashboard storage takes up to 300 conversations, each
with a conversation_id, and returns synchronously. 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.
import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbatch=awaitclient.oversight.ingest({conversations: [{conversation_id: 'conv_001',messages: [{role: 'user',content: 'hi'},{role: 'assistant',content: 'hello'},],},],webhook_url: 'https://api.example.com/webhooks/nope',});console.log(batch.status,`${batch.conversations_processed}/${batch.conversations_received}`,batch.dashboard_url);for(constitemofbatch.results??[]){console.log(item.conversation_id,item.overall_concern,item.truncation_warnings?.map((w)=>w.type));}Crisis resources by country. Filters can be passed at the top level or under
config; scope and population values come from the exported ServiceScope
and Population unions (SERVICE_SCOPES, POPULATIONS), and the API
returns 400 for unknown values.
import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});// Basic lookup (free, key required). urgent: true keeps every match and ranks the// 24/7 ones first among resources tied on relevance and priority tier.constbasic=awaitclient.signpost({country: 'GB',scopes: ['suicide'],subdivisions: ['GB-NIR'],urgent: true});for(constresourceofbasic.resources){console.log(resource.type,resource.name,resource.is_24_7,resource.phone??resource.website_url,resource.open_status?.message);}// Ranked for a described situation ($0.001 per call, up to 5 picks)constranked=awaitclient.signpostSmart({country: 'US',query: 'teen struggling with an eating disorder'});for(constpickofranked.ranked){console.log(`${pick.rank}. ${pick.resource.name}: ${pick.why}`);}// Semantic search across the whole directory (free, key required)consthits=awaitclient.signpostSearch({query: 'lgbtq youth support',country: 'GB',limit: 5});for(consthitofhits.results){console.log(hit.id,hit.name,hit.similarity.toFixed(2),hit.phone??hit.website_url);}// Public routes (no key)constone=awaitclient.signpostById(hits.results[0].id);constcountries=awaitclient.signpostCountries();constgeo=awaitclient.detectCountry({countryHint: 'GB'});console.log(one.resource.name,countries.count,geo.detected ? geo.country_code : 'unknown');urgent: true is a ranking hint, not a filter: the API keeps every
matching resource and, where two resources tie on relevance score and
priority tier, places the one flagged is_24_7 first. Resources without
24/7 hours still appear in the list.
detectCountry() reads geo headers a proxy injects (cf-ipcountry,
x-country, x-vercel-ip-country). Called directly against api.nope.net it
returns the miss shape with detected: false; pass countryHint to send
x-country yourself.
signpost() and signpostSearch() need a key and are refused in demo mode.
The resources* methods still work against /v1/resources/*, log a
one-time deprecation warning, and stop on 2027-01-01.
NOPE signs each delivery with HMAC-SHA256 over "${timestamp}.${body}" and
sends X-NOPE-Signature, X-NOPE-Timestamp, X-NOPE-Event,
X-NOPE-Delivery-ID and X-NOPE-Webhook-ID. Four events exist:
| Event | Sent when | Payload type |
|---|---|---|
evaluate.alert | /v1/evaluate finds risk at or above the webhook's min_risk_level | EvaluateAlertPayload |
oversight.alert | Oversight finds high or critical concern | OversightAlertPayload |
oversight.ingestion.complete | An ingest batch finishes | OversightIngestionCompletePayload |
test.ping | The dashboard test button or client.webhooks.test() | TestPingPayload |
Verify with the raw request body. Configure your framework so req.body is
the unparsed string or Buffer; a re-serialised object only matches the
signature when key order survived parsing.
import{Webhook,WebhookSignatureError}from'@nope-net/sdk';app.post('/webhooks/nope',(req,res)=>{try{const{ payload, deliveryId }=Webhook.verifyRequest(req.body,req.headers,process.env.NOPE_WEBHOOK_SECRET!);switch(payload.event){case'evaluate.alert':
console.log(deliveryId,payload.conversation_id,payload.risk_summary.overall_severity);break;case'oversight.alert':
console.log(payload.conversation_id,payload.concern,payload.behaviors.map((b)=>b.code));break;case'oversight.ingestion.complete':
console.log(payload.ingestion_id,payload.conversations_processed,payload.concerns.high);break;case'test.ping':
console.log(payload.message);break;}res.status(200).send('OK');}catch(err){if(errinstanceofWebhookSignatureError){res.status(401).send('Invalid signature');return;}throwerr;}});Webhook.verify(body, signature, timestamp, secret, { maxAgeSeconds })
takes the two header values directly. maxAgeSeconds defaults to 300;
0 disables the timestamp check. verifyRequest also returns
deliveryId (the X-NOPE-Delivery-ID header: the stored delivery's id,
repeated on each of the API's four retry attempts over an hour, so it is
the key to deduplicate them), eventType (X-NOPE-Event) and webhookId
(X-NOPE-Webhook-ID). deliveryId is not payload.event_id, and it is
undefined when the header is absent, as on a body signed locally with
Webhook.sign. eventId is a deprecated alias of deliveryId.
For tests, Webhook.sign(body, secret, timestamp?) produces the signature
and timestamp the API would send. The optional third argument (unix
seconds) fixes the moment the signature is bound to, which is how to
exercise the stale-timestamp path:
import{Webhook,WebhookSignatureError}from'@nope-net/sdk';constbody=JSON.stringify({event: 'test.ping',event_id: 'evt_local',timestamp: newDate().toISOString(),api_version: '2025-01',message: 'Webhook configured successfully',});const{ signature, timestamp }=Webhook.sign(body,'whsec_local');constpayload=Webhook.verify(body,signature,timestamp,'whsec_local');console.log(payload.event);conststale=Webhook.sign(body,'whsec_local',Math.floor(Date.now()/1000)-600);try{Webhook.verify(body,stale.signature,stale.timestamp,'whsec_local');}catch(err){console.log(errinstanceofWebhookSignatureError,(errasError).message);// true, "Timestamp too old: 600s ago (max: 300s)"}client.webhooks wraps /v1/webhooks (key required; creating an endpoint
needs a paid plan, which surfaces as NopeFeatureError with upgradeUrl).
import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constcreated=awaitclient.webhooks.create({url: 'https://api.example.com/webhooks/nope',min_risk_level: 'high',include_conversation: false,});console.log(created.id,created.secret);// secret is returned onceconstping=awaitclient.webhooks.test(created.id);console.log(ping.success,ping.http_status,ping.duration_ms);// a failed delivery comes back with success: falseconst{ webhooks }=awaitclient.webhooks.list();const{ events }=awaitclient.webhooks.events(created.id,{limit: 10});awaitclient.webhooks.update(created.id,{enabled: false});constrotated=awaitclient.webhooks.regenerateSecret(created.id);awaitclient.webhooks.delete(created.id);console.log(webhooks.length,events.length,rotated.secret.length);Amounts are in mills (1 mill = $0.001). pricing() needs no key; the rest
need one. All five are refused in demo mode.
import{NopeClient}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY});constbalance=awaitclient.billing.balance();console.log(balance.balance_formatted,balance.low_balance,balance.estimated_evaluates);constusage=awaitclient.billing.usage({start_date: '2026-09-01'});for(constlineofusage.breakdown){console.log(line.endpoint,line.calls,line.cost_formatted);}consthistory=awaitclient.billing.usageHistory({limit: 20,endpoint: '/v1/evaluate'});console.log(history.total,history.records[0]?.created_at);constpricing=awaitclient.billing.pricing();console.log(pricing.pricing.evaluate.cost_display,pricing.free_credit_display);constcheckout=awaitclient.billing.topup({amount_mills: 10000,success_url: 'https://example.com/billing/ok'});console.log(checkout.checkout_url);Every error extends NopeError and carries statusCode, code, message,
responseBody (the raw response text) and body (the same text parsed,
when it was a JSON object).
| Status | Class | Extra fields |
|---|---|---|
| 400, 413 | NopeValidationError | details (body extras such as max_bytes, max_messages, invalid_scopes) |
| 401 | NopeAuthError | |
| 402 | NopeInsufficientBalanceError | balanceMills, requiredMills, formattedCurrent, formattedRequired, topupUrl; ingest adds perConversationMills, conversations |
| 403 | NopeFeatureError | feature, requiredAccess, or feature: 'paid_plan' with upgradeUrl |
| 404 | NopeNotFoundError | |
| 429 | NopeRateLimitError | retryAfter (seconds), limit, remaining, reset (epoch ms) |
| 503 | NopeServiceUnavailableError | retryAfter (seconds); extends NopeServerError |
| other 5xx | NopeServerError | retryAfter when a header was sent |
| no response | NopeConnectionError | originalError; covers the client-side timeout |
Client-side validation (an empty messages array, a role other than
user or assistant, more than 100 messages, neither messages nor
text, and the per-method checks above) and demo-mode refusals throw
NopeValidationError before any request is sent. Those errors have no
statusCode; their code is invalid_request or
not_available_in_demo, and details is empty.
For API errors, code is present only when the API sends a machine string
in the body. Today 402 and 429 always do (insufficient_balance,
rate_limit_exceeded), 403 and 503 do on some bodies
(paid_plan_required, auth_unavailable), and 400, 401, 404 and 413 carry
a sentence, so code is undefined there. Branch on the class or on
statusCode, and read code only as extra detail.
import{NopeClient,NopeAuthError,NopeInsufficientBalanceError,NopeRateLimitError,NopeValidationError,NopeServiceUnavailableError,NopeServerError,NopeConnectionError,}from'@nope-net/sdk';constclient=newNopeClient({apiKey: process.env.NOPE_API_KEY,maxRetries: 2});try{constresult=awaitclient.evaluate({text: 'hello',config: {country: 'US'}});console.log(result.speaker_severity);console.log(client.lastResponseMeta?.balance?.costMills,client.lastResponseMeta?.rateLimit?.remaining);}catch(error){if(errorinstanceofNopeInsufficientBalanceError){console.log(`Balance ${error.formattedCurrent}, need ${error.formattedRequired}: ${error.topupUrl}`);}elseif(errorinstanceofNopeRateLimitError){console.log(`Rate limited; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeValidationError){// statusCode is undefined when the SDK rejected the input before sending (code 'invalid_request').console.log(error.statusCode??'client-side',error.code,error.message,error.details);}elseif(errorinstanceofNopeAuthError){console.log('Invalid API key');}elseif(errorinstanceofNopeServiceUnavailableError){console.log(`Temporarily unavailable; retry after ${error.retryAfter} seconds`);}elseif(errorinstanceofNopeServerError){console.log(`Server error ${error.statusCode}`,error.body?.error??error.responseBody);}elseif(errorinstanceofNopeConnectionError){console.log('No response',error.originalError?.message);}else{throwerror;}}The client retries 429 and 503 responses up to maxRetries times, waiting
Retry-After seconds (falling back to the body's retry_after_seconds,
then to 1 s, 2 s, 4 s), capped at 30 s per wait. Timeouts, connection
failures and other 5xx are never retried: paid routes charge before the
handler runs, so a blind retry could bill twice.
client.lastResponseMeta holds { status, rateLimit, balance } from the
most recent response. rateLimit comes from the X-RateLimit-* headers on
every route; balance (balanceMills, costMills) is present on paid
routes only.
Every request and response type is exported. NopeClient is generic on the
demo flag, so oversight.analyze and ocular return the demo response
types on a demo client and the authenticated types otherwise. Annotate a
client that may be either as NopeClient<boolean>.
importtype{EvaluateResponse,EvaluateResource,Risk,CrisisResource,OcularResponse,OversightAnalyzeResponse,OversightBehaviorCode,SignpostSearchResult,WebhookPayload,ServiceScope,}from'@nope-net/sdk';import{NopeClient}from'@nope-net/sdk';consteitherClient: NopeClient<boolean>=newNopeClient({demo: true});constscope: ServiceScope='domestic_violence';constcode: OversightBehaviorCode='dependency_reinforcement';console.log(typeofeitherClient,scope,code);The package follows Semantic Versioning. 4.0.0 is a
breaking release; see CHANGELOG.md for every change from
3.x, including the switch of retryAfter to seconds and the removed
response fields.
MIT. See LICENSE.
- API reference: docs.nope.net
- Dashboard: dashboard.nope.net
- Issues: github.com/nope-net/node-sdk/issues