Skip to main content
This page is a dense, fact-first brief for AI coding assistants (Claude, Cursor, Copilot, Cody, etc.) helping a developer integrate Kira’s API. It is optimized for fast, correct integration — not human reading.
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 send X-Api-Version: 2026-04-14 header until the account is pinned)
  • Reference data: GET /v1/countries returns {count:250, data:[...]}. (/api/countries and /countries return 403 — wrong path.)
  • Credentials needed from Kira: api_key, client_id (UUID), password. Provided by your Kira contact through a secured channel.

Auth flow

Call POST /auth with only your x-api-key:
curl -X POST "https://api.balampay.com/sandbox/auth" \
  -H "Content-Type: application/json" \
  -H "x-api-key: $KIRA_API_KEY" \
  -d '{"client_id":"'"$KIRA_CLIENT_ID"'","password":"'"$KIRA_PASSWORD"'"}'
Returns:
{"message":"Auth token","data":{"access_token":"eyJ…","expires_in":3600,"token_type":"Bearer"}}
Cache the token, refresh when it’s less than 5 min from expiry. Re-auth on any 401 with retry.
See Authentication for the full flow.

One-time setup: pin the account to v2026-04-14

curl -X POST "https://api.balampay.com/sandbox/v1/versioning/upgrade" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-api-key: $KIRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"target_version":"2026-04-14"}'
The body field is target_version, not version. After this, the X-Api-Version header becomes optional. See Versioning.

Required headers — every mutating request

Update users with PUT /v1/users/{id} (→ 200), NOT PATCH (→ 403, not a route). The PATCH 403 body is a misleading gateway-level auth error ("Invalid key=value pair … in Authorization header") — it means “PATCH is not supported, use PUT”; it does NOT mean your Authorization header is wrong.
HeaderValueRequired?
AuthorizationBearer <access_token>Always
x-api-keyThe api_key from your credsAlways
X-Api-Version2026-04-14Optional after pin; safe to always send
Idempotency-KeyUUID v4, new per logical requestRequired on 6 endpoints (see below)
Content-Typeapplication/jsonOn all POST/PUT
Endpoints requiring Idempotency-Key:
  • POST /v1/users
  • POST /v1/users/{id}/verifications
  • POST /v1/recipients
  • POST /v1/virtual-accounts
  • POST /v1/virtual-accounts/{id}/payout
  • POST /v1/virtual-accounts/{id}/liquidation-address
Rule: generate a NEW UUID v4 for every distinct logical request. Reuse the SAME key only for retries of the same logical request.

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)

POST /v1/users  → status: CREATED → VERIFYING → REVIEW → VERIFIED
                                                  (terminal: REJECTED)
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.
Fill scalar gaps with 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.
status and verification_status can contradict each other. Gate your state machine on status only; treat verification_status as advisory. status lifecycle: CREATED → VERIFYING → REVIEW → VERIFIED (terminal REJECTED). You may also see ACTIVE in some tenants — treat unknown values as non-terminal.

Virtual Account

POST /v1/virtual-accounts → status: "approved"   ← API returns this at 2026-04-14
state machine: rfi (pending) → approved (activating→active) → deactivated; declined on failure
The API collapses activating/active to 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

POST /v1/virtual-accounts/{id}/payout/preview → 200 with fees breakdown
POST /v1/virtual-accounts/{id}/payout          → 201, status: "created"
                                              → "in_review" (sometimes)
                                              → "processing"
                                              → "completed" | "failed"
Casing: the payout 201 create response returns status: "created" lowercase, but GET /v1/payouts/{id} returns CREATED UPPERCASE for the same payout — always compare statuses case-insensitively.
Preview requires a funded VA. With $0 balance you get 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)

{
  "type": "individual",
  "verification_mode": "automatic",
  "first_name": "Maria",
  "last_name": "Gonzalez",
  "birth_date": "1990-05-15",
  "email": "maria@example.com",
  "phone": "+525512345678",
  "nationality": "MEX",
  "address_street": "Av. Reforma 123",
  "address_city": "Ciudad de Mexico",
  "address_state": "CDMX",
  "address_zip_code": "06600",
  "address_country": "MEX",
  "document_type": "passport",
  "document_number": "G12345678",
  "document_country": "MEX",
  "immigration_status": "Non-Resident of U.S.",
  "employment_status": "employed",
  "current_employer": "Self",
  "identifying_information": [
    {"type":"passport","issuing_country":"MEX","number":"G12345678","expiration":"2030-05-15"}
  ],
  "additional_info": {"has_us_bank_account":"no","has_denied_bank_account":"no"},
  "account_purpose": "receive_payments"
}
Gotchas:
  • verification_mode must 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. back waived for passports. Do NOT send ssn for non-US individuals, or ein for non-US businesses (use international_entity_type)
  • additional_info boolean fields are strings ("yes" / "no"), not booleans; omitting them silently declares "no"
  • nationality and address_country use alpha-3 ISO codes (MEX, USA)
  • account_purpose accepted enum (sending any other value → 400 invalid_enum_value listing these):
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_freelancing

Create business user — USA (with EIN)

{
  "type": "business",
  "verification_mode": "automatic",
  "business_legal_name": "Acme Trading LLC",
  "doing_business_as": "Acme",
  "business_type": "llc",
  "business_industry": ["merchant_wholesalers_nondurable_goods"],
  "formation_date": "2020-01-15",
  "formation_country": "USA",
  "email": "ops@acme.com",
  "phone": "+14155559999",
  "address_street": "1 Market Street",
  "address_city": "San Francisco",
  "address_state": "CA",
  "address_zip_code": "94105",
  "address_country": "USA",
  "ein": "12-3456789",
  "associated_persons": [
    {
      "first_name": "Alice",
      "last_name": "Smith",
      "birth_date": "1980-05-15",
      "email": "alice@acme.com",
      "nationality": "USA",
      "document_number": "D11111111",
      "ssn": "222-22-2222",
      "role": "control_prong"
    }
  ]
}
Gotchas:
  • business_industry is an array of NAICS-style enums, NOT a string. Valid example: "merchant_wholesalers_nondurable_goods". Invalid: "wholesale_trade", "trading".
  • business_type enum: llc / corporation / partnership / sole_proprietorship / trust
  • associated_persons requires at least one entry with role: "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):
{
  "user_id": "<verified_user_id>",
  "type": "US_BANK",
  "provider": "act",
  "mode": "fiat"
}
2. Crypto stablecoin via the SWIFT / international-wire rail (on-chain sweep address) — bank: "slovak_savings_bank" in sandbox, bank: "portage" in prod:
{
  "user_id": "<verified_user_id>",
  "type": "US_BANK",
  "mode": "crypto",
  "bank": "slovak_savings_bank",
  "destination": {
    "currency": "USDC",
    "network": "solana",
    "address": "<your wallet address>"
  }
}
Supported (destination.currency, destination.network) pairs today: (USDC, solana), (USDC, polygon), (USDT, tron), (USDT, solana), (USDT, polygon). Gotchas:
  • type must be "US_BANK" — it is the only supported type today (even crypto VAs are "US_BANK"). MX_SPEI and any other values may appear in the enum but are not wired up — do not use.
  • Only three currencies are supported on US_BANK today: USD (fiat) and USDT / USDC (crypto). No other fiat currencies (EUR, GBP, MXN, etc.) and no other crypto assets (BTC, ETH, etc.) are accepted.
  • bank is REQUIRED for US_BANK — including crypto. Omitting it returns 400 "bank is required for US_BANK virtual accounts". The value is environment-scoped: sandbox uses slovak_savings_bank (SWIFT/crypto) or austin_capital_trust (ACT); production uses portage (SWIFT/crypto) or austin_capital_trust (ACT — domestic). Sending portage in sandbox returns 400 "Invalid bank" (authorization gate, not geography).
  • provider enum is act | zenus ONLY. There is no provider: "portage" / provider: "slovak_savings_bank" — sending either returns 400 "Expected 'act' | 'zenus'". Rail selection for everything else is the bank field; the service maps bank → provider (austin_capital_trust → act, portage / slovak_savings_bank → SWIFT/crypto rail, zenus → zenus). (provider: "act" aliases bank: "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) with mode: "fiat". In sandbox the SWIFT-capable VA still shows payment_methods: WIRE+ACH (SWIFT inbound not provisioned) — SWIFT is an outbound-payout capability of the rail, not a visible VA inbound method.
  • mode is lowercase "fiat" or "crypto". Uppercase fails.
  • For crypto VAs, destination.address is 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)

{
  "user_id": "<verified_user_id>",
  "first_name": "Maria",
  "last_name": "Gonzalez",
  "email": "maria@example.com",
  "phone": "+5215512345678",
  "address": {
    "street_name": "Av. Insurgentes 100",
    "city": "Mexico City",
    "state": "CDMX",
    "postal_code": "06600",
    "country": "MX"
  },
  "account": {
    "account_type": "SWIFT",
    "bank_name": "Banco de Mexico",
    "account_number": "012345678901234567",
    "swift_code": "BNMXMXMM",
    "bank_address": {
      "street_name": "Av. Reforma 1",
      "city": "Mexico City",
      "state": "CDMX",
      "postal_code": "06600",
      "country": "MX"
    }
  }
}
Gotchas (different from User-create!):
  • Country codes are alpha-2 here (MX, US) — different from User which uses alpha-3 (MEX, USA)
  • Field is street_name, not street
  • The bank’s address goes in account.bank_address, NOT account.address
  • Required at top level: first_name, last_name (or company_name for type: business), and email (REQUIRED for WIRE / ACH / SWIFT recipients; SWIFT additionally requires phone)
  • account.bank_address is REQUIRED for WIRE / SWIFT as a STRUCTURED OBJECT ({street_name, city, state, postal_code, country}, country 2-letter) — omitting it → 400 "account.bank_address: Required"
  • account.account_type is a discriminator: SWIFT / WIRE / ACH / SPEI / WALLET / USD / etc.

Preview payout

{
  "amount": "100.00",
  "currency": "USD",
  "recipient_id": "<recipient_id>"
}
Gotchas:
  • recipient_id at top level, NOT nested under destination
  • Crypto VAs use payment_instructions instead of recipient_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

curl -X POST "https://api.balampay.com/sandbox/webhooks/register" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "x-api-key: $KIRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "client_uuid": "<your client_id>",
    "webhook_url": "https://your-domain.com/kira-webhooks"
  }'
Gotchas:
  • Path is /webhooks/register — NO /v1/ prefix. Different stack.
  • Body field is client_uuid, NOT client_id (only place in the API where this naming is used)
  • Response: {"message":"Webhook registered successfully"} — no id, no echo. There is no GET / PATCH / DELETE for webhooks today.
  • No retry on failed deliveries today. Your endpoint must be highly available.
See Webhooks for the full webhook integration guide.

Anti-patterns to avoid (do NOT do these)

  1. Do not assume the sandbox can’t reject your user. It does not auto-VERIFY, but verification runs automatically on create 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) — user.verification.failed fires and the user flips to terminal REJECTED (while 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. Create a COMPLETE user, then ask your Kira contact to flip it to VERIFIED (or to re-trigger verification after a REJECTED).
  2. Do not assume status: VERIFIED means the user can actually transact — also check that the VA’s GET /balance returns 200, not 400 “activating”.
  3. Do not reuse the same Idempotency-Key across logically-different requests. Generate a fresh UUID v4 per intent. Only reuse on retries of the same intent.
  4. Do not send additional_info: { "has_us_bank_account": false } — booleans get rejected. Send "no" (string) instead.
  5. Do not send provider: "portage" or provider: "slovak_savings_bank" in Create VA — the provider enum is only act | zenus. Returns 400 "Expected 'act' | 'zenus'". Select the Portage rail via the bank field instead.
  6. Do not send mode: "FIAT" — lowercase only.
  7. Do not nest recipient_id under destination in payout requests — top-level.
  8. Do not use account.address for the bank’s address on a recipient — it’s account.bank_address. (account.address would be the recipient’s address — that’s a top-level address field, not nested in account.)
  9. Do not assume provider and currency are present in list views — they come back null. Fetch by ID if you need them.
  10. Do not write a single error parser that assumes one error shape — Kira’s API has multiple error shapes today ({error, details} vs {message} vs {code, error, message} vs {statusCode, error, message}). Code defensively.

Webhook signature verification

Every webhook delivery includes an HMAC-SHA256 signature in the headers. Verify it before processing:
import hmac, hashlib

def verify_webhook(payload_bytes: bytes, header_signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header_signature)
The signature arrives in the 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_idevent_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.created
  • user.updated / user.status_changed / user.verification.failed (automatic verification failed → user flips to terminal REJECTED; reason in data.reasons[]) / user.verification.accepted (verification approved) / user.document.download.failed
  • virtual_account.created
  • virtual_account.activated — the only funds-ready VA webhook; you must handle it
  • virtual_account.deposit_funds_received / virtual_account.deposit_funds_in_destination (not virtual_account.deposit.completed)
  • payout.created / payout.pending / payout.processing / payout.completed / payout.failed / payout.returned
  • payin.*, card_payment.*
See State machines & webhook catalog for the full catalog, the 2 envelope shapes (standard flat, and 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

HTTPBody excerptWhat 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.
See Known limitations & quirks for the full set of edge cases.

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

Acquire token

Call POST /auth and cache the token.
2

Pin version

Pin the account to 2026-04-14.
3

Idempotency-Key correctly applied

Fresh UUID v4 per logical request.
4

Create individual user

POST /v1/users with type: individual.
5

Create business user

POST /v1/users with type: business.
6

Read a VERIFIED user

One we manually verified for you.
7

Create a VA

POST /v1/virtual-accounts.
8

Simulate inbound deposit

Confirm via the 201 + webhook.
9

Create a recipient

POST /v1/recipients.
10

Preview a payout

POST /v1/virtual-accounts/{id}/payout/preview.
11

Execute a payout

POST /v1/virtual-accounts/{id}/payout.
12

Register webhook URL

POST /webhooks/register.
13

Verify HMAC signature on incoming events

Constant-time compare on the raw body.
14

De-dup retried events

Key on data.event_id.
15

Handle one error response cleanly

Defensive parsing across error shapes.

What’s coming end of June (v2026-XX-XX)

Several gotchas in this document resolve in the next API version. Behavior changes opt-in via X-Api-Version: 2026-XX-XX:
  • GET /v1/pricing endpoint 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.
When v2026-XX-XX ships, this brief will be updated. Pin to 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.
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.