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

# For AI agents

> A canonical, fact-first integration brief you can paste into an AI coding assistant to build a correct Kira integration.

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.

<Note>
  **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.
</Note>

## Feed this brief to your agent

These docs are auto-published in machine-readable forms — always current, so your agent never works from a stale paste. Prefer fetching one of these over copying the text by hand:

<CardGroup cols={3}>
  <Card title="Whole-API context" icon="layer-group" href="https://docs.kirafin.ai/llms-full.txt">
    `docs.kirafin.ai/llms-full.txt` — every page on this site (this brief, the guides, and the full API reference) concatenated into one file.
  </Card>

  <Card title="Just this brief" icon="robot" href="https://docs.kirafin.ai/guides/for-ai-agents.md">
    `docs.kirafin.ai/guides/for-ai-agents.md` — the raw Markdown of this page. Every page is fetchable at its path plus `.md`.
  </Card>

  <Card title="Index of all pages" icon="list" href="https://docs.kirafin.ai/llms.txt">
    `docs.kirafin.ai/llms.txt` — a link index your agent can crawl to pull only the pages it needs.
  </Card>
</CardGroup>

You can still save this page as `AGENTS.md` in your repo (per the note above) — but pointing your agent at a URL means it always reads the latest facts.

### Connect the docs MCP server

These docs are also served as a hosted **MCP server** at `https://docs.kirafin.ai/mcp`. Connect your agent to it and it can search and read the docs — including the full API reference — on demand, instead of relying on a one-time paste.

<CodeGroup>
  ```bash Claude Code theme={null}
  claude mcp add --transport http kira-docs https://docs.kirafin.ai/mcp
  ```

  ```json Cursor (~/.cursor/mcp.json) theme={null}
  {
    "mcpServers": {
      "kira-docs": { "url": "https://docs.kirafin.ai/mcp" }
    }
  }
  ```

  ```json VS Code (.vscode/mcp.json) theme={null}
  {
    "servers": {
      "kira-docs": { "type": "http", "url": "https://docs.kirafin.ai/mcp" }
    }
  }
  ```
</CodeGroup>

<Tip>
  Every page here carries a contextual menu in its header — use it to copy the page as Markdown, open it in Claude / ChatGPT, one-click install this MCP server in Cursor or VS Code, or download the OpenAPI spec.
</Tip>

## 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`:

```bash theme={null}
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:

```json theme={null}
{"message":"Auth token","data":{"access_token":"eyJ…","expires_in":3600,"token_type":"Bearer"}}
```

<Tip>
  **Cache the token, refresh when it's less than 5 min from expiry.** Re-auth on any `401` with retry.
</Tip>

See [Authentication](/guides/authentication) for the full flow.

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

```bash theme={null}
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](/guides/versioning).

## Required headers — every mutating request

<Warning>
  **Update users with `PUT /v1/users/{id}` (→ 200), NOT `PATCH`.** `PATCH` is not supported on that route. A wrong path/method returns a clean routing error — `403 invalid_request` on a normal authenticated call (`404 route_not_found` if unauthenticated) — **not** an auth or signature failure. See [Known limitations](/guides/known-limitations#routing-errors-wrong-path-or-method).
</Warning>

| 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 4 endpoints (see below)     |
| `Content-Type`    | `application/json`                   | On all POST/PUT                         |

**Endpoints requiring `Idempotency-Key`:**

* `POST /v1/users`
* `POST /v1/recipients`
* `POST /v1/virtual-accounts`
* `POST /v1/virtual-accounts/{id}/payout`

**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, see [State machines](/guides/state-machines); for the webhook catalog, see [Webhooks](/guides/webhooks).

### 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]`).

<Note>
  **Timing:** the `missing_fields` map is computed from your payload and available immediately — on the `201` response and on `GET /v1/users/{id}`. Per-product `eligible` flips once verification completes. Final approval is a **manual** step, not instant — per Kira it can take **up to 24 hours** — and the sandbox can route a complete user to manual review (`status: REVIEW`) at any point in that window, which resolves on Kira's side. Don't build against a "verifies in seconds" assumption.
</Note>

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. For businesses, a beneficial owner is recognized by `has_ownership` (`true`/`false`), not by `role` — set it on every `associated_persons[]` entry (an omitted flag surfaces as `associated_persons:has_ownership` in `missing_fields`; advisory, non-blocking).

**To get a user to `VERIFIED` in sandbox:** create a COMPLETE user (all product fields + document uploads present and well-formed — scalar values may be fake, but document `file` values must be real retrievable bytes: a placeholder or unreachable URL blocks the provider submission and the user stays `CREATED`). Verification triggers automatically, but **approval is manual, not instant** — per Kira it can take **up to 24 hours**; don't build against a "verifies in seconds" assumption. It can land in manual review (`status: REVIEW`, `verification_status: in_review`), which a Kira operator resolves — more likely for businesses (KYB) and low-quality/placeholder document images. Verification only triggers once the user is complete (`verification_triggered: true`); an incomplete user is NOT rejected — it stays `CREATED` with `verification_triggered: false` and the gaps listed in `missing_fields` (fill them with `PUT /v1/users/{id}`). A terminal `REJECTED` means the provider evaluated the 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.

<Warning>
  **`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.
</Warning>

### 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; international / 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`.

<Info>
  **Constraint:** only one ACT VA per user. Re-creating returns `409 Conflict`. Read the existing VA via List/Get.
</Info>

### Payout

```
POST /v1/virtual-accounts/{id}/payout/preview → 200 with fees breakdown
POST /v1/virtual-accounts/{id}/payout          → 201, status: "created"  (response field: id)
                                              → "KYT_PENDING" (compliance screen, non-terminal)
                                              → "in_review" (sometimes)
                                              → "processing"
                                              → "completed" | "failed"
GET  /v1/payouts/{id}                          → status UPPERCASE (CREATED, KYT_PENDING, …); field: payout_id
```

<Warning>
  **Casing *and* field names differ between create and read.** The payout **201 create response** returns `status: "created"` (lowercase) and identifies the payout with an **`id`** field. But `GET /v1/payouts/{id}` and `GET /v1/payouts` return the status **UPPERCASE** (e.g. `CREATED`, `KYT_PENDING`) and name that same field **`payout_id`**, not `id`. Always compare statuses case-insensitively, and read the identifier from `id` on the create response but `payout_id` on reads.
</Warning>

A freshly created payout commonly transitions `created → KYT_PENDING` (a Know-Your-Transaction compliance screen) before `processing`. Treat `KYT_PENDING` as a **non-terminal, in-flight** state — keep polling `GET /v1/payouts/{id}` or wait for webhooks; do not treat it as failure.

Preview requires a funded VA. With a `$0` balance you get `400 "Insufficient balance"` — fund the VA first with a `simulate-deposit`, which on the fiat wire rail **credits** its available balance (a payout then **debits** it), so `deposit → balance → payout` is a real cycle and `GET /…/balance` reflects the credit (possibly after a few seconds). The balance also includes any opening sandbox float, so a payout can also succeed without a prior deposit. Balance behavior is not uniform across every rail, so treat the `virtual_account.deposit_funds_received` webhook as the rail-independent signal — and note the simulated deposit also appears in `GET /…/{id}/deposits`.

## Request body shapes (the things AI agents commonly hallucinate)

### Create individual user — international (ACT route, no documents)

```json theme={null}
{
  "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"`, case-sensitive), not booleans. For non-US-citizen users (like this example) they are **required**: omitting them surfaces in `missing_fields` and leaves the ACT product ineligible. US-citizen applications don't include this block at all
* `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):

<Accordion title="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_freelancing`
</Accordion>

### Create business user — USA (with EIN)

```json theme={null}
{
  "type": "business",
  "verification_mode": "automatic",
  "business_legal_name": "Acme Trading LLC",
  "doing_business_as": "Acme",
  "business_type": "llc",
  "business_industry": ["merchant_wholesalers_nondurable_goods"],
  "business_description": "Wholesale distribution of packaged 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",
  "account_purpose": "receive_payments_for_goods_and_services",
  "source_of_funds": "sales_of_goods_and_services",
  "high_risk_industries": "No",
  "is_nbfi_vasp": "No",
  "business_legal_history": "No",
  "pep_status": false,
  "expected_monthly_volume": "less_than_50000",
  "expected_transaction_count": "less_than_10",
  "identifying_information": [
    {
      "type": "business_formation",
      "issuing_country": "USA",
      "documents": [
        { "type": "file_business_formation", "file": "data:application/pdf;base64,…" },
        { "type": "file_certificate_of_good_standing", "file": "data:application/pdf;base64,…" },
        { "type": "file_portfolio_statement", "file": "data:application/pdf;base64,…" },
        { "type": "file_board_minutes", "file": "data:application/pdf;base64,…" }
      ]
    }
  ],
  "associated_persons": [
    {
      "has_ownership": true,
      "ownership_percentage": 100,
      "first_name": "Alice",
      "last_name": "Smith",
      "birth_date": "1980-05-15",
      "email": "alice@acme.com",
      "nationality": "USA",
      "document_type": "passport",
      "document_number": "US11111111",
      "identifying_information": [
        { "type": "ssn", "issuing_country": "USA", "number": "222-22-2222" },
        {
          "type": "passport",
          "issuing_country": "USA",
          "number": "US11111111",
          "documents": [{ "type": "front", "file": "data:image/jpeg;base64,…" }]
        }
      ]
    }
  ]
}
```

**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_prop` / `trust`
* A beneficial owner is recognized by **`has_ownership: true` + `ownership_percentage`** (5 or more) — NOT by `role`. At least one `associated_persons[]` entry must qualify, or `associated_persons:beneficial_owner` surfaces in `missing_fields` and verification does not trigger (blocking for a new business). USA businesses need an SSN on each associated person.
* `account_purpose` uses the **business** enum here — `receive_payments_for_goods_and_services`, not the individual `receive_payments` (that value 400s on a business).
* All four company-document groups must be covered — `file_business_formation`, `file_certificate_of_good_standing`, `file_portfolio_statement`, `file_board_minutes` (or equivalents; articles/bylaws cover three groups at once). Only the formation doc → the other three surface in `missing_fields`.
* The `Yes`/`No` attestations `high_risk_industries`, `is_nbfi_vasp`, `business_legal_history` are always required for KYB. `Yes` on either of the first two triggers industry EDD (extra documents).

### Create virtual account

Two routes:

**1. USD fiat via ACT** (Austin Capital Trust — US bank account):

```json theme={null}
{
  "user_id": "<verified_user_id>",
  "type": "US_BANK",
  "provider": "act",
  "mode": "fiat"
}
```

**2. Crypto stablecoin via the international-wire rail** (on-chain sweep address) — `bank: "slovak_savings_bank"` in **sandbox**, `bank: "portage"` in **prod**:

```json theme={null}
{
  "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"`). Any other value is **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` (international/crypto) or `austin_capital_trust` (ACT); production uses `portage` (international/crypto) or `austin_capital_trust` (ACT — domestic). Sending `portage` in **sandbox** returns `400 "Invalid bank"` (authorization gate, not geography).
* **The recommended default rail is the international/crypto rail, selected via `bank`** (`slovak_savings_bank` in sandbox / `portage` in production) — not ACT. The `provider` enum is **`act` ONLY** and exists as an alias for that rail (`provider: "act"` aliases `bank: "austin_capital_trust"`). There is **no** `provider: "portage"` / `provider: "slovak_savings_bank"` — sending either returns `400 "Expected 'act'"`. The service maps `bank → provider` (`austin_capital_trust → act`, `portage / slovak_savings_bank → international/crypto rail`).
* `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 — Wire

```json theme={null}
{
  "user_id": "<verified_user_id>",
  "first_name": "Maria",
  "last_name": "Gonzalez",
  "email": "maria@example.com",
  "address": {
    "street_name": "123 Market St",
    "city": "San Francisco",
    "state": "CA",
    "postal_code": "94105",
    "country": "US"
  },
  "account": {
    "account_type": "WIRE",
    "routing_number": "021000021",
    "account_number": "1234567890",
    "bank_name": "Example Bank, N.A.",
    "bank_address": {
      "street_name": "1 Bank Plaza",
      "city": "New York",
      "state": "NY",
      "postal_code": "10001",
      "country": "US"
    }
  }
}
```

**Gotchas (different from User-create!):**

* Country codes are **alpha-2** here (`US`) — different from User which uses alpha-3 (`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`** (recommended, not enforced — the API accepts an email-less create)
* `account.bank_address` is **REQUIRED for WIRE** 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: `ACH` / `WIRE` / `WALLET`

### Preview payout

<Warning>
  **Deprecated as of July 16, 2026.** New integrations should price payouts with `POST /v1/quotations` instead — see [Itemized quotations](/guides/itemized-quotations). This section still applies to existing integrations on `payout/preview`.
</Warning>

```json theme={null}
{
  "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`
* There is a minimum payout amount equal to the total fees (no fee schedule endpoint exists) — Preview returns `400 "Total fees exceed or equal the payout amount"` below it, so iterate up to find it

### Receive webhooks

Webhooks are **configured by the Kira team** — there is no self-serve registration endpoint. When you need event delivery, send Kira the HTTPS URL to deliver to and they'll register it for your account.

**Gotchas:**

* No retry on failed deliveries today. Your endpoint must be highly available.
* De-duplicate by `data.event_id`, and verify the `x-signature-sha256` HMAC over the raw body before processing.

See [Webhooks](/guides/webhooks) for the full webhook integration guide.

## Anti-patterns to avoid (do NOT do these)

<Warning>
  1. **Do not mistake "stuck in `CREATED`" for a queue, and do not assume verification lands in seconds.** Verification triggers on its own for a COMPLETE user, but approval is a **manual** step — per Kira it can take **up to 24 hours** (manual review — `status: REVIEW` — is a real outcome and resolves on Kira's side). If your user sits in `CREATED` with `verification_triggered: false`, it is incomplete: read the `missing_fields` map and fill the gaps with `PUT /v1/users/{id}` — verification then triggers on its own. If it sits in `CREATED` with `verification_triggered: true` and no `missing_fields`, the usual culprit is the documents: placeholder or unreachable `file` values cannot be uploaded to the provider, so the submission never completes — re-send real files. A terminal `REJECTED` means the provider evaluated the data and declined it (while `verification_status` may still read `unverified` — gate on `status`); the reasons are delivered ONLY in the `user.verification.failed` webhook (`data.reasons[]`) — `GET /v1/users/{id}` never exposes them. After a `REJECTED`, fix the data and ask your Kira contact to re-trigger verification.
  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 the capitalised string `"No"` (or `"Yes"`) instead; these values are case-sensitive.
  5. **Do not send `provider: "portage"` or `provider: "slovak_savings_bank"`** in Create VA — the `provider` enum is only `act`. Returns `400 "Expected 'act'"`. Select the recommended international/crypto rail via the **`bank`** field instead (`slovak_savings_bank` sandbox / `portage` production).
  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 look for `provider` or `currency` fields on a virtual account** — there are none. The rail is exposed as `bank`, and a crypto VA's currency is under `destination.currency` — identical in list and get-by-id.
  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.
</Warning>

## Webhook signature verification

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

<CodeGroup>
  ```python Python theme={null}
  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)
  ```

  ```js Node theme={null}
  const crypto = require('crypto');
  function verifyWebhook(rawBody, headerSignature, secret) {
    const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
    const a = Buffer.from(expected);
    const b = Buffer.from(headerSignature);
    // timingSafeEqual throws on length mismatch — check length first
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }
  ```
</CodeGroup>

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_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.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 [Webhooks](/guides/webhooks) for the full catalog, the 2 envelope shapes (standard flat, and `payout.status_changed` V2 with the nested `data.data` payload), and signing details.

<Note>
  Other event types may be delivered. Default to `2xx` + log for unknown events; do not return 4xx (sender will not retry on 4xx).
</Note>

## 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'"`                               | You sent an unsupported `provider`. Use `act`; 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.                                                                                                                                                            |
| `400` | `"Insufficient balance"`                         | VA balance less than requested payout. Simulate a deposit first.                                                                                                                                    |
| `400` | `"Virtual account must be in ACTIVE status"`     | VA is still `activating` (surfaces as `approved` on the `2026-04-14` pin). Wait for `account_number` to be issued (or the `virtual_account.activated` webhook), then retry — or use a different VA. |
| `401` | `"Unauthorized"`                                 | Token expired or wrong `api_key`. Re-auth.                                                                                                                                                          |
| `409` | `"already has an ACT virtual account"`           | The user already has one. Read it via List/Get.                                                                                                                                                     |
| `400` | `"Invalid query parameters"`                     | `limit` above `100` (or another invalid filter value) on a list endpoint. Use `limit<=100` on ALL list endpoints.                                                                                   |

See [Known limitations & quirks](/guides/known-limitations) 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.

<Steps>
  <Step title="Acquire token">Call `POST /auth` and cache the token.</Step>
  <Step title="Pin version">Send `X-Api-Version: 2026-04-14` on every request (optionally pin the account once if its default allows).</Step>
  <Step title="Idempotency-Key correctly applied">Fresh UUID v4 per logical request.</Step>
  <Step title="Create individual user">`POST /v1/users` with `type: individual`.</Step>
  <Step title="Create business user">`POST /v1/users` with `type: business`.</Step>
  <Step title="Read a VERIFIED user">Create a complete user; approval is manual and per Kira can take up to 24 hours (it commonly lands in `REVIEW` for a Kira operator first) — poll or wait for the webhook, then read it back once `status` is `VERIFIED`.</Step>
  <Step title="Create a VA">`POST /v1/virtual-accounts`.</Step>
  <Step title="Simulate inbound deposit">`POST /v1/virtual-accounts/{id}/simulate-deposit` to fund the VA — confirm via the `201`, the balance increase on `GET /…/balance`, and the `virtual_account.deposit_funds_received` webhook.</Step>
  <Step title="Create a recipient">`POST /v1/recipients`.</Step>
  <Step title="Preview a payout">`POST /v1/virtual-accounts/{id}/payout/preview`.</Step>
  <Step title="Execute a payout">`POST /v1/virtual-accounts/{id}/payout`.</Step>
  <Step title="Receive a webhook delivery">Ask Kira to register your HTTPS URL, then confirm a signed event arrives.</Step>
  <Step title="Verify HMAC signature on incoming events">Constant-time compare on the raw body.</Step>
  <Step title="De-dup retried events">Key on `data.event_id`.</Step>
  <Step title="Handle one error response cleanly">Defensive parsing across error shapes.</Step>
</Steps>

## Reference links

* **Interactive API reference:** [API reference](/api-reference)
* **Production-readiness checklist:** the production-certification matrix provided by your Kira contact
* **Human-facing integration guide:** see the [Quickstart](/quickstart) and the [guides](/guides/authentication)

## 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}`

<Note>
  "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.
</Note>

When v2026-XX-XX ships, this brief will be updated. Pin to `2026-04-14` until you've explicitly tested the new version.

<Note>
  **Last updated:** 2026-07-02 · **API version covered:** 2026-04-14 · **For questions:** contact your Kira integration partner via the channel they shared.
</Note>

<Accordion title="Why a 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`, …). Missing or unmappable questionnaire answers are now caught **before** submission — they appear in `missing_fields` and hold the user in `CREATED` rather than causing a rejection. A `user.verification.failed` therefore means the provider **evaluated the submitted data and declined it**; the provider's exact reasons are forwarded verbatim in `data.reasons[]`. It is **recoverable**: fix the data with `PUT /v1/users/{id}`, then ask your Kira contact to re-run verification — no need to create a new user.

  A verification-**provider call failure** is different: if Kira cannot reach the provider (network / 5xx / timeout), that is a retryable system error, **not** a rejection — the user stays non-terminal (`VERIFYING`), no `user.verification.failed` fires, and Kira retries automatically (persistent failures are escalated to Kira's team, who re-run the verification). Watch `user.status_changed` for the eventual outcome rather than treating the wait as a failure.
</Accordion>
