Skip to main content
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
rfiAwaiting KYC — more info required
approvedProvisioned — covers BOTH activating AND active (does NOT by itself mean funds-ready)
declinedProvisioning failed (terminal)
deactivatedVA 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
PENDINGInbound funds detected, not yet credited
COMPLETEDCredited — not permanently final: a bank claw-back / return can move it to REFUNDED
FAILEDInbound / settlement failed (terminal)
REFUNDEDReturned 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 raildeposit_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-deposit201 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
CREATEDPayout accepted, not yet queued
PENDINGQueued
PROCESSINGSent to the rail / provider
COMPLETEDDelivered (terminal, success)
KYT_PENDINGHeld for transaction-monitoring screening (resumes)
IN_REVIEWHeld for manual review (resumes)
FAILEDRejected / returned / cancelled (terminal)
EXPIREDCrypto 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

EventWhen
user.createdUser created
user.updatedUser record changed
user.status_changedKYC / user status changed
user.verification.acceptedAutomatic verification approved
user.document.download.failedA submitted document URL could not be downloaded
user.verification.failedAutomatic verification failed — user flips to terminal REJECTED

Virtual account (lifecycle)

EventWhen
virtual_account.createdVA provisioned (may arrive while still activating)
virtual_account.activatedVA reached active — the ONLY funds-ready VA webhook

Deposit (virtual_account.deposit_*)

EventWhen
virtual_account.deposit_scheduledInbound deposit scheduled
virtual_account.deposit_funds_receivedInbound funds detected (also re-sent with data.status:"refunded" on a return)
virtual_account.microdeposit_funds_receivedSub-$1.00 verification micro-deposit received
virtual_account.deposit_in_reviewDeposit under review
virtual_account.deposit_funds_in_transitFunds in transit
virtual_account.deposit_funds_in_destinationCredited — terminal; carries tx_hash / net_amount
virtual_account.deposit_funds_failedSettlement failed → FAILED
virtual_account.deposit_returnedDeposit returned → REFUNDED
virtual_account.deposit_funds_refundedDeposit refunded → REFUNDED

Payout

EventWhenResulting status
payout.createdPayout createdCREATED
payout.pendingPayout queuedPENDING
payout.processingPayout sent to railPROCESSING
payout.completedPayout deliveredCOMPLETED
payout.failedPayout failedFAILED
payout.returnedReturned by the beneficiary bankFAILED (+ error_code: "va-payout-bank-returned")
payout.expiredCrypto payout never funded by deadlineEXPIRED (crypto-only)
payout.deposit_receivedCrypto-funded payout: on-chain funding seen(no status change)
payout.status_changedGeneric envelope carrying status + previous_statusthe 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.