Skip to main content
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

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.
  • 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.
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.

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)

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 PATCHPATCH is not supported on that route.

Identity / user lifecycle

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.)
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.
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.”
  • 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.
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.
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.
For the full status vocabulary, see State machines; for the webhook event catalog, see 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.
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.

Virtual accounts

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.
  • 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.
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.
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.
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.
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.

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

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.

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_trustWIRE, 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

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.
  • 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 objectdeposit_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.
For webhook setup, the event catalog, and event payloads, see Webhooks. For the per-resource state machines, see State machines.