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

# Known limitations & quirks

> Documented sandbox and API gotchas to know before you integrate — read these before you debug for an hour.

These are known sandbox and API quirks worth knowing before you build against them. Each one is a hard-won integration detail — field renames, inconsistent casing, contradictory status fields, and missing endpoints — that can cost you time if you hit it blind. They are listed upfront so you don't lose an hour debugging. All of these are documented gotchas that the `v2026-XX-XX` revamp resolves.

## Request size and large document uploads

<Warning>
  **The whole request body is capped at 10 MB.** Stay under it by passing large documents as `https://` URLs (see below) rather than inline base64 — a normal-size payload on the same endpoint succeeds, so the limit is size-specific, not a broken endpoint.

  **Inline base64 documents are the usual way clients blow past it.** Base64 inflates a file by \~33%, and the **entire JSON body** counts against the 10 MB cap — not each file individually. A few multi-MB images base64-encoded into one create call will exceed 10 MB fast. For anything beyond a small inline file, pass `identifying_information[].documents[].file` as an `https://` URL — Kira fetches it asynchronously, it does not count against the body cap, and each file may be up to 30 MB (the server-side per-file cap on the URL path). Payout `supporting_documents[].file` has no URL form, so keep those small (≤ 3 MB each).

  **Stay under the limit:**

  * **User documents** — pass `documents[].file` (and the legacy flat `documents[]`) as an `https://` URL instead of an inline base64 data URI. Kira downloads the file asynchronously, so the bytes never count against the request body. URL documents require API version `2026-04-14` or later, and the host must be on your allowed-domains list. See [Creating users](/guides/creating-users).
  * **Payout `supporting_documents`** — these accept base64 only (no URL form). Keep each file within its 3 MB cap; with at most one `invoice` + one `other`, two 3 MB files base64-encode to ≈ 8 MB and fit under the 10 MB body limit.
</Warning>

<Note>
  **Only JPEG, PNG and PDF are accepted as inline base64 — the validation error message overstates it.** The `file` validator accepts exactly `data:image/jpeg`, `data:image/png` and `data:application/pdf`. Its error text is generated from a wider internal list and also names `image/heic` and `image/webp`, which are **rejected**. HEIC is the iPhone camera default, so this bites often: convert to JPEG before encoding. A high-resolution scan is also worth rasterising down (≈200 dpi keeps a passport MRZ legible) — a single-page scan can easily exceed 3 MB and eat most of the body budget.
</Note>

## Error responses

* **Malformed-id handling differs by resource — don't write one error handler for it.** User, recipient, and virtual-account ids are format-validated **before** lookup: a syntactically invalid (non-UUID) id returns `400` with `{ "error": "Invalid request data", "details": [ { "path", "message", "code" } ] }`, and `404` is reserved for well-formed ids that don't exist. **Payout ids skip this pre-validation**: any unknown payout id — malformed or not — returns `404` with `code: PAYOUT_NOT_FOUND`. Branch per resource on the HTTP status, not on a single error shape (shapes are not uniform across the API — see `ValidationErrorResponse` in the reference).

## Routing errors (wrong path or method)

<Warning>
  **A wrong path or an unsupported method is a routing error — not an auth or signature failure — even though it can come back as a `403`.** On a normal authenticated call (carrying your `Authorization: Bearer` token), an unknown path or an unsupported method returns `403` with `code: invalid_request` and a message telling you to check the path and method; it explicitly is **not** a credentials or signature problem. (An unauthenticated request to an unknown path returns `404` with `code: route_not_found`.) The API does not distinguish "no such path" from "method not supported," so don't infer one from the other — re-check the exact path and HTTP method against the API reference before touching your credentials. The most common instance: **update users with `PUT /v1/users/{id}` (→ `200`), not `PATCH`** — `PATCH` is not supported on that route.
</Warning>

## Identity / user lifecycle

<Warning>
  **Verification is automatic — but approval is manual and is not guaranteed to land within seconds.** Identity verification triggers automatically once the user is **complete** for at least one product (`verification_triggered: true`) — there is no verification call for you to make. Final approval is a **manual** step on Kira's side; per Kira it is not instant and can take **up to 24 hours** — don't build against a "verifies in seconds" assumption. The sandbox can route a complete user to **manual review** (`status: REVIEW`, `verification_status: in_review`) at any point in that window — more likely for businesses (KYB) and for low-quality or placeholder document images — which a Kira operator resolves; do not take instant approval as guaranteed, nor as production parity. Scalar values may be fake, but **document files must be real, retrievable bytes**: verification downloads each `file` at submission time, so a placeholder or unreachable `https://` document URL (or junk base64) means the documents cannot be uploaded to the provider and the user stays `CREATED` even though `verification_triggered` was `true`. An **incomplete** user is neither verified nor rejected: verification does not trigger (`verification_triggered: false`), the user stays `CREATED`, and the outstanding items are listed in the `missing_fields` map — supply them with `PUT /v1/users/{id}` and verification triggers on its own. A missing questionnaire answer like `source_of_funds` behaves differently by user type. For an **individual**, live-tested, it does not by itself block verification from triggering and does not cause an auto-reject (a user missing only `source_of_funds` still triggered verification and landed in `REVIEW`). For a **business**, `source_of_funds` is a **blocking** KYB field — live-tested, a business missing only `source_of_funds` did NOT trigger verification and stayed `CREATED` with `source_of_funds` in `missing_fields`. Send it on every user regardless of type, since it's required for product eligibility either way.

  A terminal `REJECTED` means the provider **evaluated your data and declined it** (note: `verification_status` may still read `unverified` — gate on `status`). The rejection reason is delivered ONLY in the `user.verification.failed` webhook (`data.reasons[]`) — `GET /v1/users/{id}` never exposes it. If `REJECTED`, fix the data with `PUT /v1/users/{id}` and ask your Kira contact to re-trigger verification. A rejection is distinct from a verification-**provider call failure** (a network / 5xx / timeout reaching the provider), which is a retryable system error: the user stays non-terminal (`VERIFYING`), no `user.verification.failed` fires, and the transition surfaces via `user.status_changed`. (The `verify+approved@kira.test` magic-email behavior was a proposal that never shipped.)
</Warning>

<Note>
  **`status` and `verification_status` can contradict each other on the same user.** Observed in production data: `status: VERIFIED` paired with `verification_status: unverified` / `in_review` / `started`. The relationship between the two fields is undefined today. **Gate your state machine on `status`**, and treat `verification_status` as advisory only.
</Note>

<Note>
  **`REVIEW` means manual review** — the provider has the complete submission and a human decision is pending; it resolves to `VERIFIED` or `REJECTED` with no action on your side (contact Kira if it lingers). `ACTIVE` is an undocumented `status` value seen in real tenants — handle it (and any other unknown value) as "non-terminal, do not gate flow on it."
</Note>

* **Field renames between request and response.** For example, `address_street` becomes `residential_address.street_line_1`, `address_zip_code` becomes `postal_code`, and `address_state` becomes `subdivision`. Always parse the response shape — never assume request fields round-trip.
* **Country codes are inconsistent across resources.** ISO-3 (`MEX`, `USA`) on **users**, ISO-2 (`MX`, `US`) on **recipients**. Make sure your client maps correctly.

<Warning>
  **A partial `PUT` of `associated_persons` silently clears the person's four identity fields.** Associated persons are matched on identity — `person_reference_id`, then `person_id`, then `email`, then name + `birth_date`, then name alone — so a person whose stored `email` is blank or absent can be corrected in place, without duplicating them. Two caveats. **To change an email that is already set, send `birth_date` as well** — a `PUT` carrying only the name and a new email is read as a *different person* with the same name and is added rather than merged, silently and with a `200`. The same applies to correcting a `birth_date` from a name alone; identify the person by their stored `email` or by name + the stored `birth_date` and it is corrected in place. Second, a person who was **already** duplicated by an earlier `PUT` cannot be collapsed back from the API — contact support for those. Ordinary fields you omit are preserved (`first_name`, `last_name`, `birth_date`, `title`, `ownership_percentage`, `has_ownership`, `has_control`, `is_signer`, `identifying_information`). But **`nationality`, `document_number`, `document_country` and `document_type` are re-emitted on every write** — omit them and they are overwritten as empty, whichever ones you happened to leave out. Reproduced across successive PUTs on the same KYB user: a `PUT` carrying `nationality` + `document_number` cleared the two document fields, and the next one carrying `document_type` + `document_country` cleared the first two back. **Send all four on every `PUT` that touches an associated person**, not just the fields you are changing. The only signal that data was lost is the `missing_fields` map on the next response.

  This compounds with the `document_type` behaviour below: a partial `PUT` that drops `document_type` silently reverts the person to the `ID_CARD` default and its two-sided requirement.
</Warning>

<Warning>
  **Omitting `associated_persons[].document_type` submits the UBO as an ID card, which then requires a back image.** The field defaults to `ID_CARD` when absent, and an ID card is two-sided — so a UBO whose only document is a passport front is submitted incomplete: the front uploads, the back is skipped (no URL), and the beneficiary applicant either stalls or is rejected while the parent KYB continues without it. Send `document_type: "passport"` explicitly (passports are single-sided and no back is requested). Combined with the merge caveat above, this is easy to hit: a `PUT` that restores `nationality` but omits `document_type` re-introduces the problem.
</Warning>

For the full status vocabulary, see [State machines](/guides/state-machines); for the webhook event catalog, see [Webhooks](/guides/webhooks).

* **User `email` is NOT enforced unique.** Creating two users with the same email succeeds (`201` both times) — duplicate-user detection is per-client on the identity document (`document_number` + type + country, surfacing as `400` `"User with this information already exists"`), not on email. Deduplicate emails on your side if your product requires it.
* **Questionnaire fields don't round-trip.** `account_purpose`, `source_of_funds`, `expected_monthly_volume`, `expected_transaction_count`, and `employment_status` are accepted on `POST`/`PUT` but are currently **not returned** by `GET /v1/users/{id}` — persist what you sent. The `missing_fields` map still tells you whether the API considers them supplied.

<Warning>
  **Editing a KYC/KYB-required field on an already-`VERIFIED` user re-triggers verification** and transiently moves the user out of `VERIFIED`. The required set includes the compliance scalars `expected_monthly_volume` and `expected_transaction_count`. Because virtual-account creation requires a `VERIFIED` user, patching these fields with `PUT /v1/users/{id}` *after* verification can temporarily block `POST /v1/virtual-accounts` until re-verification completes (the `PUT` response reports `requires_reverification: true`). **Supply `expected_monthly_volume`, `expected_transaction_count`, and any other product-required fields at user creation**, so they are part of the initial verification rather than a post-verification edit. If you must change them afterward, wait for `status` to return to `VERIFIED` before creating the virtual account.
</Warning>

## Virtual accounts

<Note>
  **Sandbox VAs activate automatically.** The sandbox polls provider status every 15 seconds, so a new VA typically becomes funds-ready within about a minute and the `virtual_account.activated` webhook fires on its own. If a VA stays not-funds-ready (`account_number` still `null` / `"PENDING-ACT-ACCOUNT"`) well beyond that, contact your Kira representative.
</Note>

* **`GET /v1/virtual-accounts/{id}/balance` returns `200`** with `available_balance` on an active ACT VA, and `payout/preview` + `payout` return `200`/`201` with a fee breakdown. While the VA is still activating, balance returns `400` — confirm readiness via `account_number` being a real account number — non-null AND not equal to `"PENDING-ACT-ACCOUNT"` (the sentinel is ACT-only; international / crypto VAs show `account_number: null` until provisioned) — and the `virtual_account.deposit_*` webhooks. `approved` does not mean funds-ready.
* **There is no `provider` or `currency` field on a virtual account.** The account's rail is exposed as `bank` (not `provider`), and a crypto virtual account's currency is under `destination.currency` — both identical in the list and get-by-id views.
* **Listing a user's virtual accounts.** To list a user's virtual accounts, use `GET /v1/virtual-accounts?user_id={userId}`. A legacy per-user route `GET /v1/users/{userId}/virtual-accounts` still exists but returns an older, un-paginated bare-array shape (no `bank`, `mode`, or flat bank fields) and is not recommended for new integrations. The legacy `POST /v1/users/{userId}/virtual-accounts` create route is deprecated — create virtual accounts with `POST /v1/virtual-accounts`.

<Warning>
  **Casing is inconsistent across surfaces — ALWAYS compare statuses case-insensitively.** `GET` resource `status` is UPPERCASE for payouts and deposits (`CREATED/PENDING/PROCESSING/COMPLETED`; `PENDING/COMPLETED`) and follows the version vocabulary for VAs (lowercase `approved`/`rfi`/`deactivated` on this pin). BUT the payout **201 create response** returns `status: "created"` lowercase while `GET /v1/payouts/{id}` returns `CREATED` for the same payout. Flat payout/VA webhook events carry lowercase `data.status` (`created`, `pending`, `processing`, `activating`); `payout.status_changed` carries UPPERCASE at `data.data.status`; `user.*` events carry UPPERCASE `data.status` (`CREATED`). See [State machines](/guides/state-machines).
</Warning>

<Note>
  **One ACT virtual account per user.** Re-creating returns `409 Conflict`. Use the existing VA via List/Get. The limit counts **both** modes — a user with a `fiat` ACT VA cannot also have a `crypto` one. Only VAs in `failed` or `deactivated` free the slot.
</Note>

<Warning>
  **In production, ACT activation is a bank-side approval with no useful upper bound — the sandbox's \~1 minute is not representative.** The sandbox force-approves; production never does. Activation can land within the hour or take several days, and the outcome is the bank's to give. Treat it as asynchronous: wait on the `virtual_account.activated` webhook (or poll) and never block a user-facing flow on it.

  There is **no timeout.** The only outcomes are ACT approving (`active`) or ACT rejecting (`failed`, `statusReason: "ACT application rejected"`); elapsed time alone never resolves a VA, so one whose application the bank simply never acts on stays `activating` indefinitely while polling continues. Two internal alerts fire (a warning at 2 h, a critical at 24 h) but neither changes the account state. Do not gate a user-facing flow on same-day activation, and if a VA is still `activating` well past a day, contact your Kira representative with the account id so the application can be chased with the bank.
</Warning>

<Note>
  **ACT account creation can be temporarily disabled.** When creation for a bank is turned off, `POST /v1/virtual-accounts` with that `bank` returns `400 "Account creation is currently disabled for this bank"`. Existing accounts keep working (balance, deposits, payouts are unaffected) — only new-account creation is blocked. Retry creation once the bank is re-enabled; contact your Kira representative for timing.
</Note>

## Pre-verified test users (current ACT-product fields gap)

<Note>
  The seed user we ship was verified via AiPrise but is **missing the field set required for ACT VA creation** (`immigration_status`, `additional_info:has_us_bank_account`, `additional_info:has_denied_bank_account`, etc.). You can still read/list/operate the existing VAs on that user. To create a *fresh* ACT VA on a user, the user must be re-verified with the full ACT field set — coordinate with your Kira contact.
</Note>

## Recipients

* **Recipient response uses `recipient_id`, not `id`**, and `created_ts` (non-ISO 8601), not `created_at`. The User and VA resources use the standard `id`/`created_at` — recipients are the outlier.
* **The request's `account` object comes back renamed.** Responses carry a top-level `account_type` plus the bank/wallet details under `account_details` — there is no `account` field on the way out. Parse the response shape; don't assume the create payload round-trips.
* **Recipients are scoped to the client, not the user — even though you create and list them per-user.** `POST /v1/recipients` requires a `user_id` and `GET /v1/recipients` requires a `user_id` filter, but a **payout may target any recipient in your client** — regardless of which user the recipient was created for, or which user owns the paying virtual account. Treat recipients as a client-wide book for payout purposes.
* **Recipients are immutable after creation.** There is no update endpoint for a recipient's account details — you can `POST` (create), `GET` (list), and `GET /v1/recipients/{recipient_id}` (read), but you cannot change the `account` fields of an existing recipient. To correct or add account details later, **create a new recipient** and use it for the payout.

## Payouts

* **API-key clients never need to send an OTP.** A payout created with just your `Authorization: Bearer` token and `x-api-key` header — the normal API-integrator credentials — is never asked for a one-time passcode. OTP only applies to a payout a human initiates from the Kira dashboard. A payout request from the API that's missing something fails on ordinary validation instead — for example an omitted `recipient_id` fails with an ordinary `400` validation error, and an unknown `recipient_id` returns `404 "Recipient <id> not found"` — never an OTP-required error.
* **No fee schedule endpoint and no `dry_run` mode.** Preview Payout will return `400 "Total fees exceed or equal the payout amount"` for amounts below the minimum — there's no way to know the minimum without iterating.
* **Duplicate fields in fee response.** `fees.total` and `fees.total_fees` are identical. Use either.
* **Unit ambiguity.** `bank_account_fee_percentage: "0.0004"` is a ratio; `bank_account_fee: "0.01"` is dollars. Different units, sibling fields.
* **Payout rails are per-bank.** A recipient's `account_type` must be supported by the paying VA's bank: `austin_capital_trust` → `WIRE`, `ACH`; `slovak_savings_bank` (sandbox) / `portage` (production) → `WIRE`. `WALLET` (crypto) works on any bank. An unsupported rail returns `400 "<account_type> payouts are not supported for this virtual account. Supported rails: …"`. In practice this means **`ACH` payouts require an `austin_capital_trust` VA** — a `slovak_savings_bank` / `portage` VA rejects `ACH`.
* **Crypto-funded payouts require a wallet vault on your client account.** A payout that mints a single-use deposit address (`payment_instructions`, or `mode: "CRYPTO"`) fails with a `400` reporting that the client has no wallet vault configured, when the account has not been provisioned with one. This is account setup, not something you can fix in the request — contact your Kira representative to have it enabled before integrating crypto-funded payouts. Fiat payouts from the VA balance are unaffected.

## Deposits

<Check>
  **A simulated deposit credits the VA balance.** On the fiat wire rail, `simulate-deposit` fires the deposit **event and webhook** *and* credits the VA's available balance; a payout **debits** it, so `deposit → balance → payout` is a real cycle. `GET /…/balance` returns `200` with `available_balance` on an active VA and reflects the credit (it may take a few seconds). The balance also includes any opening sandbox float, so a payout can succeed before a deposit — confirm the balance covers the amount first. Balance behavior is not uniform across every rail, so treat the `virtual_account.deposit_funds_received` webhook as the rail-independent signal. The simulated deposit is also returned by `GET /v1/virtual-accounts/{id}/deposits` — don't rely on the list staying empty. Note `settlement_triggered: false` on a fiat deposit is normal (fiat is already completed), not an error.
</Check>

* **`simulate-deposit` funds the VA on the fiat wire rail — a real credit, not just a notification.** It fires the **deposit event** (and its `virtual_account.deposit_funds_received` webhook) so you can test your deposit-notification / reconciliation handling, **and** it increases the VA's available balance, which a payout then **debits**. A fiat deposit is already `completed` at simulation time — there is **no** separate settle / force-settle step and no settlement schedule to wait for. A payout draws on the VA's whole available balance (any opening sandbox float plus the deposits you credit), so a payout can also succeed without a prior deposit; not every rail moves its balance on a simulated deposit, so keep the webhook as the rail-independent signal.
* **Snake\_case + camelCase mixed in the same response object** — `deposit_id` next to `internalPaymentId`. Tolerate both casings in your parser.

## Webhooks

* **Webhook setup is managed by Kira.** There is no self-serve webhook API — ask the Kira team to configure your HTTPS delivery URL when you need events. There are no list, update, or delete endpoints either.
* **No retry policy on failed deliveries.** Your endpoint must be highly available or you lose events.

<Note>
  For webhook setup, the event catalog, and event payloads, see [Webhooks](/guides/webhooks). For the per-resource state machines, see [State machines](/guides/state-machines).
</Note>
