This is the authoritative reference for Kira’s three state machines (virtual account, deposit, payout) and the full webhook catalog: every event name, the webhook envelope shapes, and HMAC signing. Use it to map status values to behavior and to write resilient webhook handlers.
For the practical guide to registering an endpoint and verifying signatures, see Webhooks.
All values here are code- and live-sandbox-verified. The vocabulary below assumes you pin X-Api-Version: 2026-04-14; the raw-enum default is called out where it differs. See Versioning for how to pin.
Virtual account state machine
The virtual account status vocabulary is version-dependent — it is set by your pinned X-Api-Version.
On the 2026-04-14 pin (the version this doc tells you to pin to): rfi → approved → declined, plus deactivated.
rfi (awaiting KYC) ──▶ approved (activating ──▶ active) ──▶ deactivated
│
└─(on provisioning failure)──▶ declined
Wire value (status) | Meaning |
|---|
rfi | Awaiting KYC — more info required |
approved | Provisioned — covers BOTH activating AND active (does NOT by itself mean funds-ready) |
declined | Provisioning failed (terminal) |
deactivated | VA closed / disabled (terminal) |
On the default / newer API versions the raw enum is returned instead: pending → activating → active, plus failed, deactivated. Pin to 2026-04-14 to get the rfi/approved/declined vocabulary this doc uses.
Underlying transition path (same machine, two vocabularies): pending/rfi → activating → active/approved → deactivated; any provisioning step → failed/declined (terminal).
approved ≠ funds-ready. Because approved collapses activating and active, the API returns status: "approved" before funds can move. The pending / activating / active ladder is the raw-enum default, not what the 2026-04-14 pin returns.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) — or by handling the virtual_account.activated webhook (the only “funds-ready” event). On an active ACT VA, GET /v1/virtual-accounts/{id}/balance returns 200 with available_balance (while activating it returns 400).
Webhooks vs poll-only
virtual_account.created fires when the VA is provisioned (may arrive while still activating).
virtual_account.activated fires when it reaches active — the ONLY funds-ready webhook; clients must handle it.
failed / declined and deactivated are poll-only — there is no virtual_account.failed and no virtual_account.deactivated event (and no client-facing deactivate/delete endpoint). Observe them via GET /v1/virtual-accounts/{id}.
Deposit state machine
The coarse inbound deposit status is UPPERCASE. It applies to GET /v1/virtual-accounts/{id}/deposits[/{depositId}].
PENDING ──▶ COMPLETED ──▶ REFUNDED (bank claw-back / return AFTER credit)
│
├──▶ FAILED (terminal)
└──▶ REFUNDED (returned to sender, terminal)
Wire value (status) | Meaning |
|---|
PENDING | Inbound funds detected, not yet credited |
COMPLETED | Credited — not permanently final: a bank claw-back / return can move it to REFUNDED |
FAILED | Inbound / settlement failed (terminal) |
REFUNDED | Returned to sender (terminal) |
There is no separate RETURNED deposit status — a return and a refund both collapse to REFUNDED.
Casing: the GET resource status is UPPERCASE (COMPLETED), while flat deposit webhook events carry lowercase data.status (completed) — but casing is inconsistent across surfaces generally (user.* events carry UPPERCASE data.status), so ALWAYS compare case-insensitively.
Refund/return detection (rail-dependent): a returned incoming deposit ALWAYS → status: REFUNDED, delivered via ONE of three event names depending on the rail — deposit_funds_received re-sent with data.status: "refunded", OR deposit_returned, OR deposit_funds_refunded. Branch on data.status == "refunded" (or resource status == REFUNDED), NOT on the event name.
Sandbox simulator: POST /v1/virtual-accounts/{id}/simulate-deposit → 201 with status: "completed". Live-verified: the simulated deposit does NOT appear in GET /v1/virtual-accounts/{id}/deposits (the list stays empty) and does NOT change GET /…/balance (the sandbox balance is a fixed provider value) — confirmation = the 201 response itself + the (hand-delivered) virtual_account.deposit_funds_received webhook, NOT the deposits list, NOT a balance delta.The response field settlement_triggered is true only for crypto deposits ≥ $1; for fiat it is false because the fiat deposit is already completed (no separate settlement leg). settlement_triggered: false on a fiat deposit is normal — it is NOT an error.
Payout state machine
The payout status is UPPERCASE on GET (but NOT on every surface). It is UPPERCASE in GET /v1/payouts/{id}, in the list, and inside payout.status_changed’s data.data.status. BUT the 201 create response returns status: "created" lowercase (the same payout then reads CREATED on GET), and the flat payout.* webhook events carry lowercase data.status. Compare case-insensitively.
CREATED ──▶ PENDING ──▶ PROCESSING ──▶ COMPLETED (happy path)
│
holds (resume after): ├──▶ KYT_PENDING (compliance / txn-monitoring)
└──▶ IN_REVIEW (manual review)
terminal: COMPLETED · FAILED · EXPIRED (crypto-only)
Wire value (status) | Meaning |
|---|
CREATED | Payout accepted, not yet queued |
PENDING | Queued |
PROCESSING | Sent to the rail / provider |
COMPLETED | Delivered (terminal, success) |
KYT_PENDING | Held for transaction-monitoring screening (resumes) |
IN_REVIEW | Held for manual review (resumes) |
FAILED | Rejected / returned / cancelled (terminal) |
EXPIRED | Crypto payout never funded by its deadline (terminal, crypto-only) |
- There is no
RETURNED status and no DISPUTED status. A beneficiary-bank RETURN → status FAILED, distinguished by the payout.returned event + error_code: "va-payout-bank-returned". Returned funds come back to your balance (minus any return fee) to re-attempt.
CANCELLED exists in the enum but is never written to a payout today — you won’t receive it.
EXPIRED is crypto-only and is NOT a valid ?status= list filter.
- Crypto payouts add a settlement phase before the outbound leg (you submit the on-chain
txHash, Kira settles, then the fiat/outbound leg runs).
Casing is inconsistent across surfaces — ALWAYS compare statuses case-insensitively. GET resource status is UPPERCASE (payouts, deposits; VAs follow the version vocabulary — lowercase approved on this pin). The payout 201 create response is lowercase "created" while GET 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).
Webhook delivery, signing & dedupe
Register: POST /webhooks/register (no /v1/ prefix — different stack) with body { "client_uuid": "<your client_id>", "webhook_url": "https://..." } → 200. Note the field is client_uuid here, not client_id.
Signature header: every delivery carries x-signature-sha256 = the HMAC-SHA256 of the raw request body (the full validated envelope, exact bytes), hex-encoded, keyed with your webhook secret. Verify against the raw bytes (do not re-serialize the parsed JSON), use a constant-time compare, and verify against the exact secret (case-sensitive).
Delivery semantics: single delivery, NO retry on this pin. Your endpoint must be highly available. De-duplicate on data.event_id — live-verified: event_id sits at data.event_id in BOTH envelope shapes (flat AND payout.status_changed); there is NO root-level event_id on either. Make processing idempotent (deliveries can still repeat across redeploys/replays).
import hmac, hashlib
def verify(raw_body: bytes, header_sig: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header_sig)
For step-by-step registration and verification, see the Webhooks guide.
Webhook event catalog
These are the real event names emitted today (authoritative, grouped, on the 2026-04-14 pin). Older user.verification.passed, virtual_account.deposit.completed, and payout.status_changed-only models are wrong — use the names below.
User
| Event | When |
|---|
user.created | User created |
user.updated | User record changed |
user.status_changed | KYC / user status changed |
user.verification.accepted | Automatic verification approved |
user.document.download.failed | A submitted document URL could not be downloaded |
user.verification.failed | Automatic verification failed — user flips to terminal REJECTED |
Virtual account (lifecycle)
| Event | When |
|---|
virtual_account.created | VA provisioned (may arrive while still activating) |
virtual_account.activated | VA reached active — the ONLY funds-ready VA webhook |
Deposit (virtual_account.deposit_*)
| Event | When |
|---|
virtual_account.deposit_scheduled | Inbound deposit scheduled |
virtual_account.deposit_funds_received | Inbound funds detected (also re-sent with data.status:"refunded" on a return) |
virtual_account.microdeposit_funds_received | Sub-$1.00 verification micro-deposit received |
virtual_account.deposit_in_review | Deposit under review |
virtual_account.deposit_funds_in_transit | Funds in transit |
virtual_account.deposit_funds_in_destination | Credited — terminal; carries tx_hash / net_amount |
virtual_account.deposit_funds_failed | Settlement failed → FAILED |
virtual_account.deposit_returned | Deposit returned → REFUNDED |
virtual_account.deposit_funds_refunded | Deposit refunded → REFUNDED |
Payout
| Event | When | Resulting status |
|---|
payout.created | Payout created | CREATED |
payout.pending | Payout queued | PENDING |
payout.processing | Payout sent to rail | PROCESSING |
payout.completed | Payout delivered | COMPLETED |
payout.failed | Payout failed | FAILED |
payout.returned | Returned by the beneficiary bank | FAILED (+ error_code: "va-payout-bank-returned") |
payout.expired | Crypto payout never funded by deadline | EXPIRED (crypto-only) |
payout.deposit_received | Crypto-funded payout: on-chain funding seen | (no status change) |
payout.status_changed | Generic envelope carrying status + previous_status | the ONLY way KYT_PENDING / IN_REVIEW surface |
NOT emitted (do not document these as events): virtual_account.failed, virtual_account.deactivated, payout.kyt_pending, payout.in_review, and any dispute events. KYT_PENDING and IN_REVIEW surface only via payout.status_changed.
Handler rule: return 2xx + log for any event you don’t recognize. Never return 4xx for an unknown event (there’s no retry, but a 4xx is still the wrong signal). New event types may appear without notice.
Webhook envelope shapes
On the 2026-04-14 pin, Kira emits two active envelope shapes — read the event/type field first, then branch. In both, event_id sits at data.event_id; there is no root-level event_id. (A legacy V1 shape carried event_id at the root, but it is not emitted on this pin — see below.)
1. Standard events — flat { event, data } (event_id at data.event_id)
Most events (user.*, virtual_account.*, and the simple payout.created/pending/processing/... notifications).
{
"event": "virtual_account.deposit_funds_received",
"data": {
"event_id": "491e0d6e-a5e1-4158-a331-db8accc80a57",
"status": "completed",
"amount": "123.45000000",
"currency": "USD",
"virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd"
}
}
2. Payout V2 — payout.status_changed (double-nested data.data; event_id STILL at data.event_id)
payout.status_changed keeps the outer { event, data } keys but DOUBLE-NESTS the payload: data carries event_id + event_type + created_at, and the actual payout fields sit one level deeper at data.data (status UPPERCASE, previous_status, amount, payout_id, recipient{…}, optional review_reason, destination_amount, …) — unwrap defensively. There is NO root-level event_id. This is the envelope that surfaces KYT_PENDING / IN_REVIEW.
{
"event": "payout.status_changed",
"data": {
"event_id": "f6e3c92c-43b5-49e5-8545-de31dc1105c9",
"event_type": "payout.status_changed",
"created_at": "2026-05-23T00:37:56.874Z",
"data": {
"status": "IN_REVIEW",
"previous_status": "PROCESSING",
"amount": "100.00",
"payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
"review_reason": "HTTP 500 - payout provider is not configured",
"destination_amount": "70.00"
}
}
}
Parser strategy: if data.data is present (or data.event_type == "payout.status_changed") → V2: read the status at data.data.status (UPPERCASE) and data.data.previous_status, dedupe on data.event_id; else if event present → standard flat (dedupe on data.event_id). In both event-keyed shapes event_id is ALWAYS at data.event_id.
Legacy V1 (not emitted on the 2026-04-14 pin)
A legacy V1 envelope carried event_id at the root level. On the 2026-04-14 pin, the two shapes above both carry event_id at data.event_id — there is no root-level event_id on either. You only need to handle the two shapes above.