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

# Approval requirements

> Schema, limits, adjustable approval settings and case statuses for require_approval rules with an approval requirement

export const Card = ({imgUrl, compactImgUrl, title, description, href, horizontal = false, newTab = false}) => {
  const [isHovered, setIsHovered] = useState(false);
  const handleClick = e => {
    e.preventDefault();
    if (newTab) {
      window.open(href, '_blank', 'noopener,noreferrer');
    } else {
      window.location.href = href;
    }
  };
  return <div className={`not-prose relative my-2 p-[1px] rounded-xl transition-all duration-300 ${isHovered && href ? 'bg-gradient-to-r from-[#FF4E00] to-[#874AE3]' : 'bg-gray-200'}`} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)}>
      <a href={href} onClick={href ? handleClick : undefined} className={`not-prose flex ${horizontal ? 'flex-row' : 'flex-col'} font-normal h-full bg-white overflow-hidden w-full ${href ? 'cursor-pointer' : 'cursor-default'} rounded-[11px] no-underline`}>
        {imgUrl && <div className={`relative overflow-hidden flex-shrink-0 ${horizontal ? 'w-[30%] rounded-l-[11px]' : 'w-full'}`} onClick={e => e.stopPropagation()}>
            <picture style={{
    display: 'contents'
  }}>
              {compactImgUrl && <source media="(max-width: 639px)" srcSet={compactImgUrl} />}
              <img src={imgUrl} alt={title} className="w-full h-full object-cover pointer-events-none select-none" draggable="false" />
            </picture>
            <div className="absolute inset-0 pointer-events-none" />
          </div>}
        <div className={`flex-grow px-6 py-5 ${horizontal ? 'w-[70%]' : 'w-full'} flex flex-col ${horizontal && imgUrl ? 'justify-center' : 'justify-start'}`}>
          {title && <h2 className="font-semibold text-base text-gray-800 m-0">{title}</h2>}
          {description && <div className={`font-normal text-gray-500 re leading-6 ${horizontal || !imgUrl ? 'mt-0' : 'mt-1'}`}>
              <p className="m-0 text-xs">{description}</p>
            </div>}
        </div>
      </a>
    </div>;
};

An approval requirement turns a `require_approval` rule into an approval workflow: people your app assigns approve the request in ordered stages before Para signs it. This page defines the `approvalRequirement` object, its limits, the approval settings your app can adjust without a new policy version, and the lifecycle of the approval case Para opens for each request.

<Info>
  Para turns on approval requirements per API key, so beta and production keys are enabled separately. Until it does, validating or saving a policy with an `approvalRequirement` fails with `permissions_v2_extension_unavailable`; contact Para to enable them. Build and test requirements on a beta API key, shown as **Development** in the Developer Portal, then select **Request production review** to have Para apply the policy to your production key.
</Info>

## Example

This Guardrail requires two finance approvals for native transfers of 1 ETH or more from app-owned wallets on Base. Transfers of 10 ETH or more also need one executive approval after finance. Treasury admins can change the finance count between 1 and 3 and move the executive threshold between 5 and 20 ETH.

```json treasury-approvals.json theme={null}
{
  "id": "treasury-approvals",
  "schemaVersion": "permissions.policy.v1",
  "version": "1",
  "selector": { "walletType": "EVM", "chainId": "8453" },
  "metadata": {
    "productMode": "guardrail",
    "label": "Treasury approvals",
    "description": "Large native transfers need finance approval, and very large ones also need an executive."
  },
  "rules": [
    {
      "id": "approve-large-native",
      "effect": "require_approval",
      "actions": ["transfer_native"],
      "when": { "kind": "compare", "fact": "request.value.baseUnits", "op": "gte", "value": "1000000000000000000" },
      "approvalRequirement": {
        "id": "treasury-review",
        "evidence": "authenticated_approval",
        "amountFact": "request.value.baseUnits",
        "stages": [
          { "id": "finance", "requiredApprovals": 2, "eligibility": [{ "kind": "role", "name": "finance" }] },
          {
            "id": "executive",
            "requiredApprovals": 1,
            "eligibility": [{ "kind": "role", "name": "executive" }],
            "minimumAmount": "10000000000000000000"
          }
        ],
        "configurationManagerEligibility": [{ "kind": "role", "name": "treasury-admin" }],
        "safeKnobs": [
          { "id": "finance-approvals", "kind": "required_approvals", "stageId": "finance", "min": 1, "max": 3 },
          {
            "id": "executive-threshold",
            "kind": "minimum_amount",
            "stageId": "executive",
            "min": "5000000000000000000",
            "max": "20000000000000000000"
          }
        ]
      }
    }
  ]
}
```

## Requirement fields

An `approvalRequirement` is allowed only on a rule whose `effect` is `require_approval`. In a Request, every `require_approval` rule must have one.

| Field | Type | Required | Description |
| - | - | - | - |
| `id` | string | Yes | 1 to 64 characters, unique within the policy. Approval settings and cases refer to it. |
| `stages` | array | Yes | 1 to 5 stages with distinct `id`s, run in order. See [Stages](#stages). |
| `evidence` | string | No | `authenticated_approval`, the default, or `transaction_signature`. See [Evidence](#evidence). |
| `amountFact` | string | No | `request.value.baseUnits`, `request.token.value.baseUnits`, `request.safe.value.baseUnits` or `request.safe.token.value.baseUnits`. Required when any stage sets `minimumAmount`, and allowed only then. See [Amount bands](#amount-bands). |
| `configurationManagerEligibility` | array | No | 1 to 10 [eligibility clauses](#eligibility). People who match one can change the adjustable approval settings. |
| `safeKnobs` | array | No | The adjustable approval settings, at least one, with distinct `id`s. See [Adjustable approval settings](#adjustable-approval-settings). |

Names, IDs and attribute values are trimmed, and each must be 1 to 64 characters.

## Stages

Each stage needs a number of approvals from the people its eligibility selects. The next stage opens only after the current one reaches its count.

| Field | Type | Required | Description |
| - | - | - | - |
| `id` | string | Yes | Unique within the requirement. |
| `requiredApprovals` | integer or `"all"` | Yes | 1 to 25, or `"all"` for every person eligible when the stage opens. An `"all"` stage opens only when 1 to 25 people are eligible. |
| `eligibility` | array | Yes | 1 to 10 distinct [eligibility clauses](#eligibility). |
| `minimumAmount` | string | No | The smallest amount, in base units, for which this stage applies. Not allowed on the first stage. See [Amount bands](#amount-bands). |

Each person counts once per case. Someone who approved in one stage is left out of later stages, so a two-stage requirement with two approvals each needs four different people.

## Eligibility

A person is eligible for a stage when one of their assignments matches any clause in the stage's `eligibility`.

| Clause | Shape | Matches |
| - | - | - |
| Role | `{ "kind": "role", "name": "finance" }` | A `role` assignment with that name |
| Attribute equals | `{ "kind": "attribute", "name": "region", "operator": "eq", "value": "eu" }` | An `attribute` assignment with that name and value |
| Attribute in list | `{ "kind": "attribute", "name": "region", "operator": "in", "values": ["eu", "uk"] }` | An `attribute` assignment with that name and any listed value. `values` holds at least one distinct value. |

Names and values must already be in Unicode NFKC form; validation rejects other forms.

Your app assigns roles and attributes to users inside an authorization scope, a group name your app chooses, such as one per customer organization. Each wallet that a requirement governs must be bound to an authorization scope, and a stage selects only people assigned in that wallet's scope. For a wallet without a binding, Para opens no case and the signing request fails with `403 FORBIDDEN` and the message `wallet has no trusted authorization scope binding`. Use `replaceUserApprovalAssignments` and `setWalletAuthorizationScope`, listed in the [REST SDK Permissions reference](/v3/references/rest/permissions).

| Assignment | Shape |
| - | - |
| Role | `{ "kind": "role", "name": "finance" }` |
| Attribute | `{ "kind": "attribute", "name": "region", "value": "eu" }` |

An assignment must use a role name or attribute value that an active policy declares, in a stage, in `configurationManagerEligibility` or in an `eligible_group` option. Only users linked to your app, through a wallet connected to it, can receive assignments. For any other user, the call fails with `user is not linked to this app`.

## Amount bands

Amount bands add stages as the amount grows. Set `amountFact` to the amount to read, and give each stage after the first a `minimumAmount`.

* The first stage has no `minimumAmount` and applies to every amount the rule's `when` matches. In a Guardrail, the amount at which approval starts is therefore fixed, because Guardrail parameters keep their defaults.
* Every later stage needs a `minimumAmount`. The thresholds must be positive, increasing integer strings without leading zeros.
* `amountFact` must be available for every action the rule names. `request.value.baseUnits` suits `transfer_native`, and `request.token.value.baseUnits` suits `transfer_tokens`. See [Facts and conditions](/v3/references/permissions/facts).
* For Safe payments on `execute_safe_transaction` and `sign_typed_data`, use `request.safe.value.baseUnits` for native payments or `request.safe.token.value.baseUnits` for ERC-20 payments. Each is present only for its own [payment family](/v3/references/permissions/facts#safe-payments), so pair it with a rule whose `when` reads the same family.

Bands are cumulative. A request runs every stage whose `minimumAmount` it reaches, in order, so in the example a 12 ETH transfer needs finance and then executive approval. If Para cannot read the amount when the request arrives, it does not open a case, and the request fails with `approval_amount_unavailable`.

## Evidence

`evidence` sets what counts as an approval.

| Value | Approval | Requester | Available in |
| - | - | - | - |
| `authenticated_approval` | An eligible person approves while signed in to Para. | Cannot approve or reject their own request. | Guardrail and Request |
| `transaction_signature` | An eligible person's wallet signs the transaction itself. Para checks that each signature matches the case's transaction. | Can add their own signature when eligible. | Request only |

In a Request, a rule with `authenticated_approval` evidence takes effect only after the user consents to the policy, like an `allow` rule. If the user chose to review each request, the user reviews first, then the case opens.

## Requesters

The requester is the person who starts a request. With `authenticated_approval` evidence, the requester cannot approve their own request. For a user-owned wallet, Para knows the requester: the signed-in user who asked to sign. For an app-owned wallet, the request comes from your app, so pass the ID of the Para user who asked for it as `initiatorUserId`. The REST signing endpoints and the client SDK `signMessage` and `signTransaction` methods for app-owned wallets accept it. The user must be linked to your app, or the request fails with `user is not linked to this app`. If you omit `initiatorUserId`, Para has no person to exclude, so any eligible person, including the user who triggered the request, can approve or reject it.

When a request names `initiatorUserId`, Para applies these rules to that requester:

* Para leaves the user out of every stage, whatever the evidence.
* The user cannot approve or reject the case. A decision fails with `INITIATOR_CANNOT_APPROVE` or `INITIATOR_CANNOT_REJECT`, and reading the case as an approver fails with `INITIATOR_CANNOT_REVIEW`.
* The user can cancel the pending case. See [Cancelling a case](#cancelling-a-case).
* Every later attempt of the same request must name the same `initiatorUserId`.

## Adjustable approval settings

`safeKnobs` declares settings that your app, or a configuration manager, can change after the policy is active without publishing a new version. Each setting targets one stage. A stage can have one participation setting, `required_approvals` or `eligible_group`, plus one `minimum_amount` setting.

| `kind` | Fields | Value it takes | Rules |
| - | - | - | - |
| `required_approvals` | `id`, `stageId`, `min`, `max` | An integer from `min` to `max` | `min` and `max` are 1 to 25, and `min` is at most `max`. The stage must use a number for `requiredApprovals`, within that range. |
| `eligible_group` | `id`, `stageId`, `defaultOptionId`, `options` | The `id` of one option | `options` holds 1 to 25 options with distinct `id`s, each `{ "id", "eligibility" }`. The default option's `eligibility` must be identical to the stage's `eligibility`, with the clauses and their keys in the same order. |
| `minimum_amount` | `id`, `stageId`, optional `min` and `max` | An amount string without leading zeros, from `min` to `max` | The stage must set `minimumAmount`. `min` and `max` are amount strings, `min` is at most `max`, and the stage's `minimumAmount` must be within the range. Without `max`, the published threshold is the upper bound, so the setting can lower the threshold but not raise it. |

Values are saved for one authorization scope, policy version and requirement, as an object of setting `id` to value, with at most 25 entries. For the example:

```json theme={null}
{ "finance-approvals": 3, "executive-threshold": "5000000000000000000" }
```

* Your server secret can always write values. A user can write them when one of their assignments in that scope matches `configurationManagerEligibility`.
* An unknown setting `id` fails with `safe_knob_not_declared`, and a value of the wrong type or outside its range fails with `safe_knob_value_invalid`.
* Para applies the values and checks the requirement again, so thresholds must stay positive and increasing. A `minimum_amount` value of `"0"`, or one that is not above the previous stage's threshold, fails with `requirement_schema_invalid`.
* Changing the values ends open cases for that scope and requirement. See [Changes that end open cases](#changes-that-end-open-cases).

## Limits

These bounds cover requirement structure, saved settings, approver assignments and case lifetime.

| Limit | Value |
| - | - |
| Stages per requirement | 5 |
| `requiredApprovals` per stage | 25, or `"all"` with at most 25 eligible people |
| Eligible people when a stage opens | 1 to 100, and at least `requiredApprovals` |
| Eligibility clauses per list | 10 |
| `eligible_group` options | 25 |
| Saved setting values | 25 |
| Assignments per user, across all scopes of one app | 25 |
| Distinct role names across active policies | 100 |
| Case lifetime | 7 days |

If the first stage resolves to no eligible people, to fewer than it needs, or to more than 100, Para opens no case and the signing request fails with `400`. When a later stage would resolve to more than 100 eligible people, the approval that would open it fails with `400` and the case stays `pending`. When it would resolve to fewer than it needs, the case ends as `invalidated` with `stage_snapshot_invalid`. [Permissions errors](/v3/references/permissions/errors) lists the responses.

## Cases

When a `require_approval` rule with a requirement decides a request, Para opens an approval case and does not sign until the case completes. A REST signing call returns `403 POLICY_REVIEW_REQUIRED` with the `approvalCaseId`. SDK signing with an app-owned wallet throws the same error, and the server SDK and React Native SDK carry the `approvalCaseId` in `error.data`.

The web and React SDKs currently reject with a generic error that has no `data`.

SDK signing with a user's wallet holds the request as a pending transaction. See [Review required](/v3/references/permissions/errors#review-required).

| Status | Meaning |
| - | - |
| `pending` | Waiting for approvals. |
| `completed` | Every stage reached its count. The request can be signed until seven days after the case opened; after that the case still reads `completed` but can no longer authorize signing. |
| `rejected` | An eligible person rejected the request. One rejection ends the case. |
| `expired` | The case was still `pending` seven days after it opened. |
| `cancelled` | Your app or the requester cancelled the case. |
| `invalidated` | Something the case depends on changed. `terminalReason` says what. |

Each stage takes a snapshot of its eligible people when it opens, so assignments added later do not join a stage that is already open.

For a readable [Safe payment](/v3/references/permissions/facts#safe-payments), the case's request summary includes `safePayment`: the payment kind, `native` or `erc20`, the Safe, the recipient, the amount in base units and the asset, plus `from` for an ERC-20 `transferFrom`.

<AccordionGroup>
  <Accordion title="Why a case ends">
    | `terminalReason` | Status | Cause |
    | - | - | - |
    | `rejected_by_eligible_actor` | `rejected` | An eligible person rejected the request. |
    | `case_expired` | `expired` | Seven days passed since the case opened while it was still `pending`. |
    | `case_cancelled` | `cancelled` | Your app or the requester cancelled the case. |
    | `authorization_stack_changed` | `invalidated` | The active policies that decided the request changed. |
    | `authorization_scope_changed` | `invalidated` | The wallet was bound to a different authorization scope. |
    | `requirement_configuration_changed` | `invalidated` | The adjustable approval settings, or the requirement they resolve to, changed. |
    | `requirement_unavailable` | `invalidated` | Para can no longer find or resolve the requirement. |
    | `authority_revoked` | `invalidated` | The assignments behind the open stage changed. |
    | `request_or_requirement_changed` | `invalidated` | The request was evaluated again and now needs a different requirement. |
    | `owner_review_changed` | `invalidated` | The user's own review of the request is no longer current. |
    | `request_resubmitted` | `invalidated` | The same request was submitted again, and Para opened a new attempt in its place. |
    | `request_allowed_without_approval` | `invalidated` | The same request was later signed without needing this case. |
    | `active_stage_missing`, `stage_snapshot_mismatch`, `stage_snapshot_invalid`, `next_stage_missing`, `next_stage_actor_already_counted`, `next_stage_authority_invalid`, `unexpected_next_stage_snapshot` | `invalidated` | Para could not open or check a stage, for example because the next stage has no eligible people left. |
  </Accordion>

  <Accordion title="Why a person cannot decide">
    A case read by a signed-in user includes `decision`. When `canDecide` is `false`, `reason` is one of:

    | `reason` | Meaning |
    | - | - |
    | `not_eligible` | The person is not in the open stage's snapshot. |
    | `initiator` | The person is the requester and the evidence is `authenticated_approval`. |
    | `already_decided` | The person already approved or rejected this case. |
    | `case_terminal` | The case is no longer `pending`. |
    | `authority_changed` | The assignments behind the open stage changed. |
  </Accordion>
</AccordionGroup>

### Cancelling a case

Your server cancels a case with `cancelPartnerApprovalCase` in the REST SDK, or `POST /v1/permissions-v2/approval-cases/{caseId}/cancel`. The requester, including a user named in `initiatorUserId`, cancels it with `cancelApprovalCase` in the client SDKs.

Only a `pending` case can be cancelled. The case ends with status `cancelled` and `terminalReason` `case_cancelled`. When a pending transaction from a user's wallet is waiting on the case, Para discards it, so that request never signs. A request that should still go ahead needs a new attempt, which opens a new case.

Cancelling a case that is no longer pending fails with `409 CONFLICT`.

### Changes that end open cases

Para checks these changes against open cases before it applies them:

* Replacing, activating or deactivating a policy.
* Changing a user's approval assignments.
* Binding a wallet to another authorization scope.
* Changing the adjustable approval settings.

<Note>
  An approval case is open while it is `pending`, and after it completes until it is used to sign or expires. Para refuses a change that would invalidate open cases until you confirm it. A confirmed change ends those cases as `invalidated`, and their requests need a new attempt.

  Through the API, a refused change returns `409` with code `APPROVAL_CASES_AFFECTED` and the case IDs in `affectedCaseIds`, and nothing changes. To confirm it, send the same change again with `force: true`.
</Note>

### Matching and single use

A completed case approves one request from one wallet. With `authenticated_approval` evidence, Para matches the request by its intent and ignores the fields that change when your app rebuilds the transaction:

| Request | Fields Para ignores |
| - | - |
| EVM transaction | `nonce`, the gas limit, and `gasPrice` or `maxFeePerGas` and `maxPriorityFeePerGas`, for the same transaction type |
| Stellar transaction | The fee, sequence number and signatures, the outer fee of a fee bump, and every precondition: time bounds, ledger bounds, `minSeqNum`, `minSeqAge`, `minSeqLedgerGap` and `extraSigners` |
| Safe `SafeTx` typed data sent to REST `sign-typed-data` | `nonce`, plus `safeTxGas` and `baseGas` when `gasPrice` is `0` |

Other requests, and every request under `transaction_signature` evidence, must match the approved request exactly.

To use the case for a rebuilt transaction, send it as the same request. On REST, reuse the `Idempotency-Key` header of the first attempt. In the client SDKs, pass the case ID as `resumeApprovalCaseId` to `signMessage` or `signTransaction`.

A case can be used once, within seven days of when it opened. Para reserves the case when signing starts and consumes it when the signature is produced, so signing the same request again needs a new case. Once signing has started with a case, a retry must match the reserved request exactly.

<CardGroup cols={2}>
  <Card imgUrl="/images/v3/cards/concept-policy-json.png" title="Policy JSON schema" description="Rules, effects, precedence and validation errors." href="/v3/references/permissions/policy-json" />

  <Card imgUrl="/images/v3/cards/concept-error-handling.png" title="Permissions errors" description="Responses for approval cases, decisions and settings." href="/v3/references/permissions/errors" />
</CardGroup>


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