# Approvals

When your tenant's policy asks for an approval, the money route answers `202` and holds the operation — nothing signed or sent — until the rule's approvers approve it, each with a signature of their own key. Then it goes on as the call would have.

## Overview

- **For tenants on the institutional security profile.** A tenant's profile is agreed with Aurea [Coming soon, upon request](https://docs.aureahub.com/docs/feature-availability.md). Every other tenant is on the `legacy` profile: its policy set is never enforced, nothing is ever held, and its approvers' inbox stays empty.
- **A rule asks.** A rule of your tenant's active [policy set](https://docs.aureahub.com/docs/guide-policy-sets.md) with the verdict `REQUIRE_APPROVAL` names how many approvals (`approvalThreshold`, 1–20), from whom (`approverIds`) and whether the initiator is kept from approving their own operation (`approversExcludeInitiator`, `true` by default).
- **The operation waits.** On the money routes that start an operation, the call answers `202` with the operation and its approval request; nothing is signed, handed out to sign or sent. Anywhere else, the same rule refuses: `403` `POLICY_APPROVAL_REQUIRED`.
- **Approvers sign their decision** with an approval key: a P-256 key pair they make themselves, whose public key they register. Aurea never holds the private key. Or they confirm it with a passkey (Decide with a Passkey).
- **The threshold approves, one rejection ends it.** Once the rule's threshold of distinct approvers approved, the operation goes on server-side, exactly once, as the call that started it would have.
- Each decision is on the operation's history and, while your [audit trail](https://docs.aureahub.com/docs/audit-events.md) is on, in it: `approval.requested`, `approval.approved`, `approval.rejected`, `approval.threshold_reached`, `approval.expired` and `approval.continued` (category `governance`), `approval_key.registered` and `approval_key.revoked` (category `security`) — never a key, a signature or a payload.

## Who Approves

- The routes under **Approvals** in the API Reference are for the tenant roles that approve — `approver`, `owner`, `tenantadmin` and `non_signing_admin` ([Roles](https://docs.aureahub.com/docs/authentication.md)) — on their own tenant. Any other role answers `403`.
- A request's approvers are those users, narrowed by the rule: the users its `approverIds` names when it names some (name users with one of those roles: no other role reaches the routes), any of them otherwise — and never the operation's initiator while `approversExcludeInitiator` is `true`.
- Each approver decides a request once. The threshold counts distinct approvers.

## Approval Keys

Each approver registers their own keys with [Register an Approval Key](https://docs.aureahub.com/docs/approval-key-register.md): `name` (1 to 100 characters) and `publicKey`, the public half of a P-256 key pair as a JWK.

```javascript
// In the approver's browser or device (WebCrypto): the private key never leaves it
const { publicKey, privateKey } = await crypto.subtle.generateKey(
  { name: 'ECDSA', namedCurve: 'P-256' },
  false,              // the private key cannot be exported
  ['sign', 'verify'],
);
const { kty, crv, x, y } = await crypto.subtle.exportKey('jwk', publicKey);

const res = await fetch('https://api.aureahub.com/v1/approval-keys', {
  method: 'POST',
  headers: { Authorization: `Bearer ${approverToken}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'Laptop key', publicKey: { kty, crv, x, y } }),
});
const key = await res.json(); // key.id is the kid your decisions name; keep privateKey (e.g. in IndexedDB)
```

- `publicKey`: `{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }`, `x` and `y` 32 bytes each in unpadded base64url, a point on the curve. `alg` (`ES256`), `use` (`sig`), `kid`, `key_ops` and `ext` are accepted and dropped. A JWK that carries its private part (`d`) is refused — `400` `APPROVAL_KEY_PRIVATE` — and that key pair should not be used.
- **At most two active keys per approver.** To rotate, register the new one, move to it, then revoke the old one with [Revoke an Approval Key](https://docs.aureahub.com/docs/approval-key-revoke.md); a third answers `409` `APPROVAL_KEY_LIMIT`.
- **A key is registered once, ever** — also after its revocation: `409` `APPROVAL_KEY_IN_USE`. A new key is a new key pair.
- **Revocation is final.** A decision signed with a revoked key is refused from then on (`403` `APPROVAL_SIGNATURE_KEY_UNKNOWN`); the decisions it signed before stand.
- Each approver lists, registers and revokes only their own keys. `lastUsedAt` is the last decision a key signed.

## When an Operation Is Held

These routes start an operation, and can hold it:

| Route | Page |
| --- | --- |
| POST /v1/transactions/ | [Create Transaction](https://docs.aureahub.com/docs/tx-create.md) |
| POST /v1/swap/approve | [Approve Token](https://docs.aureahub.com/docs/swap-approve.md) |
| POST /v1/swap/execute | [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md) |
| POST /v1/swap/execute-solana | [Execute Solana Swap](https://docs.aureahub.com/docs/swap-execute-solana.md) |
| POST /v1/swap/execute-gasless | [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md) |

- **Held before anything leaves.** A custodial wallet's operation is held before Aurea signs it; a client-side or MPC wallet's before anything is handed out to sign — the `202` carries no hash to sign.
- **Held only at the call's first signing step**, before anything of that call was signed or sent. A later step of the same call, once an earlier one was signed, cannot be held: there a rule asking for an approval refuses.
- **Refused, not held** — `403` `POLICY_APPROVAL_REQUIRED`, nothing signed or sent — everywhere else a rule asks for an approval: the broadcasts ([Broadcast Transaction](https://docs.aureahub.com/docs/tx-broadcast.md), [Broadcast Swap Tx](https://docs.aureahub.com/docs/swap-broadcast.md), [Broadcast Approve Tx](https://docs.aureahub.com/docs/swap-broadcast-approve.md), [Broadcast Gasless Tx](https://docs.aureahub.com/docs/swap-broadcast-gasless.md)), which carry on what was prepared and decided before; the [dApp broadcast](https://docs.aureahub.com/docs/dapps-broadcast.md) and a dApp's personal message; an MPC signing ceremony of anything but a transaction Aurea prepared for an approved operation; [Sign EIP-712 Permit](https://docs.aureahub.com/docs/swap-sign-permit.md); the [key export](https://docs.aureahub.com/docs/wallets-export-token.md); and [Earn](https://docs.aureahub.com/docs/guide-earn.md).
- **Your operations are recorded.** A held operation is followed as an [operation](https://docs.aureahub.com/docs/guide-operations.md), and an institutional tenant's operations are recorded.

The route answers `202 Accepted`:

```json
{
  "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18",
  "state": "PENDING_AUTHORIZATION",
  "subState": "awaiting_approval",
  "approval": {
    "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63",
    "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73",
    "ruleName": "Large transfers need two approvers",
    "threshold": 2,
    "expiresAt": null
  }
}
```

- Nothing was signed, handed out or sent, and no transaction record is kept for it: the approved operation writes its own.
- **The `Idempotency-Key`** — which an institutional tenant's money routes require (`400` `IDEMPOTENCY_KEY_REQUIRED` without it) — keeps this answer: the same request with the same key answers this `202` again (`idempotency-replayed: true`), and never holds a second operation. It does not tell you the outcome: follow the operation.

## Follow a Held Operation

- [Get an Operation](https://docs.aureahub.com/docs/operation-get.md): `PENDING_AUTHORIZATION` `awaiting_approval` while it waits; `approved` once approved, then on its way like any operation — or `REJECTED` (`failureReason` `APPROVAL_REJECTED`), or `FAILED` `approval_expired` where an approval window applies, or `CANCELLED` when its initiator cancels it while it waits ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)).
- The `operation.state_changed` event, on a [webhook endpoint](https://docs.aureahub.com/docs/guide-events.md) that names it, at each move.
- [An Operation's Approval](https://docs.aureahub.com/docs/operation-approval.md) (`GET /v1/operations/{id}/approval`): the initiator's view — the request, its decisions, and, once approved and gone on, `result`: the answer the call would have given.

## Decide

1. [Waiting for You](https://docs.aureahub.com/docs/approvals-inbox.md) (`GET /v1/approvals`) lists the requests you may decide now: your tenant's, pending, within their window, naming you (or any approver), not your own operation when the rule keeps the initiator apart, not decided by you yet — newest first.
2. [Get a Request](https://docs.aureahub.com/docs/approval-get.md) (`GET /v1/approvals/{operationId}`) gives what the operation moves (`chain`, `asset`, `amount` in the asset's smallest unit, `amountRef` in EUR, `destination`), the rule, the decisions so far, `canDecide`, and the two payloads to sign: `payloads.approve` and `payloads.reject`.
3. Sign the payload of your decision with one of your active approval keys (Sign a Decision).
4. Send it: [Approve](https://docs.aureahub.com/docs/approval-approve.md) (`POST /v1/approvals/{operationId}/approve`) or [Reject](https://docs.aureahub.com/docs/approval-reject.md) (`…/reject`, with an optional `reason` of 1 to 500 characters). The answer is the request as it stands. With a passkey, steps 3 and 4 are Decide with a Passkey.

## Sign a Decision

`signature` is a compact JWS — `header.payload.signature`, each part unpadded base64url, at most 4096 characters:

| Part | What it holds |
| --- | --- |
| header | `{ "alg": "ES256", "typ": "aurea-approval+jwt", "kid": "<approval key id>" }` — those three members and no other. |
| payload | The string of `payloads.approve` or `payloads.reject`, byte for byte. Don't parse it and write it again: sign the very string the API gave. |
| signature | ES256 over `header.payload`, in its 64-byte form (r and s, 32 bytes each), as JOSE writes it — not DER. WebCrypto's ECDSA signature is already in that form. |

The payload is JSON with its members in this order and no whitespace: the decision, what moves, the request it answers, the operation, the rule that asked, the window's end and the tenant.

```json
{"action":"approve","amount":"35000000000","approvalId":"c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63","asset":"0x2a22f9c3b484c3629090feed35f17ff8f88f76f0","chain":"gnosis","chainId":100,"destination":"0x52908400098527886e0f7030069857d2e4169ee7","expiresAt":null,"operationId":"1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18","ruleId":"e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73","tenantId":"6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0","v":1}
```

```javascript
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes)))
  .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const utf8 = (text) => new TextEncoder().encode(text);

// keyId: the approval key's id; privateKey: its WebCrypto private key; payload: payloads.approve or payloads.reject
async function signDecision(keyId, privateKey, payload) {
  const header = b64url(utf8(JSON.stringify({ alg: 'ES256', typ: 'aurea-approval+jwt', kid: keyId })));
  const signingInput = `${header}.${b64url(utf8(payload))}`;
  const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, utf8(signingInput));
  return `${signingInput}.${b64url(signature)}`;
}

const request = await fetch(`https://api.aureahub.com/v1/approvals/${operationId}`, {
  headers: { Authorization: `Bearer ${approverToken}` },
}).then((r) => r.json());

if (request.canDecide) {
  const signature = await signDecision(key.id, privateKey, request.payloads.approve);
  await fetch(`https://api.aureahub.com/v1/approvals/${operationId}/approve`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${approverToken}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ signature }),
  });
}
```

- A signature answers one request with one decision: an approval's payload sent to Reject, or another request's, is refused (`400` `APPROVAL_SIGNATURE_PAYLOAD_MISMATCH`).
- A signature is used once, ever: sent again, it answers `409` `APPROVAL_SIGNATURE_USED`.
- On a server with Node's `crypto.sign`, pass `dsaEncoding: 'ieee-p1363'` to get the 64-byte form, as in [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md).

The sample was checked against the API's own verification: its signature over a payload is accepted, and another payload, another key's signature or a changed header is refused.

## Decide with a Passkey

An approver can confirm the decision with their passkey instead of an approval key's signature ([Passkeys & Step-Up](https://docs.aureahub.com/docs/guide-passkeys.md)):

1. [Step-Up Options](https://docs.aureahub.com/docs/step-up-options.md) with `action` `approval.approve` (or `approval.reject`) and the `operationId`: its challenge commits to the request and its payload for that decision.
2. [Step-Up](https://docs.aureahub.com/docs/step-up.md) with the passkey's answer: a `stepUpToken`, for 2 minutes, used once.
3. [Approve](https://docs.aureahub.com/docs/approval-approve.md) (or [Reject](https://docs.aureahub.com/docs/approval-reject.md)) with `{ "stepUpToken" }` instead of `signature` — and a `reason`, for a rejection, if you like.

- **One or the other, in every mode**: a decision is never taken on the session alone. Both, or neither, answer `400` `APPROVAL_SIGNATURE_MALFORMED`.
- The token must be this approver's, for this request, its payload and this decision: anything else answers `403` `STEP_UP_INVALID`. If the operation is held again with a new request, run the step-up again.
- **Both kinds count toward the threshold**, and an approval key's signature counts the same where your tenant enforces the step-up. The decision's audit row names the passkey (`passkeyId`) instead of the approval key.

## The Order of the Checks

A decision is checked in one go, the request locked, in this order; the first check that fails answers, and nothing is recorded:

| # | Status | details.code | When |
| --- | --- | --- | --- |
| 1 | 404 | APPROVAL_NOT_FOUND | The operation has no approval request in your tenant. |
| 2 | 409 | APPROVAL_NOT_PENDING | The request is decided already: `details.status` `approved`, `rejected`, `expired` or `withdrawn`. |
| 3 | 409 | APPROVAL_EXPIRED | Its window has ended (only where an approval window applies). |
| 4 | 403 | APPROVAL_NOT_APPROVER | You are not one of its approvers: the rule names others, or it is your own operation and the rule keeps the initiator apart. |
| 5 | 409 | APPROVAL_ALREADY_DECIDED | You decided it already. |
| 6 | 400 | APPROVAL_SIGNATURE_MALFORMED | `signature` is not a compact JWS with exactly that header and a 64-byte signature — or the body carries both `signature` and `stepUpToken`, or neither. |
| 7 | 403 | APPROVAL_SIGNATURE_KEY_UNKNOWN | `kid` is not one of your active approval keys. |
| 8 | 400 | APPROVAL_SIGNATURE_PAYLOAD_MISMATCH | The signed payload is not this request's payload for this decision. |
| 9 | 403 | APPROVAL_SIGNATURE_INVALID | The signature does not verify with that key. |
| 10 | 409 | APPROVAL_SIGNATURE_USED | This signature was used already. |

With `stepUpToken` instead of `signature`, checks 7 to 10 are one: `403` `STEP_UP_INVALID` when the token is not this approver's, for this request, its payload and this decision — unknown, expired, used, or its passkey revoked since.

## How a Request Ends

- **Approved** once the threshold of distinct approvers approved: the operation goes `PENDING_AUTHORIZATION` `approved`, and on (After the Approval).
- **Rejected** by one rejection of one of its approvers: the operation is `REJECTED`, `failureReason` `APPROVAL_REJECTED`. Nothing was signed or sent.
- **No window, by default.** A request waits until it is decided: `expiresAt` is `null` in every answer and in the signed payload.
- **A window, where one is set** for the platform (5 minutes to 23 hours): `expiresAt` is its end, and a request still pending then **expires** — the operation is `FAILED` `approval_expired` (`failureReason` `APPROVAL_EXPIRED`), nothing signed or sent.
- **Withdrawn** when its initiator cancels the operation while it waits ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)): `status` `withdrawn`, the operation `CANCELLED` (`failureReason` `OPERATION_CANCELLED`). Nothing was signed or sent. A request an approver decided first is not withdrawn.
- A request ends once: a decision afterwards answers `409` `APPROVAL_NOT_PENDING`.

## After the Approval

The approved operation goes on server-side, once, with the request it was held with and as the user who made it — the path the call would have taken:

- **A custodial wallet's** operation is signed and sent.
- **A client-side or MPC wallet's** is prepared: the operation moves to `PENDING_SIGNATURE` `awaiting_client_signature` with its `preparedTxId`, and [An Operation's Approval](https://docs.aureahub.com/docs/operation-approval.md) gives the prepared transaction in `result.body` — the answer the call would have given, with `txHashToSign` (or `permitHashToSign`), `preparedTxId` and `expiresAt`. Sign and broadcast it as always, before its `expiresAt`: like every preparation it lives 5 minutes, from the moment the approved operation went on. For an approved operation, the hour after which an operation never broadcast fails (`FAILED` `never_broadcast`) counts from its approval.
- **The policy decides again** at the signing step: the rule it was approved for now lets it sign. Anything else decides as for any call — another rule asking for an approval holds it again (a new request for the same operation, and `result` is that `202`), a block refuses it (`result` is the refusal).
- **A swap goes on with the quote it was held with**: past the quote's validity it fails as the call would have (`result`: `400` `QUOTE_EXPIRED: …`).
- `result` is `{ "status", "body" }`: `201` and the transaction for a send, `200` and the swap's answer, or the error the call would have answered. It is `null` until the operation went on.
- An approved operation that this version of the API cannot carry on is not sent: `result` is `503` `APPROVAL_CONTINUATION_UNAVAILABLE` and the operation `FAILED` `not_sent`.

## Refusals

The usual envelope, the code in `details.code`; never a key, a signature or a payload:

```json
{
  "statusCode": 409,
  "error": "ConflictError",
  "message": "This approval request is decided already",
  "details": { "code": "APPROVAL_NOT_PENDING", "status": "approved" }
}
```

| Status | details.code | message |
| --- | --- | --- |
| 400 | APPROVAL_KEY_INVALID | `publicKey must be a P-256 public key as a JWK: …` — the reason, e.g. `crv must be "P-256"`, `x must be 32 bytes in unpadded base64url`, `the point is not on the curve` |
| 400 | APPROVAL_KEY_PRIVATE | `publicKey carries a private key ("d"): send the public key only, and do not use this key pair` |
| 400 | APPROVAL_KEY_NAME_INVALID | `A key's name is 1 to 100 characters` |
| 400 | APPROVAL_REASON_INVALID | `A reason is 1 to 500 characters` |
| 400 | APPROVAL_CURSOR_INVALID | `Invalid cursor` |
| 400 | APPROVAL_SIGNATURE_MALFORMED | `The signature must be a compact JWS (ES256, typ "aurea-approval+jwt", kid: one of your approval keys) over the canonical approval payload` — also with neither `signature` nor `stepUpToken`; `Send a signature (an approval key) or a stepUpToken (a passkey), not both` with both |
| 400 | APPROVAL_SIGNATURE_PAYLOAD_MISMATCH | `The signed payload is not this request's canonical "approve" payload (GET /v1/approvals/{operationId}: payloads)` (`"reject"` on Reject) |
| 403 | APPROVAL_NOT_APPROVER | `You are not one of this request's approvers` |
| 403 | APPROVAL_SIGNATURE_KEY_UNKNOWN | `The signature's kid is not one of your active approval keys` |
| 403 | APPROVAL_SIGNATURE_INVALID | `The signature does not verify with that approval key` |
| 403 | STEP_UP_INVALID | `This step-up token is not valid for this action: it is unknown, expired, used, or for something else`, with `details.action` and `details.tenantId` — a passkey's decision |
| 404 | APPROVAL_KEY_NOT_FOUND | `No such approval key of yours` |
| 404 | APPROVAL_NOT_FOUND | `No approval request for this operation here` |
| 409 | APPROVAL_KEY_LIMIT | `At most 2 active approval keys: revoke one first` |
| 409 | APPROVAL_KEY_IN_USE | `This key is registered already: a key is registered once, ever` |
| 409 | APPROVAL_KEY_REVOKED | `This key is revoked already` |
| 409 | APPROVAL_NOT_PENDING | `This approval request is decided already`, with `details.status` |
| 409 | APPROVAL_EXPIRED | `This approval request's window has ended` |
| 409 | APPROVAL_ALREADY_DECIDED | `You decided this request already` |
| 409 | APPROVAL_SIGNATURE_USED | `This signature was used already` |

- A role that does not approve answers `403` (`Tenant admin access required. Your role does not have permission to perform this action.`); a frozen account or tenant `423` ([Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md)); an id that is not a UUID `400` `Request validation failed`.
- Registering a key: at most 10 a minute. Approving and rejecting: at most 30 a minute each.

---

Web version: https://docs.aureahub.com/#guide-approvals
