PHP SDK
The NoResponse PHP SDK is the server-side client for PHP 8.2 and later. It covers every route in the interactive reference, verifies webhooks and turns your auth provider’s verified claims into authContext. 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.
Install
Section titled “Install”There is no Packagist package yet. Add the GitHub repository at github.com/noresponsehq/php to your composer.json and require the package:
{ "repositories": [ { "type": "vcs", "url": "https://github.com/noresponsehq/php" } ], "require": { "noresponse/noresponse-php": "^2.0" }}composer update noresponse/noresponse-phpComposer reads the version tags from the repository’s tags page.
Client
Section titled “Client”use NoResponse\NoResponseClient;
$client = NoResponseClient::create(getenv('NORESPONSE_API_KEY'));NoResponseClient::create($apiKey) is the builder with only the key. For more options use the builder:
$client = NoResponseClient::builder() ->setSecurity(getenv('NORESPONSE_API_KEY')) ->setServerUrl('https://api.noresponse.org') ->setClient(new \GuzzleHttp\Client(['timeout' => 10, 'proxy' => 'http://proxy:8080'])) ->setRetryConfig(new \NoResponse\Utils\Retry\RetryConfigNone()) ->build();| Builder method | Meaning |
|---|---|
setSecurity(string) |
Your nrk_ key. Read it from NORESPONSE_API_KEY as above. |
setSecuritySource(callable) |
Supplies the key on each request, for a secret that rotates. |
setServerUrl(string) |
The API host. The default is https://api.noresponse.org. |
setClient(...) |
Your own Guzzle client. Timeouts, proxies and TLS settings belong here. |
setRetryConfig(...) |
Overrides the retry policy for every call. See Retries. |
Methods
Section titled “Methods”| Call | Does |
|---|---|
$client->assessments->create($body, idempotencyKey: ...) |
POST /v1/assessments. Answers 200 (complete or partial) or 202 (pending). |
$client->assessments->get($assessmentId, $responseMode) |
GET /v1/assessments/{id}. |
Wait::wait($client, $assessmentId, timeoutSeconds: ...) |
Polls get until the assessment is no longer pending. |
$client->assessments->query($query) |
POST /v1/assessments/query. A generator of pages. |
$client->assessments->recordOutcome($body, $assessmentId, idempotencyKey: ...) |
POST /v1/assessments/{id}/outcomes. |
$client->keys->rotate($request) |
POST /v1/keys/rotate. |
Webhook::constructEvent($payload, $headers, $secrets) |
Verifies a webhook delivery. No request is made. |
Create an assessment
Section titled “Create an assessment”<?php
require 'vendor/autoload.php';
use NoResponse\Models\Shared\AssessmentRequest;use NoResponse\Models\Shared\AssessmentRequestClientContext;use NoResponse\Models\Shared\AssessmentRequestIdentifiers;use NoResponse\Models\Shared\AssessmentRequestPolicyContext;use NoResponse\Models\Shared\Flow;use NoResponse\Models\Shared\ResponseMode;use NoResponse\Models\Shared\Status;use NoResponse\NoResponseClient;
$client = NoResponseClient::create(getenv('NORESPONSE_API_KEY'));
$response = $client->assessments->create( new AssessmentRequest( accountId: 'usr_8f2k1', identifiers: new AssessmentRequestIdentifiers( name: 'Jordan Lee', ip: '81.2.69.160', ), policyContext: new AssessmentRequestPolicyContext(flow: Flow::Signup, region: 'GB'), requestId: 'req_01', deadlineMs: 400, responseMode: ResponseMode::Standard, clientContext: new AssessmentRequestClientContext(timezone: 'Europe/London', locale: 'en-GB'), ), idempotencyKey: 'signup-usr_8f2k1-01JB3C4D',);
$assessment = $response->result;if ($assessment->status === Status::Pending) { // No recommendation yet. Apply your fallback, then wait or take the webhook.} else { echo $assessment->recommendation?->value; // allow, step_up or review}The request mirrors the contract: identifiers holds email, username, phone, name and ip, and policyContext 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.
Pending and wait
Section titled “Pending and wait”use NoResponse\Wait;
$settled = Wait::wait($client, $assessment->assessmentId, timeoutSeconds: 10);Wait::wait polls assessments->get until the status is no longer pending, sleeping for Retry-After between polls with a one second floor. It throws WaitTimeoutException, which carries assessmentId and the last pending assessment as last, when the timeout (30 seconds by default) passes. 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->assessmentId, ResponseMode::Detailed);Detailed responses need the detailed scope.
Query and paging
Section titled “Query and paging”query returns a generator that yields one page at a time and follows the cursor for you:
use NoResponse\Models\Shared\AssessmentQuery;
foreach ($client->assessments->query(new AssessmentQuery(accountId: 'usr_8f2k1', limit: 50)) as $page) { foreach ($page->result->items as $item) { // each assessment at its latest version, newest first }}The query needs a key with the history scope. See Get and query assessments.
Record an outcome
Section titled “Record an outcome”use NoResponse\Models\Shared\AssessmentOutcomeRequest;use NoResponse\Models\Shared\Outcome;
$client->assessments->recordOutcome( new AssessmentOutcomeRequest( outcome: Outcome::ConfirmedAbuse, observedAt: '2026-10-20T05:00:00Z', externalRef: 'case-123', notes: 'Card testing from this account.', ), $assessment->assessmentId, idempotencyKey: 'outcome-case-123',);Outcome has ConfirmedAbuse, AccountTakeover, Chargeback, FalsePositive, Legitimate and Unknown. The idempotency key stays bound for as long as the outcome is kept, so derive it from your case.
Key rotation
Section titled “Key rotation”use NoResponse\Models\Shared\KeyRotationRequest;
$rotation = $client->keys->rotate(new KeyRotationRequest(overlapSeconds: 86400));$rotation->result->key; // the new key, shown once$rotation->result->previousKeyExpiresAt; // when the key that made this call stops workingThe new key has the scopes of the key that made the call. Store it at once and roll it out before previousKeyExpiresAt. See Authentication.
Idempotency keys
Section titled “Idempotency keys”create and recordOutcome 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 (ConflictException).
Errors
Section titled “Errors”Every API error extends NoResponse\Models\Errors\NoResponseException. The problem-document exceptions expose getStatusCode(), getRequestId() and the typed problem document as $e->container with type, title, detail, code and invalidParams.
use NoResponse\Models\Errors\APIStatusException;use NoResponse\Models\Errors\NoResponseException;use NoResponse\Models\Errors\RateLimitException;
try { $client->assessments->create($request);} catch (RateLimitException $e) { // Retries already honoured Retry-After and ran out. logError($e->getRequestId());} catch (APIStatusException $e) { logError($e->statusCode, $e->body);} catch (NoResponseException $e) { logError($e->getMessage()); // see getStatusCode() and getRequestId()} catch (\GuzzleHttp\Exception\GuzzleException $e) { // DNS, TLS or connection failure: no answer at all.}// Apply your fallback.| Exception | When |
|---|---|
BadRequestException |
400: the body or a header is malformed. See container->invalidParams. |
AuthenticationException |
401: a missing, malformed, revoked or expired key. |
PermissionDeniedException |
403: the key lacks the operation’s scope. |
NotFoundException |
404: an unknown, expired or foreign assessment. |
ConflictException |
409: an idempotency key reused with a different body, or a key already rotating. |
RateLimitException |
429, after the retries (which honour Retry-After) ran out. |
APIStatusException |
Any other 4xx or 5xx, including 503. It has statusCode, body and rawResponse. |
Quote the request id when you contact us. The codes are listed on Errors and limits.
Retries
Section titled “Retries”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 setRetryConfig, or for one call with Options:
use NoResponse\Utils\Options;use NoResponse\Utils\Retry\RetryConfigNone;
$client->assessments->create( $request, options: Options::builder()->setRetryConfig(new RetryConfigNone())->build(),);Webhooks
Section titled “Webhooks”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.
use NoResponse\Models\Shared\Assessment;use NoResponse\Webhooks\VerificationException;use NoResponse\Webhooks\Webhook;
$payload = file_get_contents('php://input');$secrets = [getenv('NORESPONSE_WEBHOOK_KEY_ID') => getenv('NORESPONSE_WEBHOOK_SECRET')];
try { $event = Webhook::constructEvent($payload, getallheaders(), $secrets);} catch (VerificationException $e) { http_response_code(400); // $e->errorCode says which check failed exit;}
if ($event->type === 'endpoint.verification') { header('Content-Type: application/json'); echo json_encode(Webhook::verificationResponse($event)); exit;}if ($event->isExpired()) { // Forget the assessment: GET now answers 404.} elseif ($event->data instanceof Assessment) { // Keep the highest assessmentVersion per assessment.}http_response_code(204);constructEvent($payload, $headers, $secrets, $toleranceSeconds = 300) takes the raw body as a string, an array of headers in any letter case 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 throws VerificationException, whose errorCode is missing_header, duplicate_header, invalid_timestamp, unknown_key, invalid_signature or invalid_payload.
The event has id, type, createdAt and data. data is an Assessment, an AssessmentExpired notice (isExpired() is true) or an EndpointVerification. Deliveries arrive at least once: deduplicate on $event->id, apply an assessment only when its assessmentVersion is higher than the one you hold, and always apply an expiry. See Webhooks.
Auth adapters
Section titled “Auth adapters”use NoResponse\Auth;
$authContext = Auth::fromClerk($verifiedClaims, ['email_verified' => true]);Auth has fromWorkOS, fromClerk, fromAuth0, fromSupabase, fromFirebase and fromOIDC($provider, $claims, $overrides). Each takes the claims your backend has already verified, as an array such as json_decode($payload, true) gives, and returns an AuthContext with verified true and transport set to backend. Pass it as the authContext argument of AssessmentRequest.
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 fromOIDC, $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 throws InvalidArgumentException when the claims have no valid issuer and subject. Pass verified claims, never the raw token.