assessment.updated webhook with the new version.
If the account’s new decision is review or block, you created an account that you would not have allowed. The late-result handler in each SDK verifies that webhook and picks an action from your policy. Then it calls the admin API of your auth provider to end the user’s sessions or to ban the user. With your own login, the SDK’s log-only provider logs what it would do until you write your own provider. Your own login shows how.
A late result goes through these steps:
- The user creates an account with Clerk, WorkOS or Auth0. The provider creates the user and the session.
- Your backend calls
POST /v1/assessmentswith the provider’s user id asaccount_id. - The first answer is
partialwithallow, so the user gets in. - A late source settles. NoResponse sends
assessment.updatedat version 2 withreviewto your webhook endpoint. handleLateResultverifies the signature and findsrevoke_sessionsforreviewin the policy. It calls that action through the provider’s admin API.
account_id. Every assessment requires account_id, and the handler acts on it.
Why evidence arrives late
An account creation assessment runs atdepth: "full" by default. It waits up to 3,000 ms for its first answer. A login assessment runs at depth: "quick" and waits only for the sources that answer within 500 ms.
A source that misses this deadline continues to run. The first answer lists it in detail.incomplete_sources, for example { "lane": "accounts", "reason": "timed_out" }. The assessment then has status: "partial", or "pending" when no usable answer arrived in time.
- The accounts lane looks for accounts registered to the email and username. It is the slowest lane. When it misses the deadline, the first answer lists it as
timed_out. - The breach lane looks for the email and username in breach tables. When it misses the deadline,
detail.risk.summary.email.breachis absent until the lane settles. - Phone lookups always run after the first answer. The first answer lists them as
deferred.
detail.assessment_version and sends it to your webhook endpoint. For example, the late lane is the breach lane, and it finds the email in a hacking-forum table. Then abuse_ecosystem_member fires, and the account’s decision moves from allow to review, as in the event below.
The same event follows a pending answer. A first assessment that is pending carries no account. Your fallback can let the user in provisionally. In that case, the account’s first decision arrives in this event.
The later event
The event carries the whole assessment at its new version. It is exactly whatGET /v1/assessments/{assessment_id} returns. This event followed a first answer of partial, score 15 and allow, at version 1.
assessment.updated at version 2
account is the account’s one current risk. account.risk.decision is its decision for the flow of this assessment: allow, step_up, review or block. The handler reads that value. block means that the score reached the block threshold you set. While blocking is off, block never appears.
detail holds the facts of this assessment alone. detail.account_id has the same value as account.id. It is the id you sent when you assessed the account creation. Here it is the Clerk user id, so the handler acts on it unchanged.
The assessment object defines every field, including band, coverage and policy_basis. Reason codes lists the reasons and their weights. Webhooks covers the envelope, its four signed headers and the delivery schedule.
The SDKs name the fields in the style of each language:
- TypeScript:
account.risk.decisionanddetail.assessmentVersion. - Python:
account.risk.decisionanddetail.assessment_version. - Go:
Account.Risk.DecisionandDetail.AssessmentVersion.
What the handler does
The handler ishandleLateResult in TypeScript, handle_late_result in Python and HandleLateResult in Go. It takes the raw body, the request headers, your signing secrets and a provider. Then it does these steps:
- It verifies the delivery before it does anything else. A delivery that fails verification raises an error before any provider call.
- It reads
account.risk.decision. An event withoutaccountcauses no action. - It finds the decision in your policy and runs the action on the user.
event, the action taken (none, revoke_sessions or ban) and the provider user id that the handler acted on. You can log it or open a case in your review queue.
Banning is opt-in: set
block or review to ban. A policy you pass replaces the default table. It does not merge with it. A decision you leave out does nothing. For example, { block: "ban" } bans on block and does nothing on allow, step_up or review. An empty policy turns every action off.
An expiry event and an endpoint.verification event never cause an action. The handler never reverses an action. When a later version says allow after a review, the handler does nothing. It is your decision to restore the account.
Each provider does the two actions through its own admin API.
When a session ends, the provider stops issuing new tokens for it. Tokens that the provider already issued stay valid until they expire. That is 60 seconds for a Clerk session token, and the access token lifetime you set for WorkOS and Auth0.
A WorkOS ban deletes the userWith WorkOS,
ban deletes the user and everything WorkOS holds for the user. You cannot undo it. If you must reverse a decision later, keep review and block on revoke_sessions. Then send the account to your own review queue.ProviderActionError with the provider’s status code. Answer the webhook with a 5xx, and NoResponse delivers it again on its retry schedule.
A 404 is not a refusal. The user or session is already gone, so the handler counts the action as done. Thus you can safely repeat a revoke or a ban. A delivery succeeds and does not retry when the user is already deleted or banned, or never existed.
An error from your user id callback, such as a failed store read, also stops the handler. TypeScript throws it, Python raises it and ResolveUserID in Go returns it. The handlers below pass it on, and your framework answers 500. NoResponse also retries that answer.
Before you act
The handler verifies the delivery. Four more rules apply to your receiver, and the handlers below follow all of them.- Answer the verification challenge. The handler returns an
endpoint.verificationevent with actionnone. Return the challenge with the SDK’s verification response helper, as Webhooks shows. - Skip test events.
POST /v1/webhooks/{endpoint_id}/testsends an event with"test": true. Its fixed example assessment has thedetail.account_idacct_7f3a9c, which is not one of your users. The handler takes no action on it, whatever your policy says. It never calls your user id callback for it. The SDKs show the flag asevent.testin TypeScript and Python,event.Testin Go and C#,event.isTest()in Java,$event->testin PHP andevent.test?in Ruby. Your own code can use it to skip the event too, for example when you record versions. - Skip stale revisions. Delivery is at least once and unordered, so a retry of version 2 can arrive after version 3. Act only when
detail.assessment_versionis higher than the highest version you saw for that assessment id. Record every version you see, including the versions that called for no action. If you do not, a latereviewat version 2 would revoke a user whom version 3 cleared. - Forget an expired assessment. The last event for an assessment is an
assessment.updatedwithstatus: "expired". It carries the last version served and no assessment body. The SDKs give it its own type, which is not anAssessment:AssessmentExpiredEventin TypeScript, andAssessmentExpireddata in Python and Go. The handler takes no action on it. Delete what you hold. Retries of any version end within three hours of its first attempt, and retention is at least one day. Thus a stale retry never arrives after you forget the assessment.
userId in TypeScript, resolve_user_id in Python and ResolveUserID in Go. The handler calls it only when the policy names an action. The callback receives the assessment’s detail.account_id and the verified event. Return no user id to take no action. The handlers below skip stale versions in the callback. If you assessed with your own id, map it to the provider’s user id there too.
In Go, ResolveUserID receives the context of the request and returns (string, error). On a failed read, return the error of the store. HandleLateResult returns it unchanged and does not call the provider, so NoResponse retries the delivery. The older UserID callback returns only a string. It still works, and the handler ignores it when you set ResolveUserID.
The versions store
versions in the handlers is your own store. It holds the highest detail.assessment_version seen for each assessment. Keep it in your database, and share it between every instance that receives webhooks. A retry can reach a different instance from the first attempt.
held returns 0 for an assessment it has not seen, so you do not need to record the version of the first answer. record keeps the higher of the stored version and the given version. Thus an older version that arrives late never lowers it. forget deletes the row. In SQL:
Versions store in SQL
ban can run after another ban deleted the user. It gets a 404 for the user, counts it as done and succeeds.
An older version can also act after a newer one. For example, version 2 starts a revoke, and version 3 records allow before the revoke ends. To stop this, make the comparison, the action and the record one exclusive step per assessment across all your receivers. For example, take a row lock with SELECT ... FOR UPDATE on the version row, and record the version only after the action succeeds.
The handler
Each handler is the whole receiver for your webhook endpoint. When you registered the endpoint, you got a signing key id and secret. Store them asNORESPONSE_WEBHOOK_KEY_ID and NORESPONSE_WEBHOOK_SECRET. The secrets map takes one entry per key id. During a secret rotation, add the new key id beside the old one.
The TypeScript handlers are Next.js App Router route handlers at app/webhooks/noresponse/route.ts. They work unchanged in any framework that gives you a Fetch API Request. The Python handlers use FastAPI. The Go handlers use net/http. Mount them with http.HandleFunc("POST /webhooks/noresponse", noresponseWebhook). The highlighted lines set the policy: change them to choose a different action.
- Clerk
- WorkOS
- Auth0
- Your own login
Set
CLERK_SECRET_KEY to your Clerk secret key. Assess with the Clerk user id, user_..., as account_id. The handler acts on it unchanged.TypeScript
Your own login
If your platform has its own login, the handler has no provider admin API to call. Each SDK has a log-only provider for that case. It logs one line for each action the handler would take, with the user id, and it calls nothing.
Each log-only provider accepts your own log function in place of the default. The quickstart creates an account with
late@test.noresponse.org. When its assessment changes to review, the default policy logs a line such as this one:
Log output
Write your own provider
A provider is a name and two methods. Each method gets the user id fromaccount_id. revokeSessions ends every session of the user, so the user must go through login again. ban stops all future logins of the user and ends the user’s sessions. In the samples, sessions is your session store and users is your user table.
- Make both methods safe to repeat. Delivery is at least once, so the handler can call a method twice for the same user. A revoke of sessions that are already gone must succeed. A disable of a user who is already disabled must also succeed.
- Fail the delivery when your store fails. Throw or return the error. The handlers above answer 500, and NoResponse delivers the event again on its retry schedule.
- Treat a missing user as done. A user that you deleted after account creation has nothing left to revoke.
TypeScript
ownLogin in TypeScript, OwnLogin() in Python and ownLogin{} in Go.
Try it
Send a test event to yourself. Use yourwebhooks key and the whe_ endpoint id you got when you registered the endpoint. The SDKs send it with webhookEndpoints.sendTestEvent, as that page shows. The handler itself needs no NoResponse key. It answers 204 and takes no action, because the event carries "test": true.
Send a test event
- Create a user in the development instance of your provider.
- Start a session as that user.
- Call the provider object directly with the user’s id.
TypeScript
workosActions and auth0Actions in TypeScript, workos_actions and auth0_actions in Python, and WorkOSActions and Auth0Actions in Go take the same credentials as the handlers above. They have the same revokeSessions and ban methods.