What this page is. A canonical brief you can give to your AI coding assistant. Save it to your repo root as
AGENTS.md (Claude Code and most AI tools auto-detect this filename), or paste it into your Claude / Cursor / ChatGPT system prompt with the header “Use this brief when writing code that calls Kira’s API.” Then tell your AI: “Build me a [thing] that talks to Kira. Use this brief as your source of truth — don’t guess.” The agent will then have everything it needs to integrate without round-tripping with you for every field name.Identity & versioning
- Sandbox base URL:
https://api.balampay.com/sandbox - Production base URL:
https://api.balampay.com(do not call until production credentials are issued) - API version:
2026-04-14(always sendX-Api-Version: 2026-04-14header until the account is pinned) - Reference data:
GET /v1/countriesreturns{count:250, data:[...]}. (/api/countriesand/countriesreturn403— wrong path.) - Credentials needed from Kira:
api_key,client_id(UUID),password. Provided by your Kira contact through a secured channel.
Auth flow
CallPOST /auth with only your x-api-key:
One-time setup: pin the account to v2026-04-14
target_version, not version. After this, the X-Api-Version header becomes optional. See Versioning.
Required headers — every mutating request
| Header | Value | Required? |
|---|---|---|
Authorization | Bearer <access_token> | Always |
x-api-key | The api_key from your creds | Always |
X-Api-Version | 2026-04-14 | Optional after pin; safe to always send |
Idempotency-Key | UUID v4, new per logical request | Required on 6 endpoints (see below) |
Content-Type | application/json | On all POST/PUT |
Idempotency-Key:
POST /v1/usersPOST /v1/users/{id}/verificationsPOST /v1/recipientsPOST /v1/virtual-accountsPOST /v1/virtual-accounts/{id}/payoutPOST /v1/virtual-accounts/{id}/liquidation-address
Core resources & their state machines
For the full state-machine reference and webhook catalog, see State machines & webhook catalog.User (will be renamed Client in v2026-XX-XX)
VERIFIED is the KYC gate only — it does NOT mean the user is product-ready. A VERIFIED user is normally eligible: false per product until that product’s required fields are filled. Read GET /v1/users/{id} → eligible_products[] (product_code, eligible) and the top-level missing_fields map (product_code → [field tokens]).
Timing:
eligible_products[] / missing_fields are empty/null on a freshly CREATED user — they populate only once verification has run; read them AFTER the user leaves CREATED (or after your Kira contact flips it), not immediately after POST.PUT /v1/users/{id}, document gaps by re-submitting uploads, then the product flips eligible: true. ACT (usa-virtual-accounts-act) and Portage (usa-virtual-accounts) have different field requirements.
Conditional tokens you’ll see: ssn:unless_immigration_status:non_us_citizen, occupation:when_employment_status:employed, identifying_information:back:unless_doc_type:passport, plus associated_persons: / additional_info: prefixes.
To get a user to VERIFIED in sandbox: sandbox does NOT auto-verify — but identity verification RUNS automatically on create (verification_triggered: true) and CAN auto-REJECT within seconds if required KYC data is missing — most commonly an omitted source_of_funds (the KYC provider’s questionnaire requires it). A rejected user fires the user.verification.failed webhook and flips to terminal REJECTED (note: verification_status may still read unverified — gate on status). Include source_of_funds in the create payload. The rejection reason is delivered ONLY in the user.verification.failed webhook (data.reasons[]) — GET /v1/users/{id} never exposes it. Use a real-looking photo/scan (tiny 1-pixel placeholders get rejected), create a COMPLETE user (all product fields + document uploads present and well-formed — scalar values may be fake), then ask your Kira contact to flip it to VERIFIED. If REJECTED, ask your Kira contact to re-trigger verification.
Virtual Account
approved; the older pending/activating/active ladder is wrong for this pin.
approved ≠ funds-ready. Detect a truly-active VA via account_number being a real account number — non-null AND != "PENDING-ACT-ACCOUNT" (the sentinel is ACT-only; SWIFT-capable / crypto VAs show account_number: null until provisioned). On an active ACT VA, GET /v1/virtual-accounts/{id}/balance returns 200 with available_balance (while activating it returns 400); confirm funding via account_number + virtual_account.deposit_* webhooks. On an active ACT VA, GET returns bank: null with payment_methods: WIRE+ACH.
Constraint: only one ACT VA per user. Re-creating returns
409 Conflict. Read the existing VA via List/Get.Payout
400 "Insufficient balance". Note the sandbox balance is a FIXED provider value: simulate-deposit returns 201 + deposit_id + status: "completed" but the simulated deposit does NOT appear in GET /…/{id}/deposits and does NOT change GET /…/balance. Confirm a simulated deposit via the 201 response itself + the (hand-delivered) virtual_account.deposit_funds_received webhook — NOT the deposits list, NOT a balance delta.
Request body shapes (the things AI agents commonly hallucinate)
Create individual user — international (ACT route, no documents)
verification_modemust be"automatic"immigration_status(non-US ACT) is exactly one of:"U.S. Citizen","Permanent U.S. Resident","Lawful Permanent Resident of U.S.","Non-Permanent U.S. Resident","Non-Resident of U.S."- Documents are file uploads: government IDs / business docs need
identifying_information[].documents: [{type:"front"|"back", file:"data:image/jpeg;base64,…"}](HTTPS URL ok on 2026-04-14+). Tax IDs (ssn/itin/ein/curp/rfc/...) are number-only.backwaived for passports. Do NOT sendssnfor non-US individuals, oreinfor non-US businesses (useinternational_entity_type) additional_infoboolean fields are strings ("yes"/"no"), not booleans; omitting them silently declares"no"nationalityandaddress_countryuse alpha-3 ISO codes (MEX,USA)account_purposeaccepted enum (sending any other value →400 invalid_enum_valuelisting these):
account_purpose accepted values
account_purpose accepted values
receive_payments · manage_professional_income · make_payments · manage_personal_funds · investment_trading · charitable_donations · investment_purposes · operating_a_company · payments_to_friends_or_family_abroad · personal_or_living_expenses · purchase_goods_and_services · protect_wealth · receive_salary · receive_payment_for_freelancingCreate business user — USA (with EIN)
business_industryis an array of NAICS-style enums, NOT a string. Valid example:"merchant_wholesalers_nondurable_goods". Invalid:"wholesale_trade","trading".business_typeenum:llc/corporation/partnership/sole_proprietorship/trustassociated_personsrequires at least one entry withrole: "control_prong". USA businesses need an SSN on that AP.
Create virtual account
Two routes: 1. USD fiat via ACT (Austin Capital Trust — US bank account):bank: "slovak_savings_bank" in sandbox, bank: "portage" in prod:
(destination.currency, destination.network) pairs today: (USDC, solana), (USDC, polygon), (USDT, tron), (USDT, solana), (USDT, polygon).
Gotchas:
typemust be"US_BANK"— it is the only supported type today (even crypto VAs are"US_BANK").MX_SPEIand any other values may appear in the enum but are not wired up — do not use.- Only three currencies are supported on
US_BANKtoday: USD (fiat) and USDT / USDC (crypto). No other fiat currencies (EUR, GBP, MXN, etc.) and no other crypto assets (BTC, ETH, etc.) are accepted. bankis REQUIRED forUS_BANK— including crypto. Omitting it returns400 "bank is required for US_BANK virtual accounts". The value is environment-scoped: sandbox usesslovak_savings_bank(SWIFT/crypto) oraustin_capital_trust(ACT); production usesportage(SWIFT/crypto) oraustin_capital_trust(ACT — domestic). Sendingportagein sandbox returns400 "Invalid bank"(authorization gate, not geography).providerenum isact | zenusONLY. There is noprovider: "portage"/provider: "slovak_savings_bank"— sending either returns400 "Expected 'act' | 'zenus'". Rail selection for everything else is thebankfield; the service mapsbank → provider(austin_capital_trust → act,portage / slovak_savings_bank → SWIFT/crypto rail,zenus → zenus). (provider: "act"aliasesbank: "austin_capital_trust".)- SWIFT (outbound) = the SWIFT / international-wire rail, NOT ACT. ACT is domestic WIRE/ACH/FedNow only and cannot SWIFT. For SWIFT-capable fiat use
bank: "slovak_savings_bank"(sandbox) /bank: "portage"(prod) withmode: "fiat". In sandbox the SWIFT-capable VA still showspayment_methods: WIRE+ACH(SWIFT inbound not provisioned) — SWIFT is an outbound-payout capability of the rail, not a visible VA inbound method. modeis lowercase"fiat"or"crypto". Uppercase fails.- For crypto VAs,
destination.addressis required at create time — it’s the on-chain wallet you own where deposits will sweep. Kira does not custody this wallet. - One ACT VA per user. Re-creating an ACT VA returns
409 Conflict— read the existing one via List/Get.
Create recipient — SWIFT (international wire)
- Country codes are alpha-2 here (
MX,US) — different from User which uses alpha-3 (MEX,USA) - Field is
street_name, notstreet - The bank’s address goes in
account.bank_address, NOTaccount.address - Required at top level:
first_name,last_name(orcompany_namefortype: business), andemail(REQUIRED for WIRE / ACH / SWIFT recipients; SWIFT additionally requiresphone) account.bank_addressis REQUIRED for WIRE / SWIFT as a STRUCTURED OBJECT ({street_name, city, state, postal_code, country},country2-letter) — omitting it →400 "account.bank_address: Required"account.account_typeis a discriminator:SWIFT/WIRE/ACH/SPEI/WALLET/USD/ etc.
Preview payout
recipient_idat top level, NOT nested underdestination- Crypto VAs use
payment_instructionsinstead ofrecipient_id - Minimum payout amount is ~$3 for SWIFT (no fee schedule endpoint exists — iterate up if you hit
400 "Total fees exceed or equal the payout amount")
Register webhook
- Path is
/webhooks/register— NO/v1/prefix. Different stack. - Body field is
client_uuid, NOTclient_id(only place in the API where this naming is used) - Response:
{"message":"Webhook registered successfully"}— noid, no echo. There is no GET / PATCH / DELETE for webhooks today. - No retry on failed deliveries today. Your endpoint must be highly available.
Anti-patterns to avoid (do NOT do these)
Webhook signature verification
Every webhook delivery includes an HMAC-SHA256 signature in the headers. Verify it before processing:x-signature-sha256 header (hex HMAC-SHA256 over the raw body). Verify against the raw bytes with a constant-time compare. Single delivery, NO retry on this pin. De-duplicate by data.event_id — event_id sits at data.event_id in BOTH envelope shapes (there is NO root-level event_id); payout.status_changed additionally double-nests its payload at data.data (status UPPERCASE + previous_status). Make processing idempotent.
Known event types (subscribe in your handler)
user.createduser.updated/user.status_changed/user.verification.failed(automatic verification failed → user flips to terminalREJECTED; reason indata.reasons[]) /user.verification.accepted(verification approved) /user.document.download.failedvirtual_account.createdvirtual_account.activated— the only funds-ready VA webhook; you must handle itvirtual_account.deposit_funds_received/virtual_account.deposit_funds_in_destination(notvirtual_account.deposit.completed)payout.created/payout.pending/payout.processing/payout.completed/payout.failed/payout.returnedpayin.*,card_payment.*
payout.status_changed V2 with the nested data.data payload), and signing details.
Other event types may be delivered. Default to
2xx + log for unknown events; do not return 4xx (sender will not retry on 4xx).Common errors and what they mean
| HTTP | Body excerpt | What it means |
|---|---|---|
400 | "target_version is Required" | You sent {"version":...} to versioning/upgrade. Use target_version. |
400 | "Expected 'act' | 'zenus'" | You sent an unsupported provider. Use act or zenus; select other rails via bank. |
400 | "idempotency-key" "Required" | You forgot the Idempotency-Key header. Generate a UUID v4. |
400 | "User is missing required fields for product…" | The user isn’t fully populated for the product (e.g., ACT needs immigration_status, additional_info.has_us_bank_account). |
400 | "Total fees exceed or equal the payout amount" | Your payout amount is below the minimum (~$3 SWIFT). |
400 | "Insufficient balance" | VA balance less than requested payout. Simulate a deposit first. |
400 | "Virtual account is not active" | VA is still activating. Wait and retry, or use a different VA. |
401 | "Unauthorized" | Token expired or wrong api_key. Re-auth. |
404 | (HTML or JSON) | Wrong path. Common mistake: /v1/webhooks/register instead of /webhooks/register. |
409 | "already has an ACT virtual account" | The user already has one. Read it via List/Get. |
500 | "Internal server error" | If on GET /v1/users?limit>100 or GET /v1/virtual-accounts?limit>100 — known cap. Use limit<=100 on ALL list endpoints. |
Production readiness checklist (15 items)
Before requesting production credentials, your integration must demonstrably pass the following in sandbox. Full details and an evidence template are in the production-certification matrix your Kira contact provides.Reference links
- Interactive API reference: API reference
- Sandbox health: https://api.balampay.com/sandbox/health
- Production-readiness checklist: the production-certification matrix provided by your Kira contact
- Human-facing integration guide: see the Quickstart and the guides
What’s coming end of June (v2026-XX-XX)
Several gotchas in this document resolve in the next API version. Behavior changes opt-in viaX-Api-Version: 2026-XX-XX:
GET /v1/pricingendpoint with your contracted rates- Unified error shape:
{type, code, message, param, agent_hint}
“magic-trigger” verification emails, magic SSN/EIN tables, an 8-attempt webhook retry policy, and a separate
POST /v1/documents upload endpoint have been floated in BDD specs but are NOT shipping — do not design against them. Today: verify via your Kira contact, documents go inline as base64/HTTPS in identifying_information[].documents[], and webhooks are single-delivery with no retry.2026-04-14 until you’ve explicitly tested the new version.
Last updated: 2026-05-26 · API version covered: 2026-04-14 · For questions: contact your Kira integration partner via the channel they shared.
Why automatic rejection happens (transparency)
Why automatic rejection happens (transparency)
Kira routes identity verification to a specialist KYC provider whose automated flow includes a compliance questionnaire filled from the fields you send on
POST /v1/users (source_of_funds, account_purpose, employment_status, occupation, expected_monthly_volume, …). If a required answer is missing or can’t be matched to the provider’s accepted options, the provider rejects automatically — that is what user.verification.failed means; the provider’s exact reasons are forwarded verbatim in data.reasons[]. It is recoverable: fix the data with PUT /v1/users/{id} (most often: add source_of_funds), then ask your Kira contact to re-run verification — no need to create a new user.