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

# Screening Policy Rules

> The rule format behind a screening policy, for conditions the developer console's policy editor cannot express.

A [screening policy](/docs/policies/screening-policies) is stored as data, not as code. The policy editor in the developer console writes one shape of rule. This page describes the full format, which you author through the `ScreeningPolicies` endpoints in the [API reference](/docs/api-reference/overview).

Use it when you need a condition the editor cannot express: a negation, a nested combination of conditions, or a screening provider field the editor does not offer.

## Prerequisites

* Your own provider key for TRM Labs or Chainalysis. See [bring your own screening key](/docs/get-started/security/address-screening#bring-your-own-screening-key).
* The Admin role on the environment.
* Screening policies enabled for the environment. They are in Beta.

## Shape

A policy holds two ordered lists of rules:

```json theme={"system"}
{
  "preScreen": [],
  "postScreen": []
}
```

`preScreen` decides whether the provider is called. `postScreen` decides the verdict from the provider's response. Each list holds at most 200 rules. Both are optional, and a policy with neither behaves exactly like an environment with no policy.

Every rule is one condition and one action:

```json theme={"system"}
{ "when": { "field": "chain", "op": "eq", "value": "SOL" }, "then": "SKIP" }
```

Rules are evaluated top to bottom and the first match wins. The list order is the policy, so Dynamic stores your rules exactly as you send them and never reorders them.

## Actions

`then` in `preScreen` is `SCREEN` or `SKIP`. If no pre-screen rule matches, the address is screened.

`then` in `postScreen` is `BLOCK`, `ALERT`, or `ALLOW`. If no post-screen rule matches, the address is allowed.

## Conditions

A condition is one of these. Each object holds exactly one form.

| Form | Meaning |
| - | - |
| `{ "field": …, "op": …, "value": … }` | Compare one field. |
| `{ "all": [ … ] }` | Every listed condition matches. |
| `{ "any": [ … ] }` | At least one listed condition matches. |
| `{ "not": { … } }` | The listed condition does not match. |
| `{ "exists": { "in": …, "where": { … } } }` | At least one element of a provider array matches. `postScreen` only. |
| `{ "always": true }` | Matches every screen. `preScreen` only, and only as a rule's whole condition. |

`all` and `any` take 1 to 200 conditions. Conditions nest up to 5 levels deep.

### Comparing one field

```json theme={"system"}
{ "field": "chainalysis.risk", "op": "gte", "value": "High" }
```

`op` is one of `eq`, `neq`, `in`, `nin`, `gte`, `lte`, `gt`, `lt`, or `contains`.

* `in` and `nin` take an array of 1 to 200 values. Every other operator takes a single value.
* `contains` is a substring test. It is never a regular expression.

Which operators a field accepts depends on what the field holds. A pairing the field does not accept is rejected with `400`.

| The field holds | Accepts |
| - | - |
| free text, such as `category` or `chainalysis.riskReason` | `eq`, `neq`, `in`, `nin`, `contains` |
| a number, such as `categoryRiskScoreLevel` | `eq`, `neq`, `in`, `nin`, `gte`, `lte`, `gt`, `lt` |
| an ordered level, such as `chainalysis.risk` | `eq`, `neq`, `in`, `nin`, `gte`, `lte`, `gt`, `lt` |
| a closed set of values, such as `exposureType` | `eq`, `neq`, `in`, `nin` |

Ordering operators do not work on free text. `contains` works only on free text.

### Quantifying over a provider array

Both providers report signals as arrays. `exists` matches when one element of the array satisfies every condition in `where`:

```json theme={"system"}
{
  "exists": {
    "in": "trm.addressRiskIndicators",
    "where": {
      "all": [
        { "field": "category", "op": "eq", "value": "Mixer" },
        { "field": "categoryRiskScoreLevel", "op": "gte", "value": 10 }
      ]
    }
  }
}
```

Every condition inside `where` binds to the **same** element. That is the point of `exists`. Two unrelated signals cannot jointly satisfy one rule, so the example above matches an address carrying a high-scoring Mixer signal, not an address that carries a Mixer signal and separately carries something else scoring 10.

Inside `where`, a bare field name such as `category` reads the bound element. An absolute path such as `chainalysis.risk` still reads the response.

### Matching every screen

`{ "always": true }` is how you turn a surface off:

```json theme={"system"}
{ "preScreen": [{ "when": { "always": true }, "then": "SKIP" }] }
```

* It is accepted only in `preScreen`, and only as a rule's whole condition. Nested anywhere, or used in `postScreen`, it is a `400`.
* `false` is a `400`. The form exists to match, not to be switched off.
* Nothing may follow it. A later rule is unreachable and the write is rejected.

<Warning>
  Skipping the provider call drops that provider's own sanctions blocks at that surface. Dynamic still checks every address against its own sanctions list. See [when to screen](/docs/policies/screening-policies#when-to-screen).
</Warning>

## Fields

### Both phases, both providers

`tx.amountUsd`, `tx.asset`, `chain`, and `address`. These need no provider response, so they work in both phases, and they are the **only** fields a `preScreen` rule may use.

* `address` is compared exactly, with no case folding, because Base58 and Base32 chains are case significant.
* `chain` is compared exactly against the chain Dynamic recorded for the wallet, such as `EVM` or `SOL`.
* `tx.asset` is compared case-insensitively, so `usdc` matches `USDC`.
* `tx.amountUsd` and `tx.asset` are present only where the request established them. Neither is available on the signing path.

### TRM Labs

| Field | Where it reads |
| - | - |
| `trm.addressHighestRiskScoreLevel` | the response |
| `category`, `riskType`, `categoryRiskScoreLevel` | an element of `trm.addressRiskIndicators` |
| `category`, `riskScoreLevel`, `isVasp` | an element of `trm.entities` |

`in` for an `exists` is `trm.addressRiskIndicators` or `trm.entities`.

`riskType` is `OWNERSHIP`, `COUNTERPARTY`, or `INDIRECT`. Category strings are compared **case sensitively**, in the TitleCase TRM returns, such as `Mixer`. Risk score levels are numbers and are compared numerically.

### Chainalysis

| Field | Where it reads |
| - | - |
| `chainalysis.risk`, `chainalysis.riskReason`, `chainalysis.cluster.category`, `chainalysis.cluster.name` | the response |
| `category` | an element of `addressIdentifications`, `exposures`, or `triggers` |
| `exposureType`, `direction`, `value` | an element of `chainalysis.exposures` |
| `percentage`, `ruleTriggered.risk`, `ruleTriggered.exposureType`, `ruleTriggered.direction` | an element of `chainalysis.triggers` |

`in` for an `exists` is `chainalysis.addressIdentifications`, `chainalysis.exposures`, or `chainalysis.triggers`.

`chainalysis.risk` is an ordered level, so `gte` works on it: `Low`, `Medium`, `High`, `Severe`. `category`, `chainalysis.cluster.category`, `direction`, and the `ruleTriggered` fields are compared case-insensitively. `chainalysis.cluster.name` and `chainalysis.riskReason` are compared exactly. `cluster` and `ruleTriggered` are nullable, so a field beneath either reads as unknown when the provider omits the parent.

<Warning>
  `exposureType` has a closed vocabulary of `direct` and `indirect`, lowercase. The developer console labels the same distinction Direct exposure and Indirect exposure, so do not copy a label into a rule as a value.
</Warning>

## Unknown values

A field the response does not carry is **unknown**, not absent and not false. A rule fires only on a definite match.

* A condition on an unknown field does not match, and it does not make the rule match either.
* `not`, `neq`, and `nin` do not fire on an unknown value. They are not a way to test for absence.
* `exists` over an empty array does not match. `exists` over a field the response omits is unknown.

This is what makes amount conditions safe to write on a surface that sometimes has no amount: the condition sits out rather than firing on a value that was never established.

## Writing a policy

A write replaces the whole rule blob for one provider's sign-in policy or transaction policy.

* Send `revision` exactly as the read returned it. A stale `revision` is rejected with `409` rather than applied, so a concurrent edit cannot be silently overwritten.
* Omit `revision` only when creating that policy for the first time.
* Rules are validated before they are stored. A field the provider's schema does not define, an operator the field does not accept, an operand of the wrong type, a list longer than 200, and a condition nested too deep are each a `400`.
* A TRM policy naming a `chainalysis.*` field is a `400`, and the reverse is too. The provider comes from the path, and the rules must match it.
* A saved policy is live everywhere within 60 seconds.

<Note>
  Neither the sanctions baseline nor the trailing allow appears in `rules`. Both are generated on every screen, so there is nothing to read back and no request body that can weaken them. The API calls the baseline the sanctions floor.
</Note>

## Rules the policy editor writes

The policy editor writes ordinary rules in this format. A rule you send in the same shape is shown in the editor as editable, not as an advanced rule. See [Configure a screening policy](/docs/policies/configure-a-screening-policy) for the editor itself.

### When to screen

Each **When to screen** control is stored as a `SKIP` rule on everything it excludes. Screening only Solana, and only transfers of \$1,000 or more:

```bash theme={"system"}
curl -X PUT \
"https://app.dynamicauth.com/api/v0/environments/<environment_id>/screeningPolicies/trmWalletScreening/TRANSACTION" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_token>" \
-d '{
  "revision": 4,
  "rules": {
    "preScreen": [
      { "when": { "field": "tx.amountUsd", "op": "lt", "value": 1000 }, "then": "SKIP" },
      { "when": { "not": { "field": "chain", "op": "in", "value": ["SOL"] } }, "then": "SKIP" }
    ],
    "postScreen": []
  }
}'
```

### A category rule

A category rule is one `exists` over the provider's signal array. Blocking Mixer on the address itself at a TRM Labs risk score of 10 (high) or above:

```bash theme={"system"}
curl -X PUT \
"https://app.dynamicauth.com/api/v0/environments/<environment_id>/screeningPolicies/trmWalletScreening/SIGN_IN" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_token>" \
-d '{
  "revision": 7,
  "rules": {
    "preScreen": [],
    "postScreen": [
      {
        "when": {
          "exists": {
            "in": "trm.addressRiskIndicators",
            "where": {
              "all": [
                { "field": "category", "op": "in", "value": ["Mixer"] },
                { "field": "riskType", "op": "in", "value": ["OWNERSHIP"] },
                { "field": "categoryRiskScoreLevel", "op": "gte", "value": 10 }
              ]
            }
          }
        },
        "then": "BLOCK"
      }
    ]
  }
}'
```

A category rule with more than one threshold is stored as one rule per threshold. The editor stores the stricter action first, and a higher score before a lower one, whatever order the thresholds were added in.

## How the policy editor treats an API-authored rule

The policy editor recognizes a rule by its structure. A rule outside the shape it writes is shown read-only, at its real position in the evaluation order, with a delete control.

* Saving from the policy editor does not rewrite an advanced rule and does not move it.
* An advanced rule does not disable the editor. Rules the editor does recognize stay editable.
* Reading a policy and saving it back unchanged leaves it unchanged.

## Address exceptions

An address exception is an entry on the provider, not a rule in the policy. It applies to both the sign-in policy and the transaction policy unless you narrow it, and each entry is written on its own:

```bash theme={"system"}
curl -X POST \
"https://app.dynamicauth.com/api/v0/environments/<environment_id>/screeningPolicyAddresses/trmWalletScreening" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <your_token>" \
-d '{
  "address": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "disposition": "DENY",
  "appliesToScope": "ALL",
  "note": "Treasury policy"
}'
```

* `disposition` is `DENY` to block the address or `ALLOW` to exempt it from your rules.
* Omit `chain` to apply the entry to every chain.
* Omit `appliesToScope` to apply the entry to both policies.
* An address already listed for that provider and chain is rejected with `409`. Remove the existing entry to change its decision.
* An exempted address is still blocked if it is sanctioned. The sanctions baseline is evaluated before address exceptions.

## Screening an address yourself

Two endpoints return a screening verdict on demand. Both are policy aware, and both gained an additive `action` of `allow`, `alert`, or `block` alongside the field they already returned. An alert never blocks, so an integration that reads only `isBlocked` or `decision` behaves exactly as it did before policies existed.

They take different inputs, so which one you want follows from what you have:

| You have | Use | It does |
| - | - | - |
| an address | `GET /sdk/{environmentId}/wallet/sanctions` | screens that one address |
| an unsigned transaction | `POST /sdk/{environmentId}/wallet/screenTransaction` | decodes the payload, screens the sender and every destination it finds, and returns one verdict |

Reach for the POST before signing from a wallet you do not control. Do not build it out of the GET: extracting destinations from a raw payload means reimplementing per-chain decoding for EVM, Solana, Bitcoin, Tron, Sui, and Stellar, and the verdict over a set of addresses is not the same guarantee as a verdict over each one in turn.

<Note>
  The `wallet/sanctions` path predates screening policies. The endpoint is policy aware and returns the full `allow`, `alert`, or `block` verdict, not a sanctions yes or no. The path is kept because it is released.
</Note>

On `GET /sdk/{environmentId}/wallet/sanctions`:

* `scope` chooses which policy to evaluate, and defaults to `TRANSACTION`. This is the only surface where the caller chooses the policy. Every other surface always uses the policy listed for it on [screening policies](/docs/policies/screening-policies#a-sign-in-policy-and-a-transaction-policy).
* `amountUsd` and `asset` supply values to whichever policy `scope` selected. They are independent of `scope`: passing an amount does not move you to the transaction policy. They also never trigger a provider call and are not part of the cache key, so screening an address as a \$10,000 transfer is free.
* `applyPolicies: false` returns the baseline verdict alone. It does not reduce the number of provider calls.

On `POST /sdk/{environmentId}/wallet/screenTransaction`:

* The request carries the transaction payload and takes no `scope`, no amount, and no policy bypass. It always evaluates the transaction policy. This is what an SDK blocks a signature on, so it accepts nothing from the caller that could weaken the verdict.
* It screens at most 100 addresses in one request. A payload that decodes to more is not screened at all, rather than screened in part.
* It is rate limited per credential and environment.

<Warning>
  A `429`, a timeout, an undecodable payload, more than 100 addresses, or any other error on `screenTransaction` means the transaction was not screened. Dynamic's SDKs fail open and proceed, and only an explicit block decision throws. Treat a failed screen as unscreened rather than as allowed.
</Warning>

Neither endpoint returns the match reason. Both accept an end-user JWT, so the response body is readable by the person being screened. The reason reaches you on the [screening webhook events](/docs/embedded-wallets/mpc/policies/webhooks) instead.


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