v2026-XX-XX revamp resolves.
Request size and large document uploads
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
400with{ "error": "Invalid request data", "details": [ { "path", "message", "code" } ] }, and404is reserved for well-formed ids that don’t exist. Payout ids skip this pre-validation: any unknown payout id — malformed or not — returns404withcode: PAYOUT_NOT_FOUND. Branch per resource on the HTTP status, not on a single error shape (shapes are not uniform across the API — seeValidationErrorResponsein the reference).
Routing errors (wrong path or method)
Identity / user lifecycle
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_streetbecomesresidential_address.street_line_1,address_zip_codebecomespostal_code, andaddress_statebecomessubdivision. 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.
- User
emailis NOT enforced unique. Creating two users with the same email succeeds (201both times) — duplicate-user detection is per-client on the identity document (document_number+ type + country, surfacing as400"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, andemployment_statusare accepted onPOST/PUTbut are currently not returned byGET /v1/users/{id}— persist what you sent. Themissing_fieldsmap still tells you whether the API considers them supplied.
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}/balancereturns200withavailable_balanceon an active ACT VA, andpayout/preview+payoutreturn200/201with a fee breakdown. While the VA is still activating, balance returns400— confirm readiness viaaccount_numberbeing a real account number — non-null AND not equal to"PENDING-ACT-ACCOUNT"(the sentinel is ACT-only; international / crypto VAs showaccount_number: nulluntil provisioned) — and thevirtual_account.deposit_*webhooks.approveddoes not mean funds-ready.- There is no
providerorcurrencyfield on a virtual account. The account’s rail is exposed asbank(notprovider), and a crypto virtual account’s currency is underdestination.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 routeGET /v1/users/{userId}/virtual-accountsstill exists but returns an older, un-paginated bare-array shape (nobank,mode, or flat bank fields) and is not recommended for new integrations. The legacyPOST /v1/users/{userId}/virtual-accountscreate route is deprecated — create virtual accounts withPOST /v1/virtual-accounts.
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.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, notid, andcreated_ts(non-ISO 8601), notcreated_at. The User and VA resources use the standardid/created_at— recipients are the outlier. - The request’s
accountobject comes back renamed. Responses carry a top-levelaccount_typeplus the bank/wallet details underaccount_details— there is noaccountfield 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/recipientsrequires auser_idandGET /v1/recipientsrequires auser_idfilter, 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), andGET /v1/recipients/{recipient_id}(read), but you cannot change theaccountfields 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: Bearertoken andx-api-keyheader — 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 omittedrecipient_idfails with an ordinary400validation error, and an unknownrecipient_idreturns404 "Recipient <id> not found"— never an OTP-required error. - No fee schedule endpoint and no
dry_runmode. Preview Payout will return400 "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.totalandfees.total_feesare 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_typemust 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 returns400 "<account_type> payouts are not supported for this virtual account. Supported rails: …". In practice this meansACHpayouts require anaustin_capital_trustVA — aslovak_savings_bank/portageVA rejectsACH. - Crypto-funded payouts require a wallet vault on your client account. A payout that mints a single-use deposit address (
payment_instructions, ormode: "CRYPTO") fails with a400reporting 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-depositfunds the VA on the fiat wire rail — a real credit, not just a notification. It fires the deposit event (and itsvirtual_account.deposit_funds_receivedwebhook) 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 alreadycompletedat 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_idnext tointernalPaymentId. 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.