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

# Deny risky accounts at your auth provider

> Point an Auth0 Action or a WorkOS Action at a hosted NoResponse endpoint. Your auth provider then refuses an account creation or a login whose risk score reaches your block threshold. No server of your own.

Your auth provider can ask NoResponse about each account creation and each login before it lets the user in. NoResponse hosts the endpoint that the provider calls. It scores the event and answers allow or deny, and the provider enforces the answer. You run no server for this, and your application code does not change.

Each event goes through these steps:

1. A user creates an account or logs in through Auth0 or WorkOS.
2. The provider's Action calls `POST https://api.noresponse.org/hooks/{integration_id}`.
3. NoResponse scores the event and answers allow or deny.
4. The provider creates the user or lets them in, or it refuses with an error message.

NoResponse supports two auth providers:

* **Auth0**: NoResponse publishes two Actions. One is for the Pre User Registration trigger and one is for the Login trigger. You paste them into your Auth0 tenant and give them two secrets.
* **WorkOS**: the AuthKit user registration and authentication Actions. You point both at the hosted URL. There is no code to install.

The hosted endpoint denies only when the account's decision, `account.risk.decision`, is `block`. No account's decision is `block` until you set a block threshold. Until then NoResponse scores and allows every event. You can install the integration, check the scores of your users' logins, and then turn blocking on.

## How the endpoint decides

Each call creates an assessment from what the provider sends. That is the email, username, phone number, name and IP address. At login it also includes the provider's session facts. Then these rules apply:

* **It waits for the full score.** The endpoint waits for the assessment to complete, then decides on the latest version. It waits at most 2.5 seconds from the request's arrival for WorkOS and 4.5 seconds for Auth0. Sources that are still running at that point keep running. Later versions reach your [webhooks](https://docs.noresponse.org/guides/webhooks.md) as usual.
* **It denies only on `block`.** The answer is deny when the account's decision is `block`. That happens once the score reaches your block threshold. In every other case the answer is allow. The other decisions, the band and the reason codes do not change the answer on their own.
* **It fails open.** Any failure on the NoResponse side answers allow. Examples are a body that NoResponse cannot assess, an unavailable service, or no complete score within the time limit. A request without valid authentication answers 401, and an unknown integration id answers 404. Your provider then applies its own failure setting. When you follow the steps on this page, that setting lets the user through.
* **A retry gets the same answer.** The provider can retry the same event, with the same WorkOS Action id or the same Auth0 transaction id. For 24 hours, the retry gets the same assessment and decision.
* **Both hook points are on by default.** `pre_registration` runs before the user exists, and `post_login` runs after a successful login. An integration can list only one. NoResponse allows an event at a hook point that the integration does not list, and creates no assessment. See [Choose the hook points](#choose-the-hook-points).

At login the assessment uses `flow` `login` and your provider's user id as its `account_id`. The login then shows in that account's history, and the account's current risk carries into the decision. At account creation the assessment uses `flow` `account_creation`. There is no user yet, so its `account_id` is `pre:` and then its own assessment id.

## Set your block threshold

The account's decision is `block` when you set a block threshold and the score is at or above it. The threshold is off by default. While it is off, the endpoint denies nothing.

To turn blocking on, email [info@noresponse.org](mailto:info@noresponse.org) with the score to block from. It can be any score from your review threshold to 100. The review threshold is the score from which the decision is `review`. It is 75, unless we agreed another value with you. A block threshold of 90 denies only the riskiest account creations. A threshold of 75 denies everything we would send to review. Ask us the same way to change it or turn it off.

When you set the threshold, every assessment carries it. This is true for an assessment from a hook and for one from `POST /v1/assessments`.

| Field | Value |
| - | - |
| `account.risk.decision` | `block` when the account's score is at or above the threshold. |
| `detail.risk.block` | `true` when this assessment's `detail.risk.score` is at or above the threshold, otherwise `false`. |
| `detail.risk.block_threshold` | The threshold the answer used. Absent while blocking is off. |
| `detail.risk.policy_version` | Gains a suffix such as `+t2` once we set any of your thresholds. A stored decision then names the thresholds it used. |

If you also call the API from your own backend, act on `account.risk.decision` there and refuse on `block` the same way.

## Before you start

> [!NOTE]
>
> You need an API key with the `integrations` scope, and admin access to your Auth0 tenant or your WorkOS environment. Every key that you get when you create a workspace has the `integrations` scope, as in [Get a key](https://docs.noresponse.org/testing.md#get-a-key).

The key creates and manages integrations, and it stays on your side. The provider holds only the integration's own secret.

The SDK samples read the key from `NORESPONSE_API_KEY`, as in the [Quickstart](https://docs.noresponse.org/quickstart.md):

* In TypeScript, `nr` is `new NoResponse()`.
* In Python, `client` is `NoResponse()`.
* In Go, `client` is `noresponse.NewClient(os.Getenv("NORESPONSE_API_KEY"))`.
* The cURL samples send the key as a bearer token.

An integration belongs to one Auth0 tenant or one WorkOS environment. Create one integration for each tenant or environment that you connect. For example, create one for your development tenant and one for production. Your platform can hold up to 10 integrations.

## Install the integration

**Auth0**

The two Actions call your integration's URL with a shared secret and wait up to 5 seconds. They block the account creation or login only when NoResponse answers deny. They run on Auth0's Node 22 runtime with no npm packages. Auth0 includes Actions on every plan. The Free plan allows five Actions and Forms in total.

| Action | Auth0 trigger | What it does |
| - | - | - |
| `pre-user-registration.js` | Pre User Registration | Refuses the account creation before Auth0 creates the user. |
| `post-login.js` | Login / Post Login | Refuses the login before Auth0 issues tokens. |

Auth0 runs the Pre User Registration trigger only for Database and Passwordless connections. A user who creates an account through a social or enterprise connection meets the Login trigger at their first login. Install the login Action as well to cover them.

### 1. Create the integration

Create an integration with `"platform": "auth0"`. NoResponse generates the shared secret and returns it in `secret`, once. Keep the answer open until both Actions hold it.

```ts TypeScript theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
const integration = await nr.integrations.create({ platform: "auth0" });
console.log(integration.url); // NORESPONSE_HOOK_URL
console.log(integration.secret); // NORESPONSE_HOOK_SECRET, shown once
```

```python Python theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
integration = client.integrations.create(platform="auth0")
print(integration.url)  # NORESPONSE_HOOK_URL
print(integration.secret)  # NORESPONSE_HOOK_SECRET, shown once
```

```go Go theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
res, err := client.Integrations.Create(ctx, components.IntegrationRequest{
	Platform: components.IntegrationPlatformAuth0,
})
if err != nil {
	return err
}
fmt.Println(res.Result.URL)     // NORESPONSE_HOOK_URL
fmt.Println(*res.Result.Secret) // NORESPONSE_HOOK_SECRET, shown once
```

```bash cURL theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl https://api.noresponse.org/v1/integrations \
  -X POST \
  -H "Authorization: Bearer $NORESPONSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "platform": "auth0" }'
```

The answer is 201:

```json Response theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "integration_id": "int_01JA2Z8K6Q4V7W9XBCDEFGHJKM",
  "platform": "auth0",
  "hook_points": ["pre_registration", "post_login"],
  "url": "https://api.noresponse.org/hooks/int_01JA2Z8K6Q4V7W9XBCDEFGHJKM",
  "secret": "nri_k3Yx8pQ2mN7vR4tW6zB9cD1eF5gH0jL4sP9uV2wX7yA",
  "created_at": "2026-10-14T09:00:00.000Z"
}
```

`url` is the value for `NORESPONSE_HOOK_URL`, and `secret` is the value for `NORESPONSE_HOOK_SECRET`. The secret starts with `nri_`, and NoResponse does not show it again. If you lose it, [rotate it](#rotate-a-secret).

### 2. Install the Actions

Install each Action in the Auth0 dashboard. To install both at once, use the [Management API script](#install-with-the-management-api).

> **pre-user-registration.js**
>
> ```js title="pre-user-registration.js" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
> /**
>  * NoResponse risk check at account creation, for the Auth0 Pre User
>  * Registration trigger.
>  *
>  * Sends the new account to your NoResponse integration endpoint and blocks it
>  * only when NoResponse answers "deny". Any failure, slow answer or unexpected
>  * reply lets the account through, so NoResponse can never stop your users
>  * creating accounts on its own.
>  *
>  * Secrets (Action editor, Secrets tab):
>  *   NORESPONSE_HOOK_URL     the integration URL, https://api.noresponse.org/hooks/<integration id>
>  *   NORESPONSE_HOOK_SECRET  the shared secret shown when you created the integration (nri_...)
>  *   NORESPONSE_TIMEOUT_MS   optional, how long to wait in milliseconds (default 5000, at most 8000)
>  *
>  * Runtime: Node 22. No npm dependencies.
>  */
>
> const HOOK_POINT = "pre_registration";
> const DEFAULT_TIMEOUT_MS = 5000;
> const MAX_TIMEOUT_MS = 8000; // Auth0 can end a run at about 10 seconds.
> const USER_MESSAGE = "We could not create your account. Please contact support.";
>
> exports.onExecutePreUserRegistration = async (event, api) => {
>   const verdict = await askNoResponse(event, HOOK_POINT);
>   if (verdict.deny) {
>     api.access.deny("noresponse_risk_block", USER_MESSAGE);
>   }
> };
>
> // ---- Shared with post-login.js: keep the two copies identical. ----
>
> async function askNoResponse(event, hookPoint) {
>   const secrets = event.secrets || {};
>   const url = secrets.NORESPONSE_HOOK_URL;
>   const secret = secrets.NORESPONSE_HOOK_SECRET;
>   if (!url || !secret) {
>     return allow(hookPoint, "not configured");
>   }
>   if (!safeHookUrl(url)) {
>     return allow(hookPoint, "hook URL must be https");
>   }
>   const timeoutMs = timeoutFrom(secrets.NORESPONSE_TIMEOUT_MS);
>   const controller = new AbortController();
>   const timer = setTimeout(() => controller.abort(), timeoutMs);
>   let response;
>   let text;
>   try {
>     response = await fetch(url, {
>       method: "POST",
>       headers: {
>         authorization: `Bearer ${secret}`,
>         "content-type": "application/json",
>         accept: "application/json",
>       },
>       body: JSON.stringify(buildBody(event, hookPoint)),
>       signal: controller.signal,
>       redirect: "error", // never resend the body to another host
>     });
>     text = await response.text();
>   } catch (err) {
>     return allow(hookPoint, controller.signal.aborted ? `timeout after ${timeoutMs} ms` : "network error");
>   } finally {
>     clearTimeout(timer);
>   }
>   if (response.status !== 200) {
>     return allow(hookPoint, `status ${response.status}`);
>   }
>   let answer;
>   try {
>     answer = JSON.parse(text);
>   } catch (err) {
>     return allow(hookPoint, "unreadable response");
>   }
>   if (!answer || typeof answer !== "object" || (answer.decision !== "allow" && answer.decision !== "deny")) {
>     return allow(hookPoint, "unexpected response");
>   }
>   const assessment = typeof answer.assessment_id === "string" ? answer.assessment_id.slice(0, 64) : "none";
>   if (answer.decision === "deny") {
>     console.log(`noresponse ${hookPoint}: deny, assessment ${assessment}`);
>     return { deny: true, assessmentId: answer.assessment_id };
>   }
>   return { deny: false, assessmentId: answer.assessment_id };
> }
>
> function allow(hookPoint, reason) {
>   console.log(`noresponse ${hookPoint}: allowed without a decision (${reason})`);
>   return { deny: false };
> }
>
> // The secret only ever travels over HTTPS; plain HTTP is accepted for a
> // loopback address so the Action can be tried against a local server.
> function safeHookUrl(value) {
>   let parsed;
>   try {
>     parsed = new URL(value);
>   } catch (err) {
>     return false;
>   }
>   if (parsed.protocol === "https:") return true;
>   return parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
> }
>
> function timeoutFrom(value) {
>   const n = Number(value);
>   if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;
>   return Math.min(Math.floor(n), MAX_TIMEOUT_MS);
> }
>
> function buildBody(event, hookPoint) {
>   const user = event.user || {};
>   const request = event.request || {};
>   const geoip = request.geoip || {};
>   const connection = event.connection || {};
>   const body = {
>     hook_point: hookPoint,
>     event_id: eventId(event),
>     user: pick({
>       user_id: user.user_id,
>       email: user.email,
>       email_verified: user.email_verified,
>       username: user.username,
>       phone_number: user.phone_number,
>       phone_verified: user.phone_verified,
>       name: user.name,
>       created_at: user.created_at,
>     }),
>     request: pick({
>       ip: request.ip,
>       user_agent: request.user_agent,
>       geo: pick({ country_code: geoip.countryCode, time_zone: geoip.timeZone }),
>     }),
>     connection: pick({ name: connection.name, strategy: connection.strategy }),
>   };
>   const methods = event.authentication && Array.isArray(event.authentication.methods)
>     ? event.authentication.methods.map((m) => pick({ name: m && m.name, timestamp: m && m.timestamp }))
>     : undefined;
>   const loginsCount = event.stats ? event.stats.logins_count : undefined;
>   if (methods || loginsCount !== undefined) {
>     body.session = pick({
>       authentication: methods ? { methods } : undefined,
>       stats: loginsCount !== undefined ? { logins_count: loginsCount } : undefined,
>     });
>   }
>   return body;
> }
>
> function eventId(event) {
>   const transaction = event.transaction || {};
>   if (typeof transaction.id === "string" && transaction.id) return transaction.id;
>   if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
>     return globalThis.crypto.randomUUID();
>   }
>   return require("node:crypto").randomUUID();
> }
>
> // Drops absent fields, so the request carries only what Auth0 supplied.
> function pick(fields) {
>   const out = {};
>   for (const [key, value] of Object.entries(fields)) {
>     if (value === undefined || value === null) continue;
>     if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) continue;
>     out[key] = value;
>   }
>   return out;
> }
> ```

> **post-login.js**
>
> ```js title="post-login.js" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
> /**
>  * NoResponse sign-in risk check for the Auth0 Login (post-login) trigger.
>  *
>  * Sends the sign-in to your NoResponse integration endpoint and blocks it only
>  * when NoResponse answers "deny". Any failure, slow answer or unexpected reply
>  * lets the sign-in through, so NoResponse can never lock your users out on its
>  * own.
>  *
>  * Secrets (Action editor, Secrets tab):
>  *   NORESPONSE_HOOK_URL     the integration URL, https://api.noresponse.org/hooks/<integration id>
>  *   NORESPONSE_HOOK_SECRET  the shared secret shown when you created the integration (nri_...)
>  *   NORESPONSE_TIMEOUT_MS   optional, how long to wait in milliseconds (default 5000, at most 8000)
>  *
>  * Runtime: Node 22. No npm dependencies.
>  */
>
> const HOOK_POINT = "post_login";
> const DEFAULT_TIMEOUT_MS = 5000;
> const MAX_TIMEOUT_MS = 8000; // Auth0 can end a run at about 10 seconds.
> const DENY_REASON = "Sign-in blocked. Please contact support.";
>
> exports.onExecutePostLogin = async (event, api) => {
>   const verdict = await askNoResponse(event, HOOK_POINT);
>   if (verdict.deny) {
>     api.access.deny(DENY_REASON);
>   }
> };
>
> // ---- Shared with pre-user-registration.js: keep the two copies identical. ----
>
> async function askNoResponse(event, hookPoint) {
>   const secrets = event.secrets || {};
>   const url = secrets.NORESPONSE_HOOK_URL;
>   const secret = secrets.NORESPONSE_HOOK_SECRET;
>   if (!url || !secret) {
>     return allow(hookPoint, "not configured");
>   }
>   if (!safeHookUrl(url)) {
>     return allow(hookPoint, "hook URL must be https");
>   }
>   const timeoutMs = timeoutFrom(secrets.NORESPONSE_TIMEOUT_MS);
>   const controller = new AbortController();
>   const timer = setTimeout(() => controller.abort(), timeoutMs);
>   let response;
>   let text;
>   try {
>     response = await fetch(url, {
>       method: "POST",
>       headers: {
>         authorization: `Bearer ${secret}`,
>         "content-type": "application/json",
>         accept: "application/json",
>       },
>       body: JSON.stringify(buildBody(event, hookPoint)),
>       signal: controller.signal,
>       redirect: "error", // never resend the body to another host
>     });
>     text = await response.text();
>   } catch (err) {
>     return allow(hookPoint, controller.signal.aborted ? `timeout after ${timeoutMs} ms` : "network error");
>   } finally {
>     clearTimeout(timer);
>   }
>   if (response.status !== 200) {
>     return allow(hookPoint, `status ${response.status}`);
>   }
>   let answer;
>   try {
>     answer = JSON.parse(text);
>   } catch (err) {
>     return allow(hookPoint, "unreadable response");
>   }
>   if (!answer || typeof answer !== "object" || (answer.decision !== "allow" && answer.decision !== "deny")) {
>     return allow(hookPoint, "unexpected response");
>   }
>   const assessment = typeof answer.assessment_id === "string" ? answer.assessment_id.slice(0, 64) : "none";
>   if (answer.decision === "deny") {
>     console.log(`noresponse ${hookPoint}: deny, assessment ${assessment}`);
>     return { deny: true, assessmentId: answer.assessment_id };
>   }
>   return { deny: false, assessmentId: answer.assessment_id };
> }
>
> function allow(hookPoint, reason) {
>   console.log(`noresponse ${hookPoint}: allowed without a decision (${reason})`);
>   return { deny: false };
> }
>
> // The secret only ever travels over HTTPS; plain HTTP is accepted for a
> // loopback address so the Action can be tried against a local server.
> function safeHookUrl(value) {
>   let parsed;
>   try {
>     parsed = new URL(value);
>   } catch (err) {
>     return false;
>   }
>   if (parsed.protocol === "https:") return true;
>   return parsed.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
> }
>
> function timeoutFrom(value) {
>   const n = Number(value);
>   if (!Number.isFinite(n) || n <= 0) return DEFAULT_TIMEOUT_MS;
>   return Math.min(Math.floor(n), MAX_TIMEOUT_MS);
> }
>
> function buildBody(event, hookPoint) {
>   const user = event.user || {};
>   const request = event.request || {};
>   const geoip = request.geoip || {};
>   const connection = event.connection || {};
>   const body = {
>     hook_point: hookPoint,
>     event_id: eventId(event),
>     user: pick({
>       user_id: user.user_id,
>       email: user.email,
>       email_verified: user.email_verified,
>       username: user.username,
>       phone_number: user.phone_number,
>       phone_verified: user.phone_verified,
>       name: user.name,
>       created_at: user.created_at,
>     }),
>     request: pick({
>       ip: request.ip,
>       user_agent: request.user_agent,
>       geo: pick({ country_code: geoip.countryCode, time_zone: geoip.timeZone }),
>     }),
>     connection: pick({ name: connection.name, strategy: connection.strategy }),
>   };
>   const methods = event.authentication && Array.isArray(event.authentication.methods)
>     ? event.authentication.methods.map((m) => pick({ name: m && m.name, timestamp: m && m.timestamp }))
>     : undefined;
>   const loginsCount = event.stats ? event.stats.logins_count : undefined;
>   if (methods || loginsCount !== undefined) {
>     body.session = pick({
>       authentication: methods ? { methods } : undefined,
>       stats: loginsCount !== undefined ? { logins_count: loginsCount } : undefined,
>     });
>   }
>   return body;
> }
>
> function eventId(event) {
>   const transaction = event.transaction || {};
>   if (typeof transaction.id === "string" && transaction.id) return transaction.id;
>   if (globalThis.crypto && typeof globalThis.crypto.randomUUID === "function") {
>     return globalThis.crypto.randomUUID();
>   }
>   return require("node:crypto").randomUUID();
> }
>
> // Drops absent fields, so the request carries only what Auth0 supplied.
> function pick(fields) {
>   const out = {};
>   for (const [key, value] of Object.entries(fields)) {
>     if (value === undefined || value === null) continue;
>     if (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0) continue;
>     out[key] = value;
>   }
>   return out;
> }
> ```

The same files, with the deploy script, are in [`/integrations/auth0/actions.json`](https://docs.noresponse.org/integrations/auth0/actions.json) on this site. The [Management API script](#install-with-the-management-api) section downloads them.

Do these steps for each of the two files:

1. In the Auth0 dashboard, open **Actions**, then **Library**, then **Create Action** and **Build from scratch**.
2. Give it a name, for example "NoResponse account creation risk check" or "NoResponse login risk check".
3. Choose the trigger from the table above and the **Node 22** runtime. Then select **Create**.
4. Replace the editor's code with the whole file.
5. Open **Secrets** (the key icon) and add these values:
   * `NORESPONSE_HOOK_URL`: the integration's `url`.
   * `NORESPONSE_HOOK_SECRET`: the integration's `secret`.
   * `NORESPONSE_TIMEOUT_MS` (optional): how long the Action waits for NoResponse, in milliseconds. The default is 5000, and the largest value it accepts is 8000. NoResponse answers within 4.5 seconds, so a value below 4500 lets some events through unscored.
6. Select **Deploy**.
7. Open **Actions**, then **Triggers**. Open **pre-user-registration** for the account creation Action, or **post-login** for the login Action.
8. Drag the Action from **Custom** into the flow between **Start** and **Complete**. Then select **Apply**.

A refused account creation sees "We could not create your account. Please contact support." A refused login returns `access_denied` to your application with the description "Sign-in blocked. Please contact support." To use your own words, change `USER_MESSAGE` in `pre-user-registration.js` or `DENY_REASON` in `post-login.js` before you deploy.

Each Action writes one log line, and only in two cases: when it denies, or when it allows without a decision. The line holds the hook point, the decision, and the assessment id or the reason. No secret, email address or IP address goes in the log.

### 3. Test it

1. In the Action editor, select **Test** (the play icon) and **Run**. Auth0's sample event has no real user, so NoResponse answers allow.
2. Read the Action's log in the test output. A correct setup writes nothing.
3. A line such as `noresponse post_login: allowed without a decision (status 401)` means that the Action could not get a decision. The table below gives the cause.
4. Create an account with a new test user through your application, then log in with it. Both pass while your block threshold is off.
5. Read the login's assessment. Use a key with the `history` scope to query the account's history. Use the Auth0 user id, such as `auth0|64abc`, as the `account_id`. See [Get and query assessments](https://docs.noresponse.org/reference/get-and-query.md).

| Log line ends with | Cause | What to do |
| - | - | - |
| `(not configured)` | `NORESPONSE_HOOK_URL` or `NORESPONSE_HOOK_SECRET` is missing. | Add both secrets and deploy again. |
| `(hook URL must be https)` | The URL is not `https`. | Copy `url` from the integration exactly. |
| `(status 401)` | The secret does not match the integration. | Paste the current `secret`, or [rotate it](#rotate-a-secret) if you lost it. See [`invalid_hook_signature`](https://docs.noresponse.org/problems/invalid-hook-signature). |
| `(status 404)` | The integration id in the URL does not exist, or you deleted the integration. | Copy `url` from `GET /v1/integrations`. |
| `(timeout after 5000 ms)`, or your own timeout | No answer within the Action's timeout. | Check that `NORESPONSE_TIMEOUT_MS` is 4500 or more. |
| `(network error)`, `(unreadable response)` or `(unexpected response)` | The call or its answer failed. | The Action let the user through. If it happens again, write to us with the time of the event. |

A deny writes `noresponse pre_registration: deny, assessment asm_...`, or the same line with `post_login`. Use the id to find the assessment.

**WorkOS**

WorkOS AuthKit calls the hosted URL from two Actions:

* The user registration Action runs after a user creates an account and before WorkOS provisions the user.
* The authentication Action runs after a user logs in and before WorkOS redirects them to your application.

NoResponse verifies each request with your WorkOS Actions signing secret. It signs its answer with the same secret, as WorkOS requires.

### 1. Copy the signing secret

1. In the [WorkOS dashboard](https://dashboard.workos.com/), choose the environment that you connect.
2. Open **Actions** and then the **Configuration** tab.
3. Copy the value under **Signing secret**. The samples below read it from `WORKOS_ACTIONS_SECRET`.

Each WorkOS environment has its own signing secret, so create one integration per environment.

### 2. Create the integration

Create an integration with `"platform": "workos"` and the signing secret. NoResponse stores the secret encrypted, and no answer contains it.

```ts TypeScript theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
const integration = await nr.integrations.create({
  platform: "workos",
  signingSecret: process.env.WORKOS_ACTIONS_SECRET!,
});
console.log(integration.url); // the Endpoint URL for both WorkOS Actions
```

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

integration = client.integrations.create(
    platform="workos",
    signing_secret=os.environ["WORKOS_ACTIONS_SECRET"],
)
print(integration.url)  # the Endpoint URL for both WorkOS Actions
```

```go Go theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
res, err := client.Integrations.Create(ctx, components.IntegrationRequest{
	Platform:      components.IntegrationPlatformWorkos,
	SigningSecret: noresponse.Pointer(os.Getenv("WORKOS_ACTIONS_SECRET")),
})
if err != nil {
	return err
}
fmt.Println(res.Result.URL) // the Endpoint URL for both WorkOS Actions
```

```bash cURL theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl https://api.noresponse.org/v1/integrations \
  -X POST \
  -H "Authorization: Bearer $NORESPONSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{ \"platform\": \"workos\", \"signing_secret\": \"$WORKOS_ACTIONS_SECRET\" }"
```

The answer is 201 and has no `secret`:

```json Response theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "integration_id": "int_01JA2Z9M3R5T8W1YBCDEFGHJKN",
  "platform": "workos",
  "hook_points": ["pre_registration", "post_login"],
  "url": "https://api.noresponse.org/hooks/int_01JA2Z9M3R5T8W1YBCDEFGHJKN",
  "created_at": "2026-10-14T09:05:00.000Z"
}
```

The signing secret must be 16 to 256 printable characters with no spaces. A WorkOS integration without one answers 400 [`invalid_request`](https://docs.noresponse.org/problems/invalid-request). Its `invalid_params` names `signing_secret`.

### 3. Point both Actions at the URL

> [!WARNING]
>
> **Set error handling to allow.** WorkOS denies the account creation or login when it cannot reach your endpoint or check its answer. The only exception is an endpoint with the allow option for error handling. Choose the allow option on both Actions. NoResponse already answers allow on its own failures. This setting covers the cases where no answer arrives, such as a network fault between WorkOS and NoResponse. A fault between the two then lets the user through.

On the same **Configuration** tab, under **Endpoints**, do these steps for **Authentication action** and then for **User registration action**:

1. Select **Configure action**.
2. Turn on **Enable**.
3. Under **Error handling**, choose the allow option. For the authentication action, it is **Allow authentication**. See the warning above.
4. Paste the integration's `url` into **Endpoint URL**.
5. Select **Save changes**.

Both Actions use the same URL. NoResponse identifies each one from the request. The user registration Action is the `pre_registration` hook point, and the authentication Action is `post_login`.

A refused account creation sees "This sign-up could not be completed." A refused login sees "This sign-in could not be completed."

### 4. Test it

1. Open the **Test** tab under **Actions** and select **Send test action**. The test reaches NoResponse, which answers with a signed `Allow`. The **Logs** tab shows the call.
2. Create an account with a new test user through AuthKit, then log in with it. Both pass while your block threshold is off.
3. Read the login's assessment. Use a key with the `history` scope to query the account's history. Use the WorkOS user id, such as `user_01JA2ZE5F6G7H8J9K0MNPQRSTV`, as the `account_id`. See [Get and query assessments](https://docs.noresponse.org/reference/get-and-query.md).

The **Logs** tab can show a 401 answer. Then the signing secret that NoResponse holds is not the one that WorkOS signs with. Copy it again and [rotate](#rotate-a-secret) the integration with it. A 404 means that the URL names an integration that does not exist. In both cases WorkOS applies your error handling. With allow set, the user gets through.

## Install with the Management API

`scripts/deploy-to-tenant.mjs` does every Auth0 dashboard step above in one command. It does these things:

1. It creates or updates both Actions with their secrets.
2. It waits for Auth0 to build them.
3. It runs Auth0's Action test on each.
4. Only when the test passes, it deploys the Action and adds it to its trigger. It keeps any Actions that are already there.

When you run it again, it updates the Actions in place. It prints no secrets, and it needs Node 22 with no npm packages.

Download the three files into the layout the script expects. `actions.json` holds the exact contents of each file under its path. The second command writes them out.

```bash Download the Auth0 files theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
mkdir -p noresponse-auth0 && cd noresponse-auth0
curl -fsS https://docs.noresponse.org/integrations/auth0/actions.json | node -e '
  const fs = require("node:fs"), path = require("node:path");
  let body = "";
  process.stdin.on("data", (chunk) => (body += chunk)).on("end", () => {
    for (const [file, code] of Object.entries(JSON.parse(body).files)) {
      fs.mkdirSync(path.dirname(file), { recursive: true });
      fs.writeFileSync(file, code);
    }
  });'
```

This is the script:

> **scripts/deploy-to-tenant.mjs**
>
> ```js title="deploy-to-tenant.mjs" theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
> #!/usr/bin/env node
> // Creates or updates the two NoResponse Actions in an Auth0 tenant, deploys
> // them, binds them to the Pre User Registration and Login flows, and runs
> // Auth0's own Action test on each with a sample event.
> //
> //   AUTH0_DOMAIN=dev-xyz.eu.auth0.com \
> //   AUTH0_CLIENT_ID=... AUTH0_CLIENT_SECRET=... \
> //   NORESPONSE_HOOK_URL=https://api.noresponse.org/hooks/<integration id> \
> //   NORESPONSE_HOOK_SECRET=nri_... \
> //   node scripts/deploy-to-tenant.mjs [--skip-bind] [--skip-test[=<trigger>,...]] [--auth0-cli]
> //
> // --auth0-cli sends the Management API calls through a logged-in auth0 CLI
> // (auth0 api ...) instead, and then needs only the two NORESPONSE_ values.
> // AUTH0_ACTION_PREFIX puts a prefix on both Action names.
> //
> // The client is a Machine to Machine application authorised for the Auth0
> // Management API with the read:actions, create:actions and update:actions
> // scopes. Nothing secret is printed. Exit code 0 means every step succeeded,
> // 2 means the environment is incomplete and nothing was called.
>
> import { readFile } from "node:fs/promises";
> import { spawnSync } from "node:child_process";
> import { fileURLToPath } from "node:url";
> import path from "node:path";
>
> const HERE = path.dirname(fileURLToPath(import.meta.url));
> const ROOT = path.join(HERE, "..");
>
> // Auth0's test harness itself reads tenant.id, connection.id and
> // request.method, so the sample events carry them.
> const SAMPLE_CONTEXT = {
>   tenant: { id: "action-test" },
>   client: { client_id: "action-test", name: "Action test", metadata: {} },
>   connection: { id: "con_action_test", name: "Username-Password-Authentication", strategy: "auth0", metadata: {} },
>   request: {
>     ip: "203.0.113.45",
>     hostname: "action-test.auth0.com",
>     method: "POST",
>     language: "en",
>     user_agent: "Mozilla/5.0",
>     geoip: { countryCode: "GB", timeZone: "Europe/London" },
>     body: {},
>     query: {},
>   },
> };
>
> export const ACTIONS = [
>   {
>     file: "pre-user-registration.js",
>     name: "NoResponse signup risk check",
>     trigger: "pre-user-registration",
>     sampleEvent: {
>       ...SAMPLE_CONTEXT,
>       user: { email: "auth0-action-test@example.com" },
>     },
>   },
>   {
>     file: "post-login.js",
>     name: "NoResponse sign-in risk check",
>     trigger: "post-login",
>     sampleEvent: {
>       ...SAMPLE_CONTEXT,
>       transaction: { id: "auth0-action-test" },
>       authentication: { methods: [{ name: "pwd", timestamp: new Date(0).toISOString() }] },
>       stats: { logins_count: 1 },
>       user: { user_id: "auth0|action-test", email: "auth0-action-test@example.com", email_verified: true },
>     },
>   },
> ];
>
> const REQUIRED = ["AUTH0_DOMAIN", "AUTH0_CLIENT_ID", "AUTH0_CLIENT_SECRET", "NORESPONSE_HOOK_URL", "NORESPONSE_HOOK_SECRET"];
> const REQUIRED_WITH_CLI = ["NORESPONSE_HOOK_URL", "NORESPONSE_HOOK_SECRET"];
>
> export async function main(env, args, log = console.log) {
>   const viaCli = args.includes("--auth0-cli");
>   const missing = (viaCli ? REQUIRED_WITH_CLI : REQUIRED).filter((k) => !env[k]);
>   if (missing.length) {
>     log(`missing environment: ${missing.join(", ")}`);
>     return 2;
>   }
>   // AUTH0_BASE_URL exists so the tests can point the script at a fake tenant.
>   let call;
>   if (viaCli) {
>     call = cliClient();
>   } else {
>     const base = (env.AUTH0_BASE_URL || `https://${env.AUTH0_DOMAIN}`).replace(/\/$/, "");
>     call = managementClient(base, await getToken(base, env));
>   }
>
>   const triggers = (await call("GET", "/api/v2/actions/triggers")).triggers || [];
>   let failures = 0;
>   for (const template of ACTIONS) {
>     const spec = { ...template, name: `${env.AUTH0_ACTION_PREFIX || ""}${template.name}` };
>     const version = currentVersion(triggers, spec.trigger);
>     const code = await readFile(path.join(ROOT, spec.file), "utf8");
>     const action = await upsertAction(call, spec, version, code, env);
>     log(`${spec.trigger}: action ${action.id} saved (trigger version ${version})`);
>     await waitUntilBuilt(call, action.id, env);
>     // Test the built draft first, so a failing one is never deployed.
>     if (!skipsTest(args, spec.trigger)) {
>       const result = await call("POST", `/api/v2/actions/actions/${action.id}/test`, { payload: { event: spec.sampleEvent } });
>       const summary = summariseTest(result);
>       log(`${spec.trigger}: Auth0 test ${summary.ok ? "passed" : "FAILED, not deployed"}: ${summary.text}`);
>       if (!summary.ok) {
>         failures += 1;
>         continue;
>       }
>     }
>     await call("POST", `/api/v2/actions/actions/${action.id}/deploy`);
>     log(`${spec.trigger}: deployed`);
>     if (!args.includes("--skip-bind")) {
>       const bound = await bind(call, spec, action.id);
>       log(`${spec.trigger}: ${bound ? "bound to the flow" : "already bound to the flow"}`);
>     }
>   }
>   return failures ? 1 : 0;
> }
>
> // --skip-test skips every test run; --skip-test=post-login skips one trigger.
> function skipsTest(args, trigger) {
>   return args.some((a) => a === "--skip-test" || (a.startsWith("--skip-test=") && a.slice(12).split(",").includes(trigger)));
> }
>
> export const RATE_LIMIT_ATTEMPTS = 8;
> const RATE_LIMIT_MAX_WAIT_MS = 15000;
>
> // How long to wait after Auth0 answered 429: Retry-After (seconds) when sent,
> // else until X-RateLimit-Reset (Unix seconds), else exponential from 1 s.
> // Always between 250 ms and 15 s.
> export function retryDelayMs(headers, attempt, now = Date.now()) {
>   const clamp = (ms) => Math.min(RATE_LIMIT_MAX_WAIT_MS, Math.max(250, Math.ceil(ms)));
>   const after = headers.get("retry-after");
>   if (after !== null && after !== "" && Number.isFinite(Number(after))) return clamp(Number(after) * 1000);
>   // Retry-After may also be an HTTP-date.
>   const until = after ? Date.parse(after) : NaN;
>   if (Number.isFinite(until) && until > now) return clamp(until - now);
>   const reset = Number(headers.get("x-ratelimit-reset"));
>   if (Number.isFinite(reset) && reset > 0) return clamp(reset * 1000 - now + 250);
>   return clamp(1000 * 2 ** (attempt - 1));
> }
>
> // fetch that waits out Auth0's 429 (the Management API's global limit hit a
> // staging deploy mid-poll) up to RATE_LIMIT_ATTEMPTS. A 429 means Auth0 did not
> // act, so a retried POST cannot run twice. Returns the last response.
> export async function fetchRetrying(url, init, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
>   for (let attempt = 1; ; attempt++) {
>     const res = await fetch(url, init);
>     if (res.status !== 429 || attempt >= RATE_LIMIT_ATTEMPTS) return res;
>     await res.body?.cancel();
>     await sleep(retryDelayMs(res.headers, attempt));
>   }
> }
>
> async function getToken(base, env) {
>   const res = await fetchRetrying(`${base}/oauth/token`, {
>     method: "POST",
>     redirect: "error",
>     headers: { "content-type": "application/json" },
>     body: JSON.stringify({
>       grant_type: "client_credentials",
>       client_id: env.AUTH0_CLIENT_ID,
>       client_secret: env.AUTH0_CLIENT_SECRET,
>       audience: `https://${env.AUTH0_DOMAIN}/api/v2/`,
>     }),
>   });
>   if (res.status !== 200) throw new Error(`token request failed with status ${res.status}`);
>   const body = await res.json();
>   if (!body.access_token) throw new Error("token response has no access_token");
>   return body.access_token;
> }
>
> function managementClient(base, token) {
>   return async (method, route, body) => {
>     const res = await fetchRetrying(`${base}${route}`, {
>       method,
>       redirect: "error",
>       headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
>       body: body === undefined ? undefined : JSON.stringify(body),
>     });
>     const text = await res.text();
>     if (res.status < 200 || res.status >= 300) {
>       throw new Error(`${method} ${route.split("?")[0]} failed with status ${res.status}: ${text.slice(0, 300)}`);
>     }
>     return text ? JSON.parse(text) : {};
>   };
> }
>
> // The same calls through `auth0 api`, which holds its own login. The body goes
> // on stdin so secrets never appear in the process list.
> function cliClient(bin = process.env.AUTH0_CLI_BIN || "auth0") {
>   return async (method, route, body) => cliCall(bin, method, route, body);
> }
>
> function cliCall(bin, method, route, body) {
>   const url = new URL(route, "https://tenant.invalid");
>   const argv = ["api", method.toLowerCase(), url.pathname.replace(/^\/api\/v2\//, "")];
>   for (const [k, v] of url.searchParams) argv.push("-q", `${k}=${v}`);
>   if (body !== undefined) argv.push("--data", "@-");
>   if (method === "DELETE") argv.push("--force");
>   const out = spawnSync(bin, argv, { input: body === undefined ? "" : JSON.stringify(body), encoding: "utf8" });
>   if (out.status !== 0) {
>     throw new Error(`${method} ${url.pathname} failed through the auth0 CLI: ${(out.stderr || out.stdout || "").slice(0, 300)}`);
>   }
>   const text = (out.stdout || "").trim();
>   return text ? JSON.parse(text) : {};
> }
>
> export function currentVersion(triggers, id) {
>   const matches = triggers.filter((t) => t.id === id);
>   const current = matches.find((t) => String(t.status).toUpperCase() === "CURRENT") || matches[0];
>   if (!current) throw new Error(`the tenant lists no ${id} trigger`);
>   return current.version;
> }
>
> async function upsertAction(call, spec, version, code, env) {
>   const secrets = [
>     { name: "NORESPONSE_HOOK_URL", value: env.NORESPONSE_HOOK_URL },
>     { name: "NORESPONSE_HOOK_SECRET", value: env.NORESPONSE_HOOK_SECRET },
>   ];
>   if (env.NORESPONSE_TIMEOUT_MS) secrets.push({ name: "NORESPONSE_TIMEOUT_MS", value: env.NORESPONSE_TIMEOUT_MS });
>   const query = new URLSearchParams({ actionName: spec.name, triggerId: spec.trigger });
>   const found = ((await call("GET", `/api/v2/actions/actions?${query}`)).actions || []).find((a) => a.name === spec.name);
>   const body = { code, runtime: "node22", secrets, dependencies: [], supported_triggers: [{ id: spec.trigger, version }] };
>   if (found) return call("PATCH", `/api/v2/actions/actions/${found.id}`, body);
>   return call("POST", "/api/v2/actions/actions", { name: spec.name, ...body });
> }
>
> async function waitUntilBuilt(call, id, env) {
>   const deadline = Date.now() + 120000;
>   const pause = Number(env.AUTH0_POLL_MS || 1500);
>   for (;;) {
>     const action = await call("GET", `/api/v2/actions/actions/${id}`);
>     if (action.status === "built") return;
>     if (action.status === "failed") throw new Error(`action ${id} failed to build`);
>     if (Date.now() > deadline) throw new Error(`action ${id} still ${action.status} after 120 s`);
>     await new Promise((r) => setTimeout(r, pause));
>   }
> }
>
> // PATCH bindings replaces the whole list, so keep every existing binding.
> async function bind(call, spec, actionId) {
>   const route = `/api/v2/actions/triggers/${spec.trigger}/bindings`;
>   const existing = (await call("GET", route)).bindings || [];
>   if (existing.some((b) => b.action && b.action.id === actionId)) return false;
>   const bindings = existing.map((b) => ({ ref: { type: "action_id", value: b.action.id }, display_name: b.display_name }));
>   bindings.push({ ref: { type: "action_id", value: actionId }, display_name: spec.name });
>   await call("PATCH", route, { bindings });
>   return true;
> }
>
> // Auth0 wants the test event under payload.event and answers with
> // payload.output.commands (such as AccessDenied), payload.logs and, when the
> // code threw, payload.error.
> export function summariseTest(result) {
>   const payload = (result && result.payload) || {};
>   if (payload.error) return { ok: false, text: `${payload.error.name || "Error"}: ${payload.error.message || ""}` };
>   const commands = ((payload.output && payload.output.commands) || []).map((c) => c.type);
>   const logs = String(payload.logs || "").trim().split("\n").filter(Boolean);
>   return { ok: true, text: `commands [${commands.join(", ")}], log ${logs.length ? JSON.stringify(logs) : "empty"}` };
> }
>
> if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
>   main(process.env, process.argv.slice(2)).then(
>     (code) => process.exit(code),
>     (err) => {
>       console.log(`failed: ${err.message}`);
>       process.exit(1);
>     },
>   );
> }
> ```

In Auth0, create a Machine to Machine application. Authorise it for the **Auth0 Management API** with the `read:actions`, `create:actions` and `update:actions` scopes. Then run this command with your own values:

```bash Deploy the Actions highlight={1-5} theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
AUTH0_DOMAIN=your-tenant.eu.auth0.com \
AUTH0_CLIENT_ID=your-m2m-client-id \
AUTH0_CLIENT_SECRET=your-m2m-client-secret \
NORESPONSE_HOOK_URL=https://api.noresponse.org/hooks/int_01JA2Z8K6Q4V7W9XBCDEFGHJKM \
NORESPONSE_HOOK_SECRET=nri_k3Yx8pQ2mN7vR4tW6zB9cD1eF5gH0jL4sP9uV2wX7yA \
node scripts/deploy-to-tenant.mjs --skip-test=post-login
```

`--skip-test=post-login` skips Auth0's test run for the login Action. Auth0's Action test endpoint fails for every Login Action with `Cannot read properties of undefined (reading 'ip')`. Test that Action in the Action editor instead, as in the Test it step.

| Option | Effect |
| - | - |
| `--skip-bind` | Deploys the Actions without adding them to their triggers. |
| `--skip-test` | Skips Auth0's test runs for both Actions. `--skip-test=post-login` skips one. |
| `--auth0-cli` | Makes the calls through a logged-in [Auth0 CLI](https://github.com/auth0/auth0-cli) (`auth0 api`). Then only the two `NORESPONSE_` values are necessary. |
| `AUTH0_ACTION_PREFIX` | Puts a prefix on both Action names, for a trial install beside your real ones. |
| `NORESPONSE_TIMEOUT_MS` | Sets the Action secret of the same name. |

The script exits 0 when every step succeeded. It exits 2 when a required value is missing, and in that case it called nothing.

## What the Auth0 Actions send and receive

Each call is `POST <url>` with `Authorization: Bearer <secret>` and a JSON body built from the Auth0 event. The Action leaves out the fields that Auth0 does not supply. At account creation, Auth0 has no user id, verification flags, creation time or session yet. The account creation Action sends only the email, username, phone number, name, request and connection.

```json Request body theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "hook_point": "post_login",
  "event_id": "the Auth0 transaction id, or a random UUID when there is none",
  "user": { "user_id": "auth0|64abc", "email": "jordan.lee@example.com", "email_verified": true, "created_at": "2026-10-01T09:00:00.000Z" },
  "request": { "ip": "81.2.69.160", "user_agent": "Mozilla/5.0", "geo": { "country_code": "GB", "time_zone": "Europe/London" } },
  "connection": { "name": "Username-Password-Authentication", "strategy": "auth0" },
  "session": { "authentication": { "methods": [{ "name": "pwd", "timestamp": "2026-10-14T09:00:00.000Z" }] }, "stats": { "logins_count": 7 } }
}
```

NoResponse answers 200 with the decision. A deny carries the assessment that it used:

```json Response theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
{
  "decision": "deny",
  "assessment_id": "asm_01JB3C4D5E6F7G8H9JKMNPQRST",
  "account_id": "auth0|64abc",
  "risk": { "score": 92, "recommendation": "review", "block": true }
}
```

An allow that NoResponse could not score is `{ "decision": "allow" }` with nothing else. The Action blocks only on `"decision": "deny"`. It treats any other answer, status or failure as allow.

## Choose the hook points

Both hook points are on unless you list fewer. To score account creation only, create the integration with `hook_points`:

```ts TypeScript theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
const integration = await nr.integrations.create({ platform: "auth0", hookPoints: ["pre_registration"] });
console.log(integration.url, integration.secret);
```

```python Python theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
integration = client.integrations.create(platform="auth0", hook_points=["pre_registration"])
print(integration.url, integration.secret)
```

```go Go theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
res, err := client.Integrations.Create(ctx, components.IntegrationRequest{
	Platform:   components.IntegrationPlatformAuth0,
	HookPoints: []components.IntegrationHookPoint{components.IntegrationHookPointPreRegistration},
})
if err != nil {
	return err
}
fmt.Println(res.Result.URL)
```

```bash cURL theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl https://api.noresponse.org/v1/integrations \
  -X POST \
  -H "Authorization: Bearer $NORESPONSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "platform": "auth0", "hook_points": ["pre_registration"] }'
```

NoResponse answers allow to an event at a hook point that the integration does not list, and creates no assessment. You choose the hook points once, when you create the integration. To change them, do these steps:

1. Create a new integration with the list that you want.
2. Move the provider to its URL. For Auth0, also move the provider to its secret.
3. Delete the old integration.

You can also turn a hook point off at the provider. Take the Auth0 Action out of its trigger, or turn off **Enable** on the WorkOS Action. The provider then never calls NoResponse for that event.

## Manage integrations

Every call below needs a key with the `integrations` scope. `GET /v1/integrations` lists your integrations, oldest first, without secrets. The [interactive reference](https://docs.noresponse.org/api-reference.md) has every field.

### Rotate a secret

`POST /v1/integrations/{integration_id}/rotate` replaces the secret. NoResponse still accepts the replaced secret for 24 hours, until `previous_secret_expires_at`. You can switch the provider over without a refused call.

* **Auth0**: send `{}`. The answer holds the new `secret`, shown once. Put it in `NORESPONSE_HOOK_SECRET` on both Actions and deploy them within 24 hours.
* **WorkOS**: regenerate the signing secret in the WorkOS dashboard, then send it as `signing_secret` immediately. WorkOS signs with the new secret from the moment you regenerate it. NoResponse refuses those calls until it has the new secret.

> [!WARNING]
>
> Rotation is not idempotent. A retry after a lost answer rotates again. The secret that was in force before the first call then stops working at once.

```ts TypeScript theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
const rotated = await nr.integrations.rotate("int_01JA2Z8K6Q4V7W9XBCDEFGHJKM");
console.log(rotated.secret); // the new NORESPONSE_HOOK_SECRET, shown once
console.log(rotated.previousSecretExpiresAt);
```

```python Python theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
rotated = client.integrations.rotate("int_01JA2Z8K6Q4V7W9XBCDEFGHJKM")
print(rotated.secret)  # the new NORESPONSE_HOOK_SECRET, shown once
print(rotated.previous_secret_expires_at)
```

```go Go theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
res, err := client.Integrations.Rotate(ctx, "int_01JA2Z8K6Q4V7W9XBCDEFGHJKM", components.IntegrationRotationRequest{})
if err != nil {
	return err
}
fmt.Println(*res.Result.Secret) // the new NORESPONSE_HOOK_SECRET, shown once
fmt.Println(*res.Result.PreviousSecretExpiresAt)
```

```bash cURL theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl https://api.noresponse.org/v1/integrations/int_01JA2Z8K6Q4V7W9XBCDEFGHJKM/rotate \
  -X POST \
  -H "Authorization: Bearer $NORESPONSE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

For WorkOS, pass the new secret. It is `signingSecret` in TypeScript, `signing_secret` in Python and cURL, and `SigningSecret` in Go.

### Delete an integration

First take the Actions out of the provider. Take the Auth0 Actions out of their triggers, or turn off **Enable** on both WorkOS Actions. Then delete the integration with `DELETE /v1/integrations/{integration_id}`, which answers 204. From then on its URL answers 404, and the provider applies its own failure setting.

```bash Delete an integration theme={"theme":{"light":"github-dark-default","dark":"github-dark-default"}}
curl https://api.noresponse.org/v1/integrations/int_01JA2Z8K6Q4V7W9XBCDEFGHJKM \
  -X DELETE \
  -H "Authorization: Bearer $NORESPONSE_API_KEY"
```

Creating an integration is not idempotent either. A retry after a lost answer creates a second integration, which you can list and delete. When you already have 10 integrations, a new one answers 409 [`too_many_integrations`](https://docs.noresponse.org/problems/too-many-integrations).

## After the hook answers

The hook decides at its time limit. A source that answers later can still move the score. That later version reaches your [webhooks](https://docs.noresponse.org/guides/webhooks.md) like any other `assessment.updated`. A later version can move the decision to `block` for an account that the hook already let in. Act on it from your webhook receiver, for example by suspending the account. [Turn an assessment into a decision](https://docs.noresponse.org/guides/decisions.md) has the rules to apply.


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