Skip to content

Go SDK

github.com/noresponsehq/go/v2 is the server-side client for Go. It covers every route in the interactive reference, verifies webhooks and turns your auth provider’s verified claims into auth_context. Requests and responses are typed structs in the models/components package, with the contract’s descriptions as doc comments.

Terminal window
go get github.com/noresponsehq/go/v2
import (
noresponse "github.com/noresponsehq/go/v2"
"github.com/noresponsehq/go/v2/models/components"
)

The root package is noresponse. Request and response types live in github.com/noresponsehq/go/v2/models/components, typed API errors in github.com/noresponsehq/go/v2/models/apierrors and per-call options in github.com/noresponsehq/go/v2/models/operations. The module needs Go 1.24 or later. The source and issue tracker are at github.com/noresponsehq/go. Keep the client on your backend, where your API key lives.

client := noresponse.NewClient(os.Getenv("NORESPONSE_API_KEY"),
noresponse.WithTimeout(10*time.Second),
noresponse.WithRetryConfig(retry.Config{
Strategy: "backoff",
Backoff: &retry.BackoffStrategy{InitialInterval: 200, MaxInterval: 2000, Exponent: 2, MaxElapsedTime: 5000}, // milliseconds
RetryConnectionErrors: true,
}),
)

NewClient(apiKey, opts...) returns a *noresponse.NoResponse and no error. It reads nothing from the environment, so pass your nrk_ key. noresponse.New(opts...) is the same constructor without a key argument: it reads NORESPONSE_API_KEY unless you pass WithAPIKey or WithSecurity.

Option Meaning
WithAPIKey(key) Your nrk_ key. WithSecurity(key) is the same option.
WithSecuritySource(fn) A function that returns the key for each call, for a key you rotate while the process runs.
WithServerURL(url) The API host. The default is https://api.noresponse.org.
WithTimeout(d) A timeout applied to each operation.
WithRetryConfig(config) The retry policy for every call. See Retries.
WithClient(c) Your own HTTP client, any value with a Do(*http.Request) (*http.Response, error) method, for a proxy, a transport or tests.

The default HTTP client has a 60 second timeout. Nothing on the client changes after construction, so one client is safe to share across goroutines.

Every method takes a context.Context as its first argument. Cancelling it cancels the request and any retry wait.

Method Calls Result
client.Assessments.Create(ctx, idempotencyKey, request, opts...) POST /v1/assessments res.Result is a components.Assessment
client.Assessments.Get(ctx, id, responseMode, opts...) GET /v1/assessments/{id} res.Result is a components.Assessment
noresponse.WaitForAssessment(ctx, client, id, responseMode) Polls GET /v1/assessments/{id} *components.Assessment that is no longer pending
client.Assessments.Query(ctx, query, opts...) POST /v1/assessments/query res.Result is a components.AssessmentList; res.Next() fetches the next page
noresponse.AllAssessments(ctx, client, query) POST /v1/assessments/query, page by page iter.Seq2[components.Assessment, error]
client.Assessments.RecordOutcome(ctx, id, idempotencyKey, request, opts...) POST /v1/assessments/{id}/outcomes res.Result is a components.AssessmentOutcome
client.Keys.Rotate(ctx, request, opts...) POST /v1/keys/rotate res.Result is a components.KeyRotation
noresponse.ConstructEvent(payload, headers, secrets, opts...) No request *noresponse.Event
noresponse.VerificationResponse(event) No request map[string]string to encode as JSON

Every response also carries res.Headers, the response headers as a map[string][]string. Wrap it in http.Header(res.Headers) to read one, for example X-Request-ID, Retry-After, Location on a 202 or Idempotent-Replayed. Optional request fields are pointers; noresponse.Pointer(value) makes one, and noresponse.String, noresponse.Bool, noresponse.Int and noresponse.Int64 do the same for one type each.

res, err := client.Assessments.Create(ctx, "signup-usr_8f2k1-01JB3C4D", components.AssessmentRequest{
AccountID: "usr_8f2k1",
Identifiers: components.AssessmentRequestIdentifiers{
Email: noresponse.Pointer("[email protected]"),
Name: noresponse.Pointer("Jordan Lee"),
IP: noresponse.Pointer("81.2.69.160"),
},
PolicyContext: &components.AssessmentRequestPolicyContext{
Flow: noresponse.Pointer(components.FlowSignup),
Region: noresponse.Pointer("GB"),
},
RequestID: noresponse.Pointer("req_01"),
DeadlineMs: noresponse.Pointer(int64(400)),
ResponseMode: noresponse.Pointer(components.ResponseModeStandard),
ClientContext: &components.AssessmentRequestClientContext{
Timezone: noresponse.Pointer("Europe/London"),
Locale: noresponse.Pointer("en-GB"),
Platform: noresponse.Pointer(components.PlatformWeb),
},
AuthContext: authContext, // from noresponse.FromClerkClaims and friends
})
if err != nil {
return err
}
assessment := res.Result

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 method returns the assessment for both a 200 and a 202. Read assessment.Status: components.StatusComplete and components.StatusPartial carry Risk and Recommendation; components.StatusPending carries neither, so both are nil.

Create and RecordOutcome take the Idempotency-Key as a required string argument. The same key with the same body replays the stored answer and starts no new work; the same key with a different body is a 409. noresponse.NewIdempotencyKey() returns a random UUID. Derive the key from your own signup attempt or case instead, and store it beside the signup, so a retry after a crash sends the same one. The client reuses the key on every automatic retry of one call.

res, err := client.Assessments.Create(ctx, "signup-"+signupAttempt.ID, request)
if err == nil && http.Header(res.Headers).Get("Idempotent-Replayed") != "" {
fmt.Println("replayed", http.Header(res.Headers).Get("X-Request-ID"))
}
detailed := components.ResponseModeDetailed
latest, err := client.Assessments.Get(ctx, assessment.AssessmentID, &detailed)
if err != nil {
return err
}
settled, err := noresponse.WaitForAssessment(ctx, client, assessment.AssessmentID, nil)
if err != nil {
return err
}

Get takes the response mode as a pointer; pass nil for the default. WaitForAssessment polls Get until Status is not pending, sleeping for Retry-After between polls with a one second floor. When the context has no deadline it gives up after noresponse.DefaultWaitTimeout (30 seconds); give the context a deadline to choose your own. Running out of time returns a *noresponse.WaitTimeoutError that carries the last assessment it saw, and a cancelled context returns the context’s error. Do not hold a signup open on it: apply your fallback first and let the result arrive by webhook or a background goroutine.

waitCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
settled, err := noresponse.WaitForAssessment(waitCtx, client, assessment.AssessmentID, nil)
var timedOut *noresponse.WaitTimeoutError
if errors.As(err, &timedOut) {
fmt.Println("still pending", timedOut.Last.AssessmentVersion)
}
res, err := client.Assessments.Query(ctx, components.AssessmentQuery{
AccountID: "usr_8f2k1",
Limit: noresponse.Pointer(int64(50)),
})
if err != nil {
return err
}
page := res.Result
fmt.Println(len(page.Items), page.NextCursor, page.RetentionDays) // []components.Assessment, *string, *int64
if res.Next != nil {
next, err := res.Next() // the next page, or nil on the last page
if err != nil {
return err
}
_ = next
}

Query returns one page, newest first. res.Next is nil on the last page and otherwise fetches the following one. The query needs a key with the history scope. See Get and query assessments.

query := components.AssessmentQuery{AccountID: "usr_8f2k1"}
for assessment, err := range noresponse.AllAssessments(ctx, client, query) {
if err != nil {
return err
}
fmt.Println(assessment.AssessmentID, assessment.Status) // every unexpired assessment, one request per page
}

AllAssessments follows the cursor for you, so there is nothing to carry between pages. When a page fails it yields a zero components.Assessment with the error and stops.

res, err := client.Assessments.RecordOutcome(ctx, assessment.AssessmentID, "outcome-"+caseID, components.AssessmentOutcomeRequest{
Outcome: components.OutcomeConfirmedAbuse,
ObservedAt: noresponse.Pointer("2026-10-20T05:00:00Z"),
ExternalRef: noresponse.Pointer("case-123"),
Notes: noresponse.Pointer("Card testing from this account."),
})
if err != nil {
return err
}
fmt.Println(res.Result.ID, res.Result.ReceivedAt)

Outcome is one of OutcomeConfirmedAbuse, OutcomeAccountTakeover, OutcomeChargeback, OutcomeFalsePositive, OutcomeLegitimate or OutcomeUnknown. ObservedAt is an RFC 3339 string. The idempotency key stays bound for as long as the outcome is kept, so derive it from your case rather than generating a fresh one per call.

res, err := client.Keys.Rotate(ctx, components.KeyRotationRequest{OverlapSeconds: noresponse.Pointer(int64(86400))})
if err != nil {
return err
}
rotation := res.Result
newKey := rotation.Key // the new key, shown once
fmt.Println(rotation.PreviousKeyExpiresAt) // 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 PreviousKeyExpiresAt. The client keeps using the key it was built with, so build a new client with the new key. See Authentication.

Every API failure is a typed error you inspect with errors.As. Each carries the problem document’s Type, Title, Detail, Code, RequestID and InvalidParams.

_, err := client.Assessments.Get(ctx, id, nil)
var notFound *apierrors.NotFoundError
var limited *apierrors.RateLimitError
var bad *apierrors.BadRequestError
var other *apierrors.APIStatusError
switch {
case errors.As(err, &notFound):
log.Println("no such assessment, request", notFound.RequestID)
case errors.As(err, &limited):
log.Println("rate limited, request", limited.RequestID) // quote it when you contact us
case errors.As(err, &bad):
for _, param := range bad.InvalidParams {
log.Println(param.Field, param.Reason) // on a 400
}
case errors.As(err, &other):
log.Println("HTTP", other.StatusCode)
case err != nil:
log.Println(err) // a network failure or a cancelled context
}
// Apply your fallback.
Error Returned for
*apierrors.BadRequestError 400
*apierrors.AuthenticationError 401
*apierrors.PermissionDeniedError 403
*apierrors.NotFoundError 404
*apierrors.ConflictError 409
*apierrors.RateLimitError 429
*apierrors.APIStatusError Any other status, with StatusCode, Message and Body

A failed connection, a timeout or a cancelled context returns the underlying error from net/http or context, so use errors.Is(err, context.DeadlineExceeded) to detect a timeout. The codes are listed on Errors and limits.

The client retries 408, 429, 5xx answers and connection errors with exponential backoff and jitter: 500 ms first, 8 seconds at most between attempts and 30 seconds in all. Every attempt of a create or outcome call sends the same idempotency key, so a retry never repeats work. Change the policy for the whole client with WithRetryConfig, or for one call with operations.WithRetries:

res, err := client.Assessments.Get(ctx, id, nil, operations.WithRetries(retry.Config{Strategy: "none"}))

retry is github.com/noresponsehq/go/v2/retry. Durations in retry.BackoffStrategy are milliseconds. operations.WithOperationTimeout(d) bounds one call.

Pass the raw request body. Re-encoding parsed JSON breaks the signature.

secrets := map[string]string{os.Getenv("NORESPONSE_WEBHOOK_KEY_ID"): os.Getenv("NORESPONSE_WEBHOOK_SECRET")}
http.HandleFunc("/webhooks/noresponse", func(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
event, err := noresponse.ConstructEvent(payload, r.Header, secrets, noresponse.WithTolerance(300*time.Second))
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
switch data := event.Data.(type) {
case *components.Assessment:
if isNewer(event.ID, data) {
apply(data) // the full assessment, as Get returns it
}
case *noresponse.AssessmentExpired:
forget(data.AssessmentID)
case *noresponse.EndpointVerification:
body, err := noresponse.VerificationResponse(event)
if err != nil {
http.Error(w, "invalid verification", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(body)
return
}
w.WriteHeader(http.StatusOK)
})

ConstructEvent(payload, headers, secrets, opts...) takes the raw body as []byte, the request’s http.Header and a map[string]string from webhook key id (whk_ and its characters) to secret. It checks that each NR-Webhook-* header is present exactly once, that the timestamp is within the tolerance (noresponse.DefaultWebhookTolerance, 300 seconds, unless WithTolerance says otherwise), 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. Every failure is a *noresponse.WebhookError that matches noresponse.ErrWebhook with errors.Is; its Code says which check failed.

The returned Event carries ID, Type, CreatedAt and Data. For an assessment.updated event Data is a *components.Assessment, or a *noresponse.AssessmentExpired for the terminal expiry notice; event.Expired() reports the second. For an endpoint.verification event Data is a *noresponse.EndpointVerification, and VerificationResponse(event) returns the map to encode as the answer.

Deliveries arrive at least once and unordered. Deduplicate on event.ID and apply an assessment only when its AssessmentVersion is higher than the one you hold; an expiry is always applied. See Webhooks.

authContext, err := noresponse.FromClerkClaims(verifiedClaims, map[string]any{
"email_verified": true,
"account_created_at": user.CreatedAt.UTC().Format(time.RFC3339),
})
if err != nil {
return err
}

Each adapter takes the claims your backend has already verified, as a map[string]any, and returns a *components.AuthContext with Verified true and Transport backend. Overrides is a second map[string]any of facts the token does not carry, keyed by the auth_context field names, such as email_verified, phone_verified, account_created_at, session_created_at, impersonated or mfa. Pass nil for none. Timestamps are RFC 3339 strings. Every adapter applies the contract’s bounds, dropping over-long values rather than truncating them, and returns an error matching noresponse.ErrInvalidAuthContext when the issuer or subject is missing. Pass verified claims, never the raw token.

Function Provider notes
FromWorkOSClaims(claims, overrides) WorkOS AuthKit access token claims. Reads organisation, roles and permissions.
FromClerkClaims(claims, overrides) Clerk session claims. Pass the session’s creation time as the session_created_at override.
FromAuth0Claims(claims, overrides) Auth0 ID token, userinfo or session claims, not an opaque API access token.
FromOIDCClaims(provider, claims, overrides) Any other OpenID Connect provider. provider is a lowercase slug such as keycloak.
FromSupabaseClaims(claims, overrides) Supabase Auth access token claims. Pass the user record’s contact verification as overrides.
FromFirebaseClaims(claims, overrides) Firebase ID token claims from the Admin SDK’s VerifyIDToken. Custom claims never change the mapping.

The guide Send your auth session shows each provider end to end.

Every request, response, webhook and error type is exported: components.Assessment, components.Risk, components.AssessmentOutcome, components.KeyRotation and the rest. Enums are typed string constants (components.Status*, Recommendation*, Band*, Confidence*, Flow*, ResponseMode*, Outcome*, ReasonCode*, Lane*), so a misspelt value does not compile.

The 1.x client was hand-written; 2.x is generated from the contract and the call shapes differ.

1.x 2.x
client, err := noresponse.NewClient(noresponse.WithAPIKey(key)) client := noresponse.NewClient(key)
client.Assessments.Create(ctx, noresponse.CreateAssessmentParams{...}, noresponse.WithIdempotencyKey(k)) client.Assessments.Create(ctx, k, components.AssessmentRequest{...})
The assessment returned directly, with response headers on Meta res.Result for the assessment and res.Headers for the headers
client.Assessments.Wait(ctx, id) noresponse.WaitForAssessment(ctx, client, id, nil)
client.Assessments.QueryAll(ctx, params) noresponse.AllAssessments(ctx, client, components.AssessmentQuery{...})
One *noresponse.Error with sentinels such as ErrRateLimited Typed errors in apierrors, checked with errors.As
StatusExpired on an *Assessment A *noresponse.AssessmentExpired in event.Data
FromClerk(claims, *AuthContext) FromClerkClaims(claims, map[string]any)