Skip to content

Ruby SDK

The NoResponse Ruby SDK is the server-side client for Ruby 3.2 and later. It covers every route in the interactive reference, verifies webhooks and turns your auth provider’s verified claims into auth_context. Every method returns an envelope: result holds the typed object and headers holds the response headers (X-Request-ID, Retry-After, Idempotent-Replayed).

Keep the client on your backend, where your API key lives.

The gem is named noresponse. RubyGems publishing is pending, so install it from the GitHub tag at github.com/noresponsehq/ruby. In your Gemfile:

gem "noresponse", git: "https://github.com/noresponsehq/ruby.git", tag: "v2.0.0"

Use the latest tag from the repository’s tags page. Once the gem is on RubyGems, the line is gem "noresponse", "~> 2.0".

require "noresponse"
Models = NoResponse::Models
client = NoResponse::NoResponseClient.new(api_key: ENV.fetch("NORESPONSE_API_KEY"))
client = NoResponse::NoResponseClient.new(
api_key: key,
server_url: "https://api.noresponse.org",
timeout_ms: 10_000,
client: Faraday.new { |f| f.proxy = "http://proxy:8080" },
retry_config: NoResponse::Utils::RetryConfig.new(strategy: "none")
)
Option Meaning
api_key Your nrk_ key. Read it from NORESPONSE_API_KEY as above.
security_source A lambda returning Models::Shared::Security.new(api_key: key) on each request, for a secret that rotates.
server_url The API host. The default is https://api.noresponse.org.
timeout_ms Timeout per request, in milliseconds.
client Your own Faraday connection, for a proxy or TLS settings.
retry_config Overrides the retry policy for every call. See Retries.

Every method also takes timeout_ms:, retries: and http_headers: for one call.

Call Does
client.assessments.create(body:, idempotency_key:) POST /v1/assessments. Answers 200 (complete or partial) or 202 (pending).
client.assessments.get(assessment_id:, response_mode:) GET /v1/assessments/{id}.
client.assessments.wait(assessment_id, timeout:) Polls get until the assessment is no longer pending.
client.assessments.query(request:) POST /v1/assessments/query. One page of an account’s history.
client.assessments.record_outcome(assessment_id:, body:, idempotency_key:) POST /v1/assessments/{id}/outcomes.
client.keys.rotate(request:) POST /v1/keys/rotate.
NoResponse::Webhooks.construct_event(payload, headers, secrets) Verifies a webhook delivery. No request is made.
signup.rb
require "noresponse"
Models = NoResponse::Models
client = NoResponse::NoResponseClient.new(api_key: ENV.fetch("NORESPONSE_API_KEY"))
response = client.assessments.create(
body: Models::Shared::AssessmentRequest.new(
account_id: "usr_8f2k1",
identifiers: Models::Shared::AssessmentRequestIdentifiers.new(
name: "Jordan Lee",
ip: "81.2.69.160"
),
policy_context: Models::Shared::AssessmentRequestPolicyContext.new(
flow: Models::Shared::Flow::SIGNUP,
region: "GB"
),
request_id: "req_01",
deadline_ms: 400,
response_mode: Models::Shared::ResponseMode::STANDARD,
client_context: Models::Shared::AssessmentRequestClientContext.new(
timezone: "Europe/London",
locale: "en-GB"
)
),
idempotency_key: "signup-usr_8f2k1-01JB3C4D"
)
assessment = response.result
if assessment.status == Models::Shared::Status::PENDING
# No recommendation yet. Apply your fallback, then wait or take the webhook.
else
puts assessment.recommendation # allow, step_up or review
end

The request mirrors the contract: identifiers holds email, username, phone, name and ip, and policy_context holds flow and region. The limits are on Create an assessment. The recommendation is advice: your application decides what to do.

The method returns the assessment for both a 200 and a 202. complete and partial carry risk and recommendation; pending carries neither.

settled = client.assessments.wait(assessment.assessment_id, timeout: 10)

wait polls get until the status is no longer pending, sleeping for Retry-After between polls with a one second floor. It raises NoResponse::WaitTimeoutError, which carries assessment_id and the last pending assessment as last, once the timeout (30 seconds by default) is reached. NoResponse::Wait.wait(client, assessment_id) does the same. Apply your fallback first and let the result arrive by webhook or a background wait, so the signup is never held open.

latest = client.assessments.get(
assessment_id: assessment.assessment_id,
response_mode: Models::Shared::ResponseMode::DETAILED
).result

Detailed responses need the detailed scope.

cursor = nil
loop do
page = client.assessments.query(
request: Models::Shared::AssessmentQuery.new(account_id: "usr_8f2k1", limit: 50, cursor: cursor)
).result
page.items.each { |item| puts item.assessment_id } # newest first
cursor = page.next_cursor
break if cursor.nil?
end

next_cursor is nil on the last page. The query needs a key with the history scope. See Get and query assessments.

client.assessments.record_outcome(
assessment_id: assessment.assessment_id,
body: Models::Shared::AssessmentOutcomeRequest.new(
outcome: Models::Shared::Outcome::CONFIRMED_ABUSE,
observed_at: "2026-10-20T05:00:00Z",
external_ref: "case-123",
notes: "Card testing from this account."
),
idempotency_key: "outcome-case-123"
)

Outcome has CONFIRMED_ABUSE, ACCOUNT_TAKEOVER, CHARGEBACK, FALSE_POSITIVE, LEGITIMATE and UNKNOWN. The idempotency key stays bound for as long as the outcome is kept, so derive it from your case.

rotation = client.keys.rotate(
request: Models::Shared::KeyRotationRequest.new(overlap_seconds: 86_400)
).result
rotation.key # the new key, shown once
rotation.previous_key_expires_at # when the key that made this call stops working

The new key has the scopes of the key that made the call. Store it at once and roll it out before previous_key_expires_at. See Authentication.

create and record_outcome send a fresh UUID as the Idempotency-Key when you pass none, and the same key on every retry of that call, so a retried request is recorded once. Pass your own key, derived from your signup attempt, to make a retry from your side replay the same assessment: the API answers the latest version with Idempotent-Replayed: true. The same key with a different body answers 409 (ConflictError).

Every API error is a NoResponse::Models::Errors::NoResponseError. The problem-document errors carry status, type, title, detail, code, request_id and invalid_params.

Errors = NoResponse::Models::Errors
begin
client.assessments.create(body: request)
rescue Errors::RateLimitError => e
log(e.request_id) # retries already honoured Retry-After and ran out
rescue Errors::NoResponseError => e
warn "#{e.class}: #{e.message}"
rescue Faraday::Error
# DNS, TLS or connection failure: no answer at all.
end
# Apply your fallback.
Class When
BadRequestError 400: the body or a header is malformed. See invalid_params.
AuthenticationError 401: a missing, malformed, revoked or expired key.
PermissionDeniedError 403: the key lacks the operation’s scope.
NotFoundError 404: an unknown, expired or foreign assessment.
ConflictError 409: an idempotency key reused with a different body, or a key already rotating.
RateLimitError 429, after the retries (which honour Retry-After) ran out.
APIStatusError Any other 4xx or 5xx, including 503. It has status_code, body and raw_response.

Quote request_id when you contact us. The codes are listed on Errors and limits.

The client retries connection errors, 408, 429 and every 5xx answer with exponential backoff: 500 ms first, 8 seconds at most between attempts and 30 seconds in all. A 409 and every other 4xx are not retried. Every attempt of a create or outcome call sends the same Idempotency-Key, so a retried call is recorded once.

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

client.assessments.create(
body: request,
retries: NoResponse::Utils::RetryConfig.new(strategy: "none")
)

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

app.rb
# Rack or Sinatra
post "/webhooks/noresponse" do
payload = request.body.read
secrets = { ENV.fetch("NORESPONSE_WEBHOOK_KEY_ID") => ENV.fetch("NORESPONSE_WEBHOOK_SECRET") }
begin
event = NoResponse::Webhooks.construct_event(payload, request.env, secrets)
rescue NoResponse::Webhooks::VerificationError => e
halt 400, e.code.to_s
end
if event.type == "endpoint.verification"
content_type :json
next NoResponse::Webhooks.verification_response(event).to_json
end
if event.expired?
# Forget the assessment: GET now answers 404.
else
# event.data is the full assessment. Keep the highest assessment_version.
end
status 204
end

construct_event(payload, headers, secrets, tolerance_seconds: 300) takes the raw body, a hash of headers in any letter case (a Rack env works) and a map from webhook key id (whk_ and 26 characters) to secret. It checks that each NR-Webhook-* header is present exactly once, that the timestamp is within the tolerance, that the key id names a secret you hold, that the signature over timestamp + "." + raw body matches in constant time, and that the body’s id equals NR-Webhook-Id. A failure raises NoResponse::Webhooks::VerificationError, whose code is :missing_header, :duplicate_header, :invalid_timestamp, :unknown_key, :invalid_signature or :invalid_payload.

The event has id, type, created_at and data. data is an assessment, an expiry notice (expired? is true) or an endpoint verification. Deliveries arrive at least once: deduplicate on event.id, apply an assessment only when its assessment_version is higher than the one you hold, and always apply an expiry. See Webhooks.

auth_context = NoResponse::Auth.from_clerk(verified_claims, { email_verified: true })

NoResponse::Auth has from_workos, from_clerk, from_auth0, from_supabase, from_firebase and from_oidc(provider, claims, overrides). Each takes the claims your backend has already verified, as a hash such as JSON.parse gives (symbol keys also work), and returns an AuthContext with verified true and transport set to backend. Pass it as auth_context: on AssessmentRequest.new.

Each adapter reads the standard claims (issuer, subject, audience, session and token ids, times), the provider’s organisation, role and scope claims, and multi-factor evidence where the provider exposes it. The optional overrides set facts the token leaves out, using the contract’s field names: email_verified, phone_verified, account_created_at, session_created_at and the like. For from_oidc, provider is a lowercase slug of 2 to 32 characters. A claim that is missing or out of range is left out, not turned into a stronger assurance, and an adapter raises ArgumentError when the claims have no valid issuer and subject. Pass verified claims, never the raw token.