Skip to main content
Receive signed event notifications when activity happens in your account. Webhooks let you react to user verification, virtual account, deposit, and payout lifecycle changes without polling.

Setting up webhooks

Webhook delivery is configured for you by the Kira team — there is no self-serve registration endpoint. When you’re ready to receive events, contact Kira at support@kirafin.ai with the HTTPS URL where deliveries should be sent, and we’ll register it for your account. You get one URL per account. Every event type — onboarding, virtual account, deposit, and payout — is delivered to that single URL; there’s no filtering by user, product, or event type. If your onboarding and transaction systems run on different hosts, register one of them and route internally on your side. To confirm or change the URL we have on file, email support@kirafin.ai — there’s no API to look it up or update it yourself.
Your webhook signing secret is provisioned by the Kira team at the same time. If you manage your own secret material, tell Kira which value to use. Signature verification (below) is only meaningful once the secret is in place.

Verify the signature

Every delivery carries the x-signature-sha256 header. Its value is the hex HMAC-SHA256 over the raw request body, keyed with your webhook secret.
Signature verification is only meaningful once your signing secret is set (provisioned by the Kira team during setup), so confirm yours is in place before you rely on x-signature-sha256.
To verify a delivery:
1

Read the raw request body

Verify against the raw bytes exactly as received. Do not re-serialize the JSON before hashing — re-serialization changes whitespace and key order, which breaks the signature.
2

Compute the HMAC

Compute HMAC-SHA256(raw_body, webhook_secret) and hex-encode it. Use the exact, case-sensitive secret.
3

Compare in constant time

Compare your computed hex digest against the x-signature-sha256 header value using a constant-time comparison to avoid timing attacks.

Delivery semantics

Single delivery, NO retry on this pin — your endpoint must be highly available. A missed delivery is not re-sent. We also abort after 30 seconds if your endpoint hasn’t responded — treat that the same as a missed delivery. Return 2xx immediately and handle any processing afterward, asynchronously.
  • De-duplicate on data.event_id. This is live-verified: event_id sits at data.event_id in both envelope shapes. There is no root-level event_id.
  • Make processing idempotent. Because deliveries are not retried and you must dedupe, design your handler so that re-processing the same data.event_id has no additional effect.
  • Return 2xx (and log) for unknown event types. New event types may appear; acknowledge them so they are not treated as failures.

Envelope shapes

Kira emits two active envelope shapes — read the event field first, then branch. In both, event_id sits at data.event_id; there is no root-level event_id.

Standard flat shape

Most events (user.*, virtual_account.*, and the simple payout.created / pending / processing / … notifications) use a flat { event, data } envelope:

payout.status_changed V2 shape

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. event_id is still at data.event_id. This is the envelope that surfaces KYT_PENDING / IN_REVIEW.
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. Otherwise → standard flat. In both shapes, dedupe on data.event_id.
Legacy V1 (not emitted on the 2026-04-14 pin). A legacy V1 envelope carried event_id at the root level; it is not emitted on this pin, so you only need to handle the two shapes above. For the resource state machines that drive these events, see State machines.

Event catalog

These are the real event names emitted today 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.) Each status’s lifecycle is defined in State machines.

User

A verification-provider call failure (Kira could not reach its KYC/KYB provider) is a retryable system error, not a rejection: the sub-client stays non-terminal (VERIFYING) and Kira retries — it never fires user.verification.failed. If the failure persists past the automatic retries, Kira’s team is alerted and re-runs the verification; that verification is eventually closed out internally, and doing so never rejects the sub-client or fires user.verification.failed. Only a provider result of declined (or, rarely, the expiry of a session the provider received) produces user.verification.failed + terminal REJECTED. Key your onboarding UI off user.status_changed (or the status field), so a transient provider blip never looks like a rejection.
Example user.status_changed payload:

Virtual account (lifecycle)

Deposit (virtual_account.deposit_*)

The deposit payload’s source.* fields are rail-dependent. payment_rail and sender_name are common; wire adds imad, omad, wire_message; ACH instead carries sender_account_number, reference_number, trace_number, sec_code, memo. Treat each source.* field as optional.

Payout

The Resulting status column is the payout resource status on GET /v1/payouts/{id} (UPPERCASE). The event’s own data.status can differ — payout.returned carries data.status: "returned", and an ACT-rail cancellation arrives as payout.failed with data.status: "cancelled". See Status and casing notes below.
Only two events echo custom metadata. Two resource events echo the resource’s custom metadata — the string→string pairs you attached at create time: user.created and virtual_account.created. A user’s metadata can also include client-level default keys Kira has configured for your account — defaults are merged in at create time (your request keys win on conflict), so you may see keys you did not send. The value reflects the persisted state at emit time and follows the standard metadata constraints (up to 50 entries, keys 1–40 characters, values up to 500 characters, no [ or ] in keys); a resource with no metadata sends an empty object {} rather than omitting the field. Other events — user.updated, user.status_changed, user.verification.accepted/.failed, user.liveness_completed, the virtual_account.deposit_funds_received/microdeposit_funds_received deposit events, and the terminal payout.completed/payout.failed/payout.returned events — do not currently include a metadata field.
Payouts use two overlapping event families. A single transition can produce both a granular payout.* event (e.g. payout.pending / payout.processing) and a payout.status_changed carrying the same status — with different payload shapes. And terminal success arrives as either payout.completed or payout.status_changed with data.data.status: "COMPLETED", depending on the rail/provider. Drive your reconciliation off the status value (compared case-insensitively across the flat data.status and the V2 data.data.status) and treat both families as authoritative — don’t wait for one specific event name.

Status and casing notes

A beneficiary-bank return arrives as the payout.returned event carrying data.status: "returned" (lowercase, flat shape). The payout resource then resolves to a terminal failed state — GET /v1/payouts/{id} returns FAILED and carries error_code: "va-payout-bank-returned" (the error_code is on the resource, not in the webhook payload). Returned funds come back to your balance, minus any return fee. Identify a return by the event name payout.returned (or data.status == "returned"), not by a resource status of RETURNED — there is none.
A returned/refunded deposit resolves to status REFUNDED — there is no RETURNED deposit status either. Branch on data.status == "refunded", not the event name.
  • KYT_PENDING / IN_REVIEW surface only via payout.status_changed.
  • A provider cancellation (ACT rail) arrives as a payout.failed event carrying data.status: "cancelled"; the payout resource resolves to FAILED.
  • Not emitted: virtual_account.failed, virtual_account.deactivated, payout.kyt_pending, payout.in_review, and any dispute events.
Compliance holds resolve on the resource. A deposit or payout held for transaction-monitoring (KYT) or manual review surfaces the hold itself — for payouts, as payout.status_changed with data.data.status of KYT_PENDING or IN_REVIEW. The resolution of that review (cleared or rejected) is reflected on the resource and is not guaranteed to arrive as a separate terminal webhook. For any held transaction, confirm the final state with GET /v1/payouts/{id} or GET /v1/virtual-accounts/{id}/deposits/{depositId} rather than waiting on a terminal event.

Casing

Status casing differs by event family — always compare statuses case-insensitively:
The payout 201 create response is lowercase "created" while GET returns CREATEDalways compare statuses case-insensitively.

Example event payloads

The following are example event payloads, illustrating each envelope shape.
Shown for an individual, hence person_reference_id: null. For a business, this instead carries the beneficial owner’s provider reference id — the same value returned in that UBO’s entry from POST /v1/users/{user_id}/liveness-link — so you can route the result to the right person.
The source.* fields depend on the payment rail. payment_rail and sender_name are common; wire adds imad, omad, and wire_message (shown above), while ACH instead carries sender_account_number, reference_number, trace_number, sec_code, and memo. Read defensively — treat any individual source.* field as optional.
A bank return or failure uses the flat envelope. The event’s own data.status is lowercase (returned, failed, or cancelled); the payout resource resolves to FAILED and carries the error_code (GET /v1/payouts/{id}) — the error_code is not in the webhook payload.
Terminal flat payout events carry the payment’s tracking reference. Terminal flat payout events for virtual-account fiat payouts (payout.completed, payout.failed, payout.returned) carry reference_number and a recipient object alongside the standard fields. Some wire payouts additionally carry uetr, imad, and omad — treat all three as optional and absent by default.Read reference_number: it is the reference the sending bank assigned, in whatever form the rail uses — an IMAD for Fedwire, a trace number for ACH, or a UETR on rails whose bank reports one — and it is the same value GET /v1/payouts/{payout_id} reports, including on a payout that completed and was later returned. It stays null until the payment has actually been sent. uetr, where present, carries the wire value only and is deprecated; migrate to reference_number.As elsewhere, data.status on these flat events is lowercase (completed / failed / returned) even though the same payout reads UPPERCASE on GET — compare case-insensitively.