> ## Documentation Index
> Fetch the complete documentation index at: https://sdk-guide.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Governance & Policies

> Security policies, daily spending limits, limit groups, and the org audit trail

## Overview

Organization governance is four independent mechanisms:

| Mechanism | Configured by | Enforced where |
| - | - | - |
| Security policy: KYC, business hours, address whitelist, daily spend | `updateOrgPolicies` | Enforced server-side on every spend-capable operation |
| Daily spending limit | `dailySpendingLimitUsd` on the policy | Order creation and key-share release, in USD, per UTC day |
| Limit group: how many wallets and vaults per chain the org may hold | Platform side, read-only through the SDK | Wallet and vault creation |
| Audit trail | Always on | Every mutating org operation |

> [!WARNING]
>
> Policies bind Employees, not Masters. A Master API key never sees a business-hours or spending-limit rejection. The one exception is the address-book whitelist, described below.

## Reading and updating the policy

```typescript theme={null}
interface OrgPolicy {
  id: string;
  organizationId: string;
  dailySpendingLimitUsd: string | null;   // USD, serialized as a string
  businessHoursEnabled: boolean;
  businessHoursStart: string | null;      // "HH:MM" in businessHoursTimezone
  businessHoursEnd: string | null;        // "HH:MM" in businessHoursTimezone
  businessHoursTimezone: string | null;   // IANA, e.g. "Europe/Warsaw"
  businessHoursDays: number[];            // ISO weekdays, 1 = Monday … 7 = Sunday
  forceEmployeeKyc: boolean;
  forceAddressBook: boolean;
}

interface UpdateOrgPolicyInput {
  dailySpendingLimitUsd?: string | null;
  businessHoursEnabled?: boolean;
  businessHoursStart?: string;
  businessHoursEnd?: string;
  businessHoursTimezone?: string;
  businessHoursDays?: number[];
  forceEmployeeKyc?: boolean;
  forceAddressBook?: boolean;
}
```

```typescript theme={null}
const policy = await sdk.getOrgPolicies();

const updated = await sdk.updateOrgPolicies({
  businessHoursEnabled: true,
  businessHoursStart: '09:00',
  businessHoursEnd: '18:00',
  businessHoursTimezone: 'Europe/Warsaw',
  businessHoursDays: [1, 2, 3, 4, 5],
  dailySpendingLimitUsd: '25000',
  forceAddressBook: true,
});
// returns the full, fresh OrgPolicy
```

> [!NOTE]
>
> Reading the policy is open to any org member with a `PolicyManagement` key. Writing it is Master-only.

Server-side validation on `PATCH`:

* Turning on `businessHoursEnabled` requires `businessHoursStart`, `businessHoursEnd` and `businessHoursTimezone` in the same request. Missing any of them is a `400`.
* `dailySpendingLimitUsd` must be a positive numeric string. Pass explicit `null` to clear the limit.
* Every change writes an `Org Policy Updated` audit row.

No passkey or 2FA step-up material is needed from the SDK for this call.

## What each policy does

### `forceEmployeeKyc`

When the policy requires KYC and the caller is not verified, every spend-capable request is rejected with `403`. KYC applies to the *user*, and is never bypassed because the transport is an API key.

### `businessHoursEnabled`

The current time is evaluated in `businessHoursTimezone`, for Employees only. A request outside the `businessHoursStart`–`businessHoursEnd` window, or on a weekday not listed in `businessHoursDays`, is rejected with `403`.

Pure key-management steps are exempt, so an employee can still accept a wallet handover or rotate their keys after hours. Anything that signs, exports or spends never qualifies for that exemption.

### `forceAddressBook`

Every destination is checked against the Corporate Address Book (see [Organization Wallets](https://sdk-guide.mintlify.site/sdk-guide/organization-wallets.md)). Only verified entries count. An unlisted destination is rejected with `400 "ADDRESS_NOT_WHITELISTED: <address>"`.

> [!WARNING]
>
> Through an API key a Master is subject to the whitelist exactly like an Employee. This is the one
> policy that binds Masters.

### `dailySpendingLimitUsd`

See the next section.

## Daily spending limits

```typescript theme={null}
interface OrgSpendingLimitStatus {
  dailyLimitUsd: number | null;    // null when no limit is configured
  currentSpendingUsd: number;      // always present
  remainingUsd: number | null;     // null when no limit is configured
  resetAt: string;                 // next UTC midnight, ISO 8601
}

const status = await sdk.getOrgSpendingLimit();
```

```typescript theme={null}
if (status.dailyLimitUsd === null) {
  renderUnlimited();               // NOT zero
} else {
  renderRemaining(status.remainingUsd!, status.resetAt);
}
```

> [!WARNING]
>
> `dailyLimitUsd` and `remainingUsd` are `null` together when no limit is set, and the server also returns `null` when the stored value is non-numeric or `<= 0`. Render an "unlimited" state; treating `null` as `0` blocks every spend in the UI.

The counter runs on UTC days, so `resetAt` is always the next UTC midnight regardless of the org's business-hours timezone. Available to Master and Employee; requires `ViewBalances`.

### How a spend is charged

The limit is evaluated in USD, per organization, and it is charged when the spend is created. Over the limit, the request is refused with `403 "Action blocked: Daily spending limit exceeded."` If a token's price cannot be resolved while a limit is active, the spend is blocked rather than let through.

The amount charged is the one the server reads out of the transaction itself, not one the client reports, so an employee cannot understate a spend to stay under the limit. The SDK handles that exchange inside `sendTransaction`; there is nothing for you to call.

## Limit groups

A limit group caps how many wallets and vaults the organization may hold per chain. It is provisioned platform-side; the SDK only reads it.

```typescript theme={null}
interface OrgLimitGroupChainLimit {
  wallets: number;
  vaults: number;
}

interface OrgLimitGroup {
  id: string;
  name: string;
  limits: Partial<Record<ChainType, OrgLimitGroupChainLimit>>;
}

interface OrgLimitGroupEnvelopeResponse {
  limitGroup: OrgLimitGroup | null;
}

const { limitGroup } = await sdk.getOrgLimitGroup();
if (limitGroup === null) {
  // legitimate response: the org has no group assigned. Not an error.
}
```

Only chains the org has a quota for appear in `limits`. Master or Employee; requires `ViewBalances`. A `404` here means something is wrong. Treat it as a hard failure, not as "no group".

## Policy rejections in the SDK

```typescript theme={null}
type OrgPolicyCode =
  | 'ERROR_KYC_REQUIRED'
  | 'ERROR_BUSINESS_HOURS_VIOLATION'
  | 'ERROR_ADDRESS_NOT_WHITELISTED'
  | 'ERROR_DAILY_LIMIT_EXCEEDED';

const ORG_POLICY_CODES: readonly OrgPolicyCode[] = [
  'ERROR_KYC_REQUIRED',
  'ERROR_BUSINESS_HOURS_VIOLATION',
  'ERROR_ADDRESS_NOT_WHITELISTED',
  'ERROR_DAILY_LIMIT_EXCEEDED',
];
```

| Code | Raised when |
| - | - |
| `ERROR_KYC_REQUIRED` | `forceEmployeeKyc` is on and the caller's KYC is not `verified` |
| `ERROR_BUSINESS_HOURS_VIOLATION` | `businessHoursEnabled` is on and the request falls outside the window or on a disallowed weekday |
| `ERROR_ADDRESS_NOT_WHITELISTED` | `forceAddressBook` is on and the destination is not a verified Corporate Address Book entry |
| `ERROR_DAILY_LIMIT_EXCEEDED` | the transfer would push the org past `dailySpendingLimitUsd` for the current UTC day |

`OrgPolicyRejectedError` is the *intended* typed surface for these, carrying `platformCode: OrgPolicyCode`.

> [!WARNING]
>
> `OrgPolicyRejectedError` is not thrown by the current release, so an
> `instanceof` branch on it never fires. Until it is, policy rejections arrive as ordinary HTTP
> failures:
>
> | Policy | Status | What you actually catch |
> | - | - | - |
> | KYC, business hours, daily limit | `403` | `PermissionDeniedError` |
> | Address-book whitelist | `400` | `ApiError` |
>
> Detect policy rejections from the status code and the message today, as below. The switch to
> the typed error will be additive, so a `catch` written that way keeps working.

Detecting a policy rejection as things stand:

```typescript theme={null}
import {
  ApiError,
  PermissionDeniedError,
  isTrustodianError,
} from 'vaults-multichain-sdk';

function policyMessage(error: unknown): string | null {
  let status: number | undefined;

  if (error instanceof ApiError) {
    status = error.statusCode;
  } else if (error instanceof PermissionDeniedError) {
    status = error.details?.statusCode as number | undefined;
  } else {
    return null;
  }

  const raw = error.message;

  if (status === 400 && raw.includes('ADDRESS_NOT_WHITELISTED')) {
    return 'Add this address to the Corporate Address Book before sending to it.';
  }
  if (status === 403) {
    if (raw.includes('business hours') || raw.includes('BUSINESS_HOURS')) {
      return 'Transfers are only allowed during your organization's business hours.';
    }
    if (raw.includes('Daily spending limit')) {
      return 'This transfer exceeds the organization's daily spending limit.';
    }
    if (raw.includes('KYC')) {
      return 'Your organization requires identity verification before you can send.';
    }
    if (raw.includes('ERROR_SPEND_AUTHORIZATION_REQUIRED')) {
      return 'This transfer needs a server-verified spend authorization.';
    }
  }
  return null;
}

try {
  await sdk.sendTransaction(input);
} catch (error) {
  const copy = policyMessage(error);
  if (copy) {
    show(copy);
  } else if (isTrustodianError(error)) {
    show(error.message);
  } else {
    throw error;
  }
}
```

> [!NOTE]
>
> The HTTP status lives in `error.statusCode` on `ApiError` but in `error.details.statusCode` on
> `PermissionDeniedError`, which is why the helper reads it from two places. Both carry the
> server's message on `error.message`. See [Errors](https://sdk-guide.mintlify.site/sdk-guide/errors.md).

Once the typed mapping is switched on, this helper can be replaced with an
`instanceof OrgPolicyRejectedError` check keyed on `error.platformCode`, touching only your `catch`
block.

## Audit logs

```typescript theme={null}
interface GetOrgAuditLogsInput {
  page?: number;              // default 1
  limit?: number;             // default 20
  dateFrom?: string;          // ISO 8601
  dateTo?: string;            // ISO 8601
  eventTypes?: OrgAuditEventType[];
  actorId?: string;           // UUID of the acting user
}

interface OrgAuditLogsResponse {
  total: number;
  page: number;
  limit: number;
  totalPages: number;
  items: OrgAuditLogItem[];
}

const logs = await sdk.getOrgAuditLogs({
  page: 1,
  limit: 50,
  dateFrom: '2026-01-01T00:00:00.000Z',
  eventTypes: ['Org Wallet Assigned', 'Org Policy Updated'],
});
```

Master-only, and the key must carry `AuditLogAccess`.

An unrecognised value in `eventTypes` is a `400`.

> [!WARNING]
>
> The declared `OrgAuditLogItem` type does not match what the server returns. The response is
> actually:
>
> ```typescript theme={null}
> {
>   id: string;
>   date: string;              // ISO 8601
>   event: string;             // event label
>   actorEmail: string | null;
>   targetId: string | null;
>   targetType: string | null; // WALLET | VAULT | TRANSACTION | ORGANIZATION |
>                              // MEMBER | ADDRESS | ACCOUNT | POLICY
>   ipAddress: string;
>   metadata: unknown | null;
> }
> ```
>
> Read the extra fields through a cast until the type is corrected.

### Event types

`OrgAuditEventType` enumerates the labels the SDK types. Each is triggered by:

| Event | Trigger |
| - | - |
| `Account Created` | A user account is registered |
| `Login` | A user signs in |
| `Password Changed` | Account password changed while signed in |
| `Password Reset` | Account password reset via the recovery flow |
| `Email Changed` | Account email change confirmed |
| `Email Reverted` | An email change is rolled back from the notification link |
| `2FA Enabled` / `2FA Disabled` | Two-factor authentication turned on or off |
| `Wallet Created` | A personal wallet is created |
| `Wallet Imported` | A personal wallet is imported from a private key |
| `Wallet Deleted` | A personal wallet is deleted |
| `Vault Created` | A multisig vault is created (personal or org) |
| `Vault Activated` | A vault activation is broadcast on chain |
| `Vault Recovery Kit Downloaded` | A vault's recovery file is downloaded (one row per vault) |
| `Transaction Created` | An order is created |
| `Transaction Signed` | An order receives a signature |
| `Transaction Rejected` | An order is rejected by an owner |
| `Transaction Executed` | A fully-signed order is broadcast |
| `KYC Initiated` | An individual KYC session is started |
| `Org Created` | The organization is created |
| `Org KYB Initiated` | KYB verification is started for the organization |
| `Org KYB Completed` | The KYB provider returns a completed result |
| `Org Member Invited` | `inviteOrgMember` |
| `Org Member Joined` | An invited member accepts and becomes active |
| `Org Member Removed` | `removeOrgMember` |
| `Org Wallet Assigned` | `assignOrgWallet` |
| `Org Wallet Unassigned` | `unassignOrgWallet` (also on the cascade inside member removal) |
| `Org Policy Updated` | `updateOrgPolicies` |
| `Org Address Added` | `addOrgAddress` |
| `Org Address Verified` | An address-book entry passes OTP verification (API-key adds are auto-verified) |
| `Org Address Updated` | `updateOrgAddress` |
| `Org Address Removed` | `deleteOrgAddress` |
| `Org Blocked` | The organization is blocked platform-side |
| `Org Activated` | The organization is activated or unblocked |

> [!NOTE]
>
> More labels appear in the response than the SDK's union covers. Treat `item.event` as a `string`
> when matching, and do not assume the union is exhaustive.

### CSV export

```typescript theme={null}
interface ExportOrgAuditLogsInput {
  dateFrom?: string;
  dateTo?: string;
  eventTypes?: OrgAuditEventType[];
  actorId?: string;
}

const response = await sdk.exportOrgAuditLogs({
  dateFrom: '2026-01-01T00:00:00.000Z',
  dateTo: '2026-01-31T23:59:59.999Z',
});
```

Master-only; the key must carry `AuditLogAccess`. The method resolves with the raw fetch `Response` so you can stream it.

```typescript theme={null}
import { Writable } from 'node:stream';
import { createWriteStream } from 'node:fs';

const response = await sdk.exportOrgAuditLogs({ dateFrom });
await response.body!.pipeTo(Writable.toWeb(createWriteStream('audit.csv')));
```

Same filters as `getOrgAuditLogs`, minus pagination. The export streams the entire filtered result set.

The response is `Content-Type: text/csv` with `Content-Disposition: attachment; filename="org-audit-logs-YYYY-MM-DD.csv"`. The header row is fixed:

```csv theme={null}
Date,Event,Actor Email,IP Address,Metadata
2026-01-14T09:12:44.031Z,Org Wallet Assigned,master@example.com,203.0.113.7,"{""memberId"":""…"",""walletId"":""…""}"
```

`Date` is the ISO 8601 timestamp, `Metadata` is the JSON blob serialized and CSV-escaped (embedded quotes doubled). Fields containing a comma, quote, or newline are quoted.

## Method reference

| SDK method | Role | API-key permission |
| - | - | - |
| `getOrgPolicies` | Master, Employee | `PolicyManagement` |
| `updateOrgPolicies` | Master | `PolicyManagement` |
| `getOrgLimitGroup` | Master, Employee | `ViewBalances` |
| `getOrgSpendingLimit` | Master, Employee | `ViewBalances` |
| `getOrgAuditLogs` | Master | `AuditLogAccess` |
| `exportOrgAuditLogs` | Master | `AuditLogAccess` |


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