> ## Documentation Index
> Fetch the complete documentation index at: https://docs.noresponse.org/llms.txt
> Use this file to discover all available pages before exploring further.

# Act on late results

> When evidence arrives after your account creation gate answered, NoResponse sends assessment.updated. Verify it, skip test events and stale revisions, then revoke the user's sessions or ban the user in Clerk, WorkOS, Auth0 or your own login.

You assess account creation after your auth provider creates the user. Your gate reads the first answer and lets the user in. The accounts, breach and phone sources can answer after that first answer. When they change the assessment, NoResponse sends an `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](#your-own-login) shows how.

A late result goes through these steps:

1. The user creates an account with Clerk, WorkOS or Auth0. The provider creates the user and the session.
2. Your backend calls `POST /v1/assessments` with the provider's user id as `account_id`.
3. The first answer is `partial` with `allow`, so the user gets in.
4. A late source settles. NoResponse sends `assessment.updated` at version 2 with `review` to your webhook endpoint.
5. `handleLateResult` verifies the signature and finds `revoke_sessions` for `review` in the policy. It calls that action through the provider's admin API.

Assess from your backend on the first request of the new session, as [Pass your auth session](https://docs.noresponse.org/guides/auth-session.md) shows. At that time the user exists in the provider, so you send its user id as `account_id`. Every assessment requires `account_id`, and the handler acts on it.

## Why evidence arrives late

An account creation assessment runs at `depth: "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.breach` is absent until the lane settles.
* Phone lookups always run after the first answer. The first answer lists them as `deferred`.

A lane can settle and change the score, band, recommendation, confidence, coverage or status. Then NoResponse publishes a new `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](https://docs.noresponse.org/guides/assess.md#choose-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 what `GET /v1/assessments/{assessment_id}` returns. This event followed a first answer of `partial`, score 15 and `allow`, at version 1.

```json title="assessment.updated at version 2" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "id": "evt_01JB3C4N5P6Q7R8S9T0VWXYZ12",
  "type": "assessment.updated",
  "created_at": "2026-10-18T11:00:04.000Z",
  "data": {
    "schema_version": "1",
    "id": "asm_01JB3C4D5E6F7G8H9JKMNPQRST",
    "status": "complete",
    "flow": "account_creation",
    "depth": "full",
    "account": {
      "schema_version": "1",
      "id": "user_2pQ8mX1vRbK7nL4tYc9wZ3dF6hJ",
      "risk": {
        "score": 70,
        "decision": "review",
        "reasons": [
          { "code": "abuse_ecosystem_member", "weight": 45, "identifier": "email" },
          { "code": "credential_list_exposure", "weight": 25, "identifier": "email" }
        ]
      },
      "decisions_by_flow": { "account_creation": "review", "login": "review", "purchase": "review" },
      "assessment_id": "asm_01JB3C4D5E6F7G8H9JKMNPQRST",
      "updated_at": "2026-10-18T11:00:04.000Z"
    },
    "detail": {
      "schema_version": "1",
      "assessment_id": "asm_01JB3C4D5E6F7G8H9JKMNPQRST",
      "account_id": "user_2pQ8mX1vRbK7nL4tYc9wZ3dF6hJ",
      "status": "complete",
      "recommendation": "review",
      "confidence": "high",
      "risk": {
        "score": 70,
        "band": "high",
        "recommendation": "review",
        "block": false,
        "confidence": "high",
        "policy_version": "risk-2026-10.3",
        "policy_basis": "heuristic_unvalidated",
        "reasons": [
          { "code": "abuse_ecosystem_member", "weight": 45, "identifier": "email" },
          { "code": "credential_list_exposure", "weight": 25, "identifier": "email" }
        ],
        "summary": {
          "email": {
            "disposable": false,
            "accounts_found": 0,
            "breach": { "found": true, "breach_count": 3, "in_credential_lists": true, "recent_password_exposure": false, "truncated": false }
          }
        },
        "coverage": { "answered": 6, "failed": 0, "pending": 0 }
      },
      "incomplete_sources": [],
      "policy_version": "risk-2026-10.3",
      "policy_basis": "heuristic_unvalidated",
      "assessment_version": 2,
      "created_at": "2026-10-18T11:00:00.000Z",
      "updated_at": "2026-10-18T11:00:04.000Z",
      "expires_at": "2027-10-18T11:00:00.000Z",
      "timing": { "deadline_ms": 3000, "elapsed_ms": 4000 }
    }
  }
}
```

`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](https://docs.noresponse.org/reference/assessment.md) defines every field, including `band`, `coverage` and `policy_basis`. [Reason codes](https://docs.noresponse.org/reference/reason-codes.md) lists the reasons and their weights. [Webhooks](https://docs.noresponse.org/guides/webhooks.md) 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.decision` and `detail.assessmentVersion`.
* Python: `account.risk.decision` and `detail.assessment_version`.
* Go: `Account.Risk.Decision` and `Detail.AssessmentVersion`.

## What the handler does

The handler is `handleLateResult` 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:

1. It verifies the delivery before it does anything else. A delivery that fails verification raises an error before any provider call.
2. It reads `account.risk.decision`. An event without `account` causes no action.
3. It finds the decision in your policy and runs the action on the user.

The result holds the verified `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.

| Account decision | Default action | Why |
| - | - | - |
| `allow` | `none` | Nothing to undo. |
| `step_up` | `none` | The user is already in. Map it to `revoke_sessions` to send the user back through your login gate. |
| `review` | `revoke_sessions` | Every session ends, and the user must go through login again. |
| `block` | `revoke_sessions` | The score reached your block threshold. Every session ends. Map it to `ban` to refuse the user completely. |

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.

| Provider | `revoke_sessions` | `ban` | Credential |
| - | - | - | - |
| Clerk | Lists the user's active sessions and revokes each one. | `POST /users/{id}/ban`: every session ends, and Clerk refuses the user's next login. To undo it, unban the user in the dashboard. | The secret key, `sk_live_` or `sk_test_`, from **API keys** in the Clerk dashboard. |
| WorkOS | Lists the user's sessions and revokes each active one. | WorkOS has no ban. The handler revokes every session and then deletes the user. Deletion is permanent. | The API key, `sk_`, from **API Keys** in the WorkOS dashboard. |
| Auth0 | Deletes every session, then every refresh token of the user. On a plan without the sessions API, it blocks the user and reports `ban` (see below). | Sets `blocked: true`, then deletes the sessions and refresh tokens. To undo it, unblock the user in the dashboard. | A Management API token for a machine-to-machine application with the `update:users`, `delete:sessions`, `delete:refresh_tokens`, `read:device_credentials` and `delete:device_credentials` scopes. |

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.

> [!CAUTION]
>
> **A WorkOS ban deletes the user**
>
> With 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.

> [!WARNING]
>
> **Auth0 plans without the sessions API**
>
> Auth0 offers the endpoints that delete sessions and refresh tokens on Enterprise plans only. Other plans refuse them with `403 feature_not_enabled`. The Auth0 handler then deletes each refresh token through `/api/v2/device-credentials`, which every plan has. It cannot end a login session there, so `revoke_sessions` blocks the user, and `result.action` is `ban`. To let the user back in, unblock the user in the dashboard. The [Auth0 page](https://docs.noresponse.org/integrations/auth0.md#late-results) has the details.

When the admin API of the provider refuses a call, the handler raises `ProviderActionError` with the provider's status code. Answer the webhook with a 5xx, and NoResponse delivers it again on its [retry schedule](https://docs.noresponse.org/guides/webhooks.md#delivery-and-retries).

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.verification` event with action `none`. Return the challenge with the SDK's verification response helper, as [Webhooks](https://docs.noresponse.org/guides/webhooks.md#answer-endpoint-verification) shows.
* **Skip test events.** `POST /v1/webhooks/{endpoint_id}/test` sends an event with `"test": true`. Its fixed example assessment has the `detail.account_id` `acct_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 as `event.test` in TypeScript and Python, `event.Test` in Go and C#, `event.isTest()` in Java, `$event->test` in PHP and `event.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_version` is 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 late `review` at version 2 would revoke a user whom version 3 cleared.
* **Forget an expired assessment.** The last event for an assessment is an `assessment.updated` with `status: "expired"`. It carries the last version served and no assessment body. The SDKs give it its own type, which is not an `Assessment`: `AssessmentExpiredEvent` in TypeScript, and `AssessmentExpired` data 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.

The handler gets the provider user id from a callback: `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.

| Method | TypeScript | Python | Go |
| - | - | - | - |
| `held` | `held(assessmentId: string): Promise<number>` | `held(assessment_id: str) -> int` | `Held(ctx context.Context, assessmentID string) (int64, error)` |
| `record` | `record(assessmentId: string, version: number): Promise<void>` | `record(assessment_id: str, version: int) -> None` | `Record(assessmentID string, version int64)` |
| `forget` | `forget(assessmentId: string): Promise<void>` | `forget(assessment_id: str) -> None` | `Forget(assessmentID string)` |

`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:

```sql title="Versions store in SQL" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
CREATE TABLE noresponse_versions (assessment_id text PRIMARY KEY, version bigint NOT NULL);

-- held
SELECT coalesce(max(version), 0) FROM noresponse_versions WHERE assessment_id = $1;
-- record
INSERT INTO noresponse_versions (assessment_id, version) VALUES ($1, $2)
ON CONFLICT (assessment_id) DO UPDATE SET version = greatest(noresponse_versions.version, excluded.version);
-- forget
DELETE FROM noresponse_versions WHERE assessment_id = $1;
```

The receiver records a version after the handler returns. Two deliveries can overlap, for example a retry that arrives while the first attempt still calls the provider. Both pass the version comparison, and both act. Two revokes, or two bans, end in the same state. A WorkOS `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](https://docs.noresponse.org/guides/webhooks.md#register-an-endpoint), you got a signing key id and secret. Store them as `NORESPONSE_WEBHOOK_KEY_ID` and `NORESPONSE_WEBHOOK_SECRET`. The secrets map takes one entry per key id. During a [secret rotation](https://docs.noresponse.org/guides/webhooks.md#rotate-the-signing-secret), 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**

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.

```ts title="TypeScript" highlight={18} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import {
  clerkActions,
  handleLateResult,
  isExpiredEvent,
  ProviderActionError,
  verificationResponse,
  WebhookVerificationError,
} from "@noresponse/node";

const secrets = { [process.env.NORESPONSE_WEBHOOK_KEY_ID!]: process.env.NORESPONSE_WEBHOOK_SECRET! };
const clerk = clerkActions({ secretKey: process.env.CLERK_SECRET_KEY! });

export async function POST(request: Request): Promise<Response> {
  const rawBody = new Uint8Array(await request.arrayBuffer());
  let result;
  try {
    result = await handleLateResult(rawBody, request.headers, secrets, clerk, {
      policy: { review: "revoke_sessions", block: "revoke_sessions" }, // "ban" to ban instead
      userId: async (accountId, event) => {
        if (event.type !== "assessment.updated" || isExpiredEvent(event) || event.test) return null;
        if (event.data.detail.assessmentVersion <= (await versions.held(event.data.id))) return null;
        return accountId; // the Clerk user id you sent as account_id
      },
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
    if (error instanceof ProviderActionError) return new Response(null, { status: 502 }); // delivered again later
    throw error;
  }
  const { event } = result;
  if (event.type === "endpoint.verification") return Response.json(verificationResponse(event));
  if (
    event.type === "account.deleted" ||
    event.type === "workspace.deleted" ||
    event.type === "webhook_endpoint.disabled" ||
    event.type === "account.risk_changed"
  ) {
    return new Response(null, { status: 204 }); // see Webhooks
  }
  if (isExpiredEvent(event)) await versions.forget(event.data.assessmentId);
  else if (!event.test) await versions.record(event.data.id, event.data.detail.assessmentVersion);
  return new Response(null, { status: 204 });
}
```

```python title="Python" highlight={21} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
from collections.abc import Mapping

from fastapi import FastAPI, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from noresponse import (
    Assessment,
    AssessmentExpired,
    LateResult,
    ProviderActionError,
    WebhookEvent,
    WebhookVerificationError,
    clerk_actions,
    handle_late_result,
    verification_response,
)

SECRETS = {os.environ["NORESPONSE_WEBHOOK_KEY_ID"]: os.environ["NORESPONSE_WEBHOOK_SECRET"]}
CLERK = clerk_actions(os.environ["CLERK_SECRET_KEY"])
POLICY = {"review": "revoke_sessions", "block": "revoke_sessions"}  # "ban" to ban instead
app = FastAPI()


def clerk_user_id(account_id: str, event: WebhookEvent) -> str | None:
    data = event.data
    if event.test or not isinstance(data, Assessment):
        return None
    if data.detail.assessment_version <= versions.held(data.id):
        return None
    return account_id  # the Clerk user id you sent as account_id


def late_result(raw_body: bytes, headers: Mapping[str, str]) -> LateResult:
    return handle_late_result(raw_body, headers, SECRETS, CLERK, policy=POLICY, resolve_user_id=clerk_user_id)


@app.post("/webhooks/noresponse")
async def noresponse_webhook(request: Request) -> Response:
    raw_body = await request.body()
    try:
        # The handler calls Clerk with blocking HTTP, so it runs off the event loop.
        result = await run_in_threadpool(late_result, raw_body, request.headers)
    except WebhookVerificationError:
        return Response(status_code=400)
    except ProviderActionError:
        return Response(status_code=502)  # delivered again later
    event = result.event
    data = event.data
    if event.type == "endpoint.verification":
        return JSONResponse(verification_response(event))
    if isinstance(data, AssessmentExpired):
        versions.forget(data.assessment_id)
    elif isinstance(data, Assessment) and not event.test:
        versions.record(data.id, data.detail.assessment_version)
    return Response(status_code=204)
```

```go title="Go" highlight={19-22} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package main

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"os"

	noresponse "github.com/noresponsehq/go/v5"
	"github.com/noresponsehq/go/v5/models/components"
)

var (
	secrets = map[string]string{os.Getenv("NORESPONSE_WEBHOOK_KEY_ID"): os.Getenv("NORESPONSE_WEBHOOK_SECRET")}
	clerk   = &noresponse.ClerkActions{SecretKey: os.Getenv("CLERK_SECRET_KEY")}
	options = &noresponse.LateResultOptions{
		// noresponse.LateResultBan to ban instead.
		Policy: noresponse.LateResultPolicy{
			components.AccountDecisionReview: noresponse.LateResultRevokeSessions,
			components.AccountDecisionBlock:  noresponse.LateResultRevokeSessions,
		},
		ResolveUserID: clerkUserID,
	}
)

// clerkUserID returns the Clerk user id to act on, or "" to take no action.
// An error, such as a failed store read, fails the delivery so it is retried.
func clerkUserID(ctx context.Context, accountID string, event *noresponse.Event) (string, error) {
	a, ok := event.Data.(*components.Assessment)
	if event.Test || !ok {
		return "", nil
	}
	held, err := versions.Held(ctx, a.ID)
	if err != nil || a.Detail.AssessmentVersion <= held {
		return "", err
	}
	return accountID, nil // the Clerk user id you sent as account_id
}

func noresponseWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	result, err := noresponse.HandleLateResult(r.Context(), rawBody, r.Header, secrets, clerk, options)
	var refused *noresponse.ProviderActionError
	switch {
	case errors.Is(err, noresponse.ErrWebhook):
		w.WriteHeader(http.StatusBadRequest)
		return
	case errors.As(err, &refused):
		w.WriteHeader(http.StatusBadGateway) // delivered again later
		return
	case err != nil:
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	switch data := result.Event.Data.(type) {
	case *noresponse.EndpointVerification:
		response, _ := noresponse.VerificationResponse(result.Event)
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(response)
		return
	case *noresponse.AssessmentExpired:
		versions.Forget(data.AssessmentID)
	case *components.Assessment:
		if !result.Event.Test {
			versions.Record(data.ID, data.Detail.AssessmentVersion)
		}
	}
	w.WriteHeader(http.StatusNoContent)
}
```

**WorkOS**

Set `WORKOS_API_KEY` to your WorkOS API key. Assess with the WorkOS user id, `user_...`, as `account_id`. The handler acts on it unchanged. A `ban` policy deletes the user, so these handlers keep `review` on `revoke_sessions`.

```ts title="TypeScript" highlight={18} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import {
  handleLateResult,
  isExpiredEvent,
  ProviderActionError,
  verificationResponse,
  WebhookVerificationError,
  workosActions,
} from "@noresponse/node";

const secrets = { [process.env.NORESPONSE_WEBHOOK_KEY_ID!]: process.env.NORESPONSE_WEBHOOK_SECRET! };
const workos = workosActions({ apiKey: process.env.WORKOS_API_KEY! });

export async function POST(request: Request): Promise<Response> {
  const rawBody = new Uint8Array(await request.arrayBuffer());
  let result;
  try {
    result = await handleLateResult(rawBody, request.headers, secrets, workos, {
      policy: { review: "revoke_sessions", block: "revoke_sessions" }, // "ban" deletes the WorkOS user
      userId: async (accountId, event) => {
        if (event.type !== "assessment.updated" || isExpiredEvent(event) || event.test) return null;
        if (event.data.detail.assessmentVersion <= (await versions.held(event.data.id))) return null;
        return accountId; // the WorkOS user id you sent as account_id
      },
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
    if (error instanceof ProviderActionError) return new Response(null, { status: 502 }); // delivered again later
    throw error;
  }
  const { event } = result;
  if (event.type === "endpoint.verification") return Response.json(verificationResponse(event));
  if (
    event.type === "account.deleted" ||
    event.type === "workspace.deleted" ||
    event.type === "webhook_endpoint.disabled" ||
    event.type === "account.risk_changed"
  ) {
    return new Response(null, { status: 204 }); // see Webhooks
  }
  if (isExpiredEvent(event)) await versions.forget(event.data.assessmentId);
  else if (!event.test) await versions.record(event.data.id, event.data.detail.assessmentVersion);
  return new Response(null, { status: 204 });
}
```

```python title="Python" highlight={21} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
from collections.abc import Mapping

from fastapi import FastAPI, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from noresponse import (
    Assessment,
    AssessmentExpired,
    LateResult,
    ProviderActionError,
    WebhookEvent,
    WebhookVerificationError,
    handle_late_result,
    verification_response,
    workos_actions,
)

SECRETS = {os.environ["NORESPONSE_WEBHOOK_KEY_ID"]: os.environ["NORESPONSE_WEBHOOK_SECRET"]}
WORKOS = workos_actions(os.environ["WORKOS_API_KEY"])
POLICY = {"review": "revoke_sessions", "block": "revoke_sessions"}  # "ban" deletes the WorkOS user
app = FastAPI()


def workos_user_id(account_id: str, event: WebhookEvent) -> str | None:
    data = event.data
    if event.test or not isinstance(data, Assessment):
        return None
    if data.detail.assessment_version <= versions.held(data.id):
        return None
    return account_id  # the WorkOS user id you sent as account_id


def late_result(raw_body: bytes, headers: Mapping[str, str]) -> LateResult:
    return handle_late_result(raw_body, headers, SECRETS, WORKOS, policy=POLICY, resolve_user_id=workos_user_id)


@app.post("/webhooks/noresponse")
async def noresponse_webhook(request: Request) -> Response:
    raw_body = await request.body()
    try:
        # The handler calls WorkOS with blocking HTTP, so it runs off the event loop.
        result = await run_in_threadpool(late_result, raw_body, request.headers)
    except WebhookVerificationError:
        return Response(status_code=400)
    except ProviderActionError:
        return Response(status_code=502)  # delivered again later
    event = result.event
    data = event.data
    if event.type == "endpoint.verification":
        return JSONResponse(verification_response(event))
    if isinstance(data, AssessmentExpired):
        versions.forget(data.assessment_id)
    elif isinstance(data, Assessment) and not event.test:
        versions.record(data.id, data.detail.assessment_version)
    return Response(status_code=204)
```

```go title="Go" highlight={19-22} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package main

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"os"

	noresponse "github.com/noresponsehq/go/v5"
	"github.com/noresponsehq/go/v5/models/components"
)

var (
	secrets = map[string]string{os.Getenv("NORESPONSE_WEBHOOK_KEY_ID"): os.Getenv("NORESPONSE_WEBHOOK_SECRET")}
	workos  = &noresponse.WorkOSActions{APIKey: os.Getenv("WORKOS_API_KEY")}
	options = &noresponse.LateResultOptions{
		// noresponse.LateResultBan deletes the WorkOS user.
		Policy: noresponse.LateResultPolicy{
			components.AccountDecisionReview: noresponse.LateResultRevokeSessions,
			components.AccountDecisionBlock:  noresponse.LateResultRevokeSessions,
		},
		ResolveUserID: workosUserID,
	}
)

// workosUserID returns the WorkOS user id to act on, or "" to take no action.
// An error, such as a failed store read, fails the delivery so it is retried.
func workosUserID(ctx context.Context, accountID string, event *noresponse.Event) (string, error) {
	a, ok := event.Data.(*components.Assessment)
	if event.Test || !ok {
		return "", nil
	}
	held, err := versions.Held(ctx, a.ID)
	if err != nil || a.Detail.AssessmentVersion <= held {
		return "", err
	}
	return accountID, nil // the WorkOS user id you sent as account_id
}

func noresponseWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	result, err := noresponse.HandleLateResult(r.Context(), rawBody, r.Header, secrets, workos, options)
	var refused *noresponse.ProviderActionError
	switch {
	case errors.Is(err, noresponse.ErrWebhook):
		w.WriteHeader(http.StatusBadRequest)
		return
	case errors.As(err, &refused):
		w.WriteHeader(http.StatusBadGateway) // delivered again later
		return
	case err != nil:
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	switch data := result.Event.Data.(type) {
	case *noresponse.EndpointVerification:
		response, _ := noresponse.VerificationResponse(result.Event)
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(response)
		return
	case *noresponse.AssessmentExpired:
		versions.Forget(data.AssessmentID)
	case *components.Assessment:
		if !result.Event.Test {
			versions.Record(data.ID, data.Detail.AssessmentVersion)
		}
	}
	w.WriteHeader(http.StatusNoContent)
}
```

**Auth0**

Create a machine-to-machine application in Auth0. Authorize it for the Auth0 Management API with the `update:users`, `delete:sessions`, `delete:refresh_tokens`, `read:device_credentials` and `delete:device_credentials` scopes. Then set `AUTH0_DOMAIN`, `AUTH0_M2M_CLIENT_ID` and `AUTH0_M2M_CLIENT_SECRET`.

A Management API token expires. Thus each handler gets a token with the client credentials grant when it first acts, after it verifies the delivery. It uses the token again until one minute before the token expires. Assess with the Auth0 user id, such as `auth0|64abc0de1234567890abcdef`, as `account_id`. The handler acts on it unchanged.

```ts title="TypeScript" highlight={49} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import {
  auth0Actions,
  handleLateResult,
  isExpiredEvent,
  type LateResultActor,
  ProviderActionError,
  verificationResponse,
  WebhookVerificationError,
} from "@noresponse/node";

const secrets = { [process.env.NORESPONSE_WEBHOOK_KEY_ID!]: process.env.NORESPONSE_WEBHOOK_SECRET! };
const domain = process.env.AUTH0_DOMAIN!;
let cachedToken: { value: string; expiresAt: number } | undefined;

// A Management API token from the client credentials grant, reused until a minute before it expires.
async function auth0Token(): Promise<string> {
  if (cachedToken && Date.now() < cachedToken.expiresAt) return cachedToken.value;
  const response = await fetch(`https://${domain}/oauth/token`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      grant_type: "client_credentials",
      client_id: process.env.AUTH0_M2M_CLIENT_ID,
      client_secret: process.env.AUTH0_M2M_CLIENT_SECRET,
      audience: `https://${domain}/api/v2/`,
    }),
    signal: AbortSignal.timeout(3_000),
  });
  if (!response.ok) throw new Error(`Auth0 token request answered ${response.status}`);
  const grant = (await response.json()) as { access_token: string; expires_in: number };
  cachedToken = { value: grant.access_token, expiresAt: Date.now() + (grant.expires_in - 60) * 1000 };
  return cachedToken.value;
}

// Fetches the token only when the handler acts, after the delivery is verified.
// act reports the action that ran: "ban" when the plan has no sessions API.
const auth0: LateResultActor = {
  name: "auth0",
  revokeSessions: async (userId) => auth0Actions({ domain, token: await auth0Token() }).revokeSessions(userId),
  ban: async (userId) => auth0Actions({ domain, token: await auth0Token() }).ban(userId),
  act: async (action, userId) => auth0Actions({ domain, token: await auth0Token() }).act(action, userId),
};

export async function POST(request: Request): Promise<Response> {
  const rawBody = new Uint8Array(await request.arrayBuffer());
  let result;
  try {
    result = await handleLateResult(rawBody, request.headers, secrets, auth0, {
      policy: { review: "revoke_sessions", block: "revoke_sessions" }, // "ban" to block the user instead
      userId: async (accountId, event) => {
        if (event.type !== "assessment.updated" || isExpiredEvent(event) || event.test) return null;
        if (event.data.detail.assessmentVersion <= (await versions.held(event.data.id))) return null;
        return accountId; // the Auth0 user id you sent as account_id
      },
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
    if (error instanceof ProviderActionError) return new Response(null, { status: 502 }); // delivered again later
    throw error;
  }
  const { event } = result;
  if (event.type === "endpoint.verification") return Response.json(verificationResponse(event));
  if (
    event.type === "account.deleted" ||
    event.type === "workspace.deleted" ||
    event.type === "webhook_endpoint.disabled" ||
    event.type === "account.risk_changed"
  ) {
    return new Response(null, { status: 204 }); // see Webhooks
  }
  if (isExpiredEvent(event)) await versions.forget(event.data.assessmentId);
  else if (!event.test) await versions.record(event.data.id, event.data.detail.assessmentVersion);
  return new Response(null, { status: 204 });
}
```

```python title="Python" highlight={24} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
import time
from collections.abc import Mapping

import requests
from fastapi import FastAPI, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from noresponse import (
    Assessment,
    AssessmentExpired,
    LateResult,
    LateResultProvider,
    ProviderActionError,
    WebhookEvent,
    WebhookVerificationError,
    auth0_actions,
    handle_late_result,
    verification_response,
)

SECRETS = {os.environ["NORESPONSE_WEBHOOK_KEY_ID"]: os.environ["NORESPONSE_WEBHOOK_SECRET"]}
AUTH0_DOMAIN = os.environ["AUTH0_DOMAIN"]
POLICY = {"review": "revoke_sessions", "block": "revoke_sessions"}  # "ban" to block the user instead
app = FastAPI()
_token: tuple[str, float] | None = None


def auth0_token() -> str:
    """A Management API token from the client credentials grant, reused until a minute before it expires."""
    global _token
    if _token is not None and time.time() < _token[1]:
        return _token[0]
    response = requests.post(
        f"https://{AUTH0_DOMAIN}/oauth/token",
        json={
            "grant_type": "client_credentials",
            "client_id": os.environ["AUTH0_M2M_CLIENT_ID"],
            "client_secret": os.environ["AUTH0_M2M_CLIENT_SECRET"],
            "audience": f"https://{AUTH0_DOMAIN}/api/v2/",
        },
        timeout=3,
    )
    response.raise_for_status()
    grant = response.json()
    _token = (str(grant["access_token"]), time.time() + int(grant["expires_in"]) - 60)
    return _token[0]


class Auth0(LateResultProvider):
    """Fetches the token only when the handler acts, after the delivery is verified."""

    name = "auth0"

    def revoke_sessions(self, user_id: str) -> None:
        auth0_actions(auth0_token(), domain=AUTH0_DOMAIN).revoke_sessions(user_id)

    def ban(self, user_id: str) -> None:
        auth0_actions(auth0_token(), domain=AUTH0_DOMAIN).ban(user_id)

    def act(self, action: str, user_id: str) -> str:
        """Reports the action that ran: "ban" when the plan has no sessions API."""
        return auth0_actions(auth0_token(), domain=AUTH0_DOMAIN).act(action, user_id)


AUTH0 = Auth0()


def auth0_user_id(account_id: str, event: WebhookEvent) -> str | None:
    data = event.data
    if event.test or not isinstance(data, Assessment):
        return None
    if data.detail.assessment_version <= versions.held(data.id):
        return None
    return account_id  # the Auth0 user id you sent as account_id


def late_result(raw_body: bytes, headers: Mapping[str, str]) -> LateResult:
    return handle_late_result(raw_body, headers, SECRETS, AUTH0, policy=POLICY, resolve_user_id=auth0_user_id)


@app.post("/webhooks/noresponse")
async def noresponse_webhook(request: Request) -> Response:
    raw_body = await request.body()
    try:
        # The handler calls Auth0 with blocking HTTP, so it runs off the event loop.
        result = await run_in_threadpool(late_result, raw_body, request.headers)
    except WebhookVerificationError:
        return Response(status_code=400)
    except ProviderActionError:
        return Response(status_code=502)  # delivered again later
    event = result.event
    data = event.data
    if event.type == "endpoint.verification":
        return JSONResponse(verification_response(event))
    if isinstance(data, AssessmentExpired):
        versions.forget(data.assessment_id)
    elif isinstance(data, Assessment) and not event.test:
        versions.record(data.id, data.detail.assessment_version)
    return Response(status_code=204)
```

```go title="Go" highlight={23-26} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"sync"
	"time"

	noresponse "github.com/noresponsehq/go/v5"
	"github.com/noresponsehq/go/v5/models/components"
)

var (
	secrets     = map[string]string{os.Getenv("NORESPONSE_WEBHOOK_KEY_ID"): os.Getenv("NORESPONSE_WEBHOOK_SECRET")}
	auth0Domain = os.Getenv("AUTH0_DOMAIN")
	options     = &noresponse.LateResultOptions{
		// noresponse.LateResultBan to block the user instead.
		Policy: noresponse.LateResultPolicy{
			components.AccountDecisionReview: noresponse.LateResultRevokeSessions,
			components.AccountDecisionBlock:  noresponse.LateResultRevokeSessions,
		},
		ResolveUserID: auth0UserID,
	}

	tokenClient  = &http.Client{Timeout: 3 * time.Second}
	tokenMu      sync.Mutex
	cachedToken  string
	tokenExpires time.Time
)

// auth0Token returns a Management API token from the client credentials
// grant, reused until a minute before it expires.
func auth0Token(ctx context.Context) (string, error) {
	tokenMu.Lock()
	defer tokenMu.Unlock()
	if cachedToken != "" && time.Now().Before(tokenExpires) {
		return cachedToken, nil
	}
	body, err := json.Marshal(map[string]string{
		"grant_type":    "client_credentials",
		"client_id":     os.Getenv("AUTH0_M2M_CLIENT_ID"),
		"client_secret": os.Getenv("AUTH0_M2M_CLIENT_SECRET"),
		"audience":      "https://" + auth0Domain + "/api/v2/",
	})
	if err != nil {
		return "", err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://"+auth0Domain+"/oauth/token", bytes.NewReader(body))
	if err != nil {
		return "", err
	}
	req.Header.Set("Content-Type", "application/json")
	resp, err := tokenClient.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("auth0 token request answered %d", resp.StatusCode)
	}
	var grant struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int    `json:"expires_in"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&grant); err != nil {
		return "", err
	}
	cachedToken = grant.AccessToken
	tokenExpires = time.Now().Add(time.Duration(grant.ExpiresIn-60) * time.Second)
	return cachedToken, nil
}

// lazyAuth0 fetches the token only when the handler acts, after the delivery
// is verified.
type lazyAuth0 struct{}

func (lazyAuth0) Name() string { return "auth0" }

func (lazyAuth0) RevokeSessions(ctx context.Context, userID string) error {
	token, err := auth0Token(ctx)
	if err != nil {
		return err
	}
	return (&noresponse.Auth0Actions{Domain: auth0Domain, Token: token}).RevokeSessions(ctx, userID)
}

func (lazyAuth0) Ban(ctx context.Context, userID string) error {
	token, err := auth0Token(ctx)
	if err != nil {
		return err
	}
	return (&noresponse.Auth0Actions{Domain: auth0Domain, Token: token}).Ban(ctx, userID)
}

// Act reports the action that ran: LateResultBan when the plan has no
// sessions API.
func (lazyAuth0) Act(ctx context.Context, action noresponse.LateResultAction, userID string) (noresponse.LateResultAction, error) {
	token, err := auth0Token(ctx)
	if err != nil {
		return noresponse.LateResultNone, err
	}
	return (&noresponse.Auth0Actions{Domain: auth0Domain, Token: token}).Act(ctx, action, userID)
}

// auth0UserID returns the Auth0 user id to act on, or "" to take no action.
// An error, such as a failed store read, fails the delivery so it is retried.
func auth0UserID(ctx context.Context, accountID string, event *noresponse.Event) (string, error) {
	a, ok := event.Data.(*components.Assessment)
	if event.Test || !ok {
		return "", nil
	}
	held, err := versions.Held(ctx, a.ID)
	if err != nil || a.Detail.AssessmentVersion <= held {
		return "", err
	}
	return accountID, nil // the Auth0 user id you sent as account_id
}

func noresponseWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	result, err := noresponse.HandleLateResult(r.Context(), rawBody, r.Header, secrets, lazyAuth0{}, options)
	var refused *noresponse.ProviderActionError
	switch {
	case errors.Is(err, noresponse.ErrWebhook):
		w.WriteHeader(http.StatusBadRequest)
		return
	case errors.As(err, &refused):
		w.WriteHeader(http.StatusBadGateway) // delivered again later
		return
	case err != nil:
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	switch data := result.Event.Data.(type) {
	case *noresponse.EndpointVerification:
		response, _ := noresponse.VerificationResponse(result.Event)
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(response)
		return
	case *noresponse.AssessmentExpired:
		versions.Forget(data.AssessmentID)
	case *components.Assessment:
		if !result.Event.Test {
			versions.Record(data.ID, data.Detail.AssessmentVersion)
		}
	}
	w.WriteHeader(http.StatusNoContent)
}
```

**Your own login**

With no auth provider, the handler has no admin API to call. These handlers use the SDK's log-only provider. It logs the action and the user id it would act on, and it calls nothing. Assess with your own user id as `account_id`, and the handler logs it unchanged. When you [write your own provider](#write-your-own-provider), pass it in place of the log-only provider.

```ts title="TypeScript" highlight={18} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import {
  handleLateResult,
  isExpiredEvent,
  logOnlyActions,
  ProviderActionError,
  verificationResponse,
  WebhookVerificationError,
} from "@noresponse/node";

const secrets = { [process.env.NORESPONSE_WEBHOOK_KEY_ID!]: process.env.NORESPONSE_WEBHOOK_SECRET! };
const provider = logOnlyActions(); // logs with console.info and calls nothing

export async function POST(request: Request): Promise<Response> {
  const rawBody = new Uint8Array(await request.arrayBuffer());
  let result;
  try {
    result = await handleLateResult(rawBody, request.headers, secrets, provider, {
      policy: { review: "revoke_sessions" }, // "ban" to log a ban instead
      userId: async (accountId, event) => {
        if (event.type !== "assessment.updated" || isExpiredEvent(event) || event.test) return null;
        if (event.data.detail.assessmentVersion <= (await versions.held(event.data.id))) return null;
        return accountId; // your own user id, sent as account_id
      },
    });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(null, { status: 400 });
    if (error instanceof ProviderActionError) return new Response(null, { status: 502 }); // delivered again later
    throw error;
  }
  const { event } = result;
  if (event.type === "endpoint.verification") return Response.json(verificationResponse(event));
  if (
    event.type === "account.deleted" ||
    event.type === "workspace.deleted" ||
    event.type === "webhook_endpoint.disabled" ||
    event.type === "account.risk_changed"
  ) {
    return new Response(null, { status: 204 }); // see Webhooks
  }
  if (isExpiredEvent(event)) await versions.forget(event.data.assessmentId);
  else if (!event.test) await versions.record(event.data.id, event.data.detail.assessmentVersion);
  return new Response(null, { status: 204 });
}
```

```python title="Python" highlight={21} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os
from collections.abc import Mapping

from fastapi import FastAPI, Request, Response
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse
from noresponse import (
    Assessment,
    AssessmentExpired,
    LateResult,
    ProviderActionError,
    WebhookEvent,
    WebhookVerificationError,
    handle_late_result,
    log_only_actions,
    verification_response,
)

SECRETS = {os.environ["NORESPONSE_WEBHOOK_KEY_ID"]: os.environ["NORESPONSE_WEBHOOK_SECRET"]}
PROVIDER = log_only_actions()  # logs to the noresponse.late_results logger and calls nothing
POLICY = {"review": "revoke_sessions"}  # "ban" to log a ban instead
app = FastAPI()


def own_user_id(account_id: str, event: WebhookEvent) -> str | None:
    data = event.data
    if event.test or not isinstance(data, Assessment):
        return None
    if data.detail.assessment_version <= versions.held(data.id):
        return None
    return account_id  # your own user id, sent as account_id


def late_result(raw_body: bytes, headers: Mapping[str, str]) -> LateResult:
    return handle_late_result(raw_body, headers, SECRETS, PROVIDER, policy=POLICY, resolve_user_id=own_user_id)


@app.post("/webhooks/noresponse")
async def noresponse_webhook(request: Request) -> Response:
    raw_body = await request.body()
    try:
        # Your own provider may block on your database, so the handler runs off the event loop.
        result = await run_in_threadpool(late_result, raw_body, request.headers)
    except WebhookVerificationError:
        return Response(status_code=400)
    except ProviderActionError:
        return Response(status_code=502)  # delivered again later
    event = result.event
    data = event.data
    if event.type == "endpoint.verification":
        return JSONResponse(verification_response(event))
    if isinstance(data, AssessmentExpired):
        versions.forget(data.assessment_id)
    elif isinstance(data, Assessment) and not event.test:
        versions.record(data.id, data.detail.assessment_version)
    return Response(status_code=204)
```

```go title="Go" highlight={19-20} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package main

import (
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"os"

	noresponse "github.com/noresponsehq/go/v5"
	"github.com/noresponsehq/go/v5/models/components"
)

var (
	secrets  = map[string]string{os.Getenv("NORESPONSE_WEBHOOK_KEY_ID"): os.Getenv("NORESPONSE_WEBHOOK_SECRET")}
	provider = &noresponse.LogOnlyActions{} // logs to slog.Default() and calls nothing
	options  = &noresponse.LateResultOptions{
		// noresponse.LateResultBan to log a ban instead.
		Policy: noresponse.LateResultPolicy{components.AccountDecisionReview: noresponse.LateResultRevokeSessions},
		ResolveUserID: ownUserID,
	}
)

// ownUserID returns your own user id to act on, or "" to take no action.
// An error, such as a failed store read, fails the delivery so it is retried.
func ownUserID(ctx context.Context, accountID string, event *noresponse.Event) (string, error) {
	a, ok := event.Data.(*components.Assessment)
	if event.Test || !ok {
		return "", nil
	}
	held, err := versions.Held(ctx, a.ID)
	if err != nil || a.Detail.AssessmentVersion <= held {
		return "", err
	}
	return accountID, nil // your own user id, sent as account_id
}

func noresponseWebhook(w http.ResponseWriter, r *http.Request) {
	rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	result, err := noresponse.HandleLateResult(r.Context(), rawBody, r.Header, secrets, provider, options)
	var refused *noresponse.ProviderActionError
	switch {
	case errors.Is(err, noresponse.ErrWebhook):
		w.WriteHeader(http.StatusBadRequest)
		return
	case errors.As(err, &refused):
		w.WriteHeader(http.StatusBadGateway) // delivered again later
		return
	case err != nil:
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	switch data := result.Event.Data.(type) {
	case *noresponse.EndpointVerification:
		response, _ := noresponse.VerificationResponse(result.Event)
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(response)
		return
	case *noresponse.AssessmentExpired:
		versions.Forget(data.AssessmentID)
	case *components.Assessment:
		if !result.Event.Test {
			versions.Record(data.ID, data.Detail.AssessmentVersion)
		}
	}
	w.WriteHeader(http.StatusNoContent)
}
```

A delivery has 5 seconds to get an answer. The handler makes one provider request per active session. Thus the token request in the Auth0 handlers stops after 3 seconds. NoResponse counts a delivery that runs longer as failed and retries it.

The first attempt can finish and record its version before the retry arrives. Then the retry takes no action. If not, the retry acts again, as [The versions store](#the-versions-store) describes.

## 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.

| SDK | Log-only provider | Default log destination |
| - | - | - |
| TypeScript | `logOnlyActions()` | `console.info` |
| Python | `log_only_actions()` | the `noresponse.late_results` logger, at WARNING |
| Go | `&noresponse.LogOnlyActions{}` | `slog.Default()`, at Info |
| Java | `new LogOnlyActions()` | the `org.noresponse.lateresults` `System.Logger`, at INFO |
| C# | `LateResults.LogOnly()` | standard output |
| PHP | `new LogOnlyProvider()` | `error_log()` |
| Ruby | `NoResponse::LateResults.log_only` | standard error |

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:

```text title="Log output" nocopy theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
NoResponse late result (log only): would revoke every session of user usr_8f2k1
```

Use the log-only provider while you decide what revoking and banning mean on your platform. Then replace it with your own provider.

### Write your own provider

A provider is a name and two methods. Each method gets the user id from `account_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](https://docs.noresponse.org/guides/webhooks.md#delivery-and-retries).
* **Treat a missing user as done.** A user that you deleted after account creation has nothing left to revoke.

```ts title="TypeScript" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import type { LateResultProvider } from "@noresponse/node";

export const ownLogin: LateResultProvider = {
  name: "own_login",
  async revokeSessions(userId) {
    await sessions.revokeAll(userId); // delete every session row of the user
  },
  async ban(userId) {
    await users.disable(userId); // refuse their next login
    await sessions.revokeAll(userId);
  },
};
```

```python title="Python" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
from noresponse import LateResultProvider


class OwnLogin(LateResultProvider):
    name = "own_login"

    def revoke_sessions(self, user_id: str) -> None:
        sessions.revoke_all(user_id)  # delete every session row of the user

    def ban(self, user_id: str) -> None:
        users.disable(user_id)  # refuse their next login
        sessions.revoke_all(user_id)
```

```go title="Go" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package main

import "context"

// ownLogin is a noresponse.LateResultProvider for your own login.
type ownLogin struct{}

func (ownLogin) Name() string { return "own_login" }

// RevokeSessions deletes every session row of the user.
func (ownLogin) RevokeSessions(ctx context.Context, userID string) error {
	return sessions.RevokeAll(ctx, userID)
}

// Ban refuses the user's next login, then ends their sessions.
func (ownLogin) Ban(ctx context.Context, userID string) error {
	if err := users.Disable(ctx, userID); err != nil {
		return err
	}
	return sessions.RevokeAll(ctx, userID)
}
```

Pass it to the handler in place of the log-only provider: `ownLogin` in TypeScript, `OwnLogin()` in Python and `ownLogin{}` in Go.

## Try it

Send a test event to yourself. Use your `webhooks` key and the `whe_` endpoint id you got when you [registered the endpoint](https://docs.noresponse.org/guides/webhooks.md#register-an-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`.

```bash title="Send a test event" highlight={1} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl https://api.noresponse.org/v1/webhooks/whe_01JA2Z0A1B2C3D4E5F6G7H8J9K/test \
  -X POST \
  -H "Authorization: Bearer $NORESPONSE_WEBHOOKS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event": "assessment.updated" }'
```

To see an action happen, do these steps:

1. Create a user in the development instance of your provider.
2. Start a session as that user.
3. Call the provider object directly with the user's id.

The user can no longer refresh the session. The current token stops working when it expires.

```ts title="TypeScript" highlight={4} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import { clerkActions } from "@noresponse/node";

const clerk = clerkActions({ secretKey: process.env.CLERK_SECRET_KEY! });
await clerk.revokeSessions("user_2pQ8mX1vRbK7nL4tYc9wZ3dF6hJ");
```

```python title="Python" highlight={6} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
import os

from noresponse import clerk_actions

clerk = clerk_actions(os.environ["CLERK_SECRET_KEY"])
clerk.revoke_sessions("user_2pQ8mX1vRbK7nL4tYc9wZ3dF6hJ")
```

```go title="Go" highlight={13} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
package main

import (
	"context"
	"log"
	"os"

	noresponse "github.com/noresponsehq/go/v5"
)

func main() {
	clerk := &noresponse.ClerkActions{SecretKey: os.Getenv("CLERK_SECRET_KEY")}
	if err := clerk.RevokeSessions(context.Background(), "user_2pQ8mX1vRbK7nL4tYc9wZ3dF6hJ"); err != nil {
		log.Fatal(err)
	}
}
```

`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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.