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 thex-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.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
- De-duplicate on
data.event_id. This is live-verified:event_idsits atdata.event_idin both envelope shapes. There is no root-levelevent_id. - Make processing idempotent. Because deliveries are not retried and you must dedupe, design your handler so that re-processing the same
data.event_idhas 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 theevent 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 the2026-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.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
KYT_PENDING/IN_REVIEWsurface only viapayout.status_changed.- A provider cancellation (ACT rail) arrives as a
payout.failedevent carryingdata.status: "cancelled"; the payout resource resolves toFAILED. - 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:Example event payloads
The following are example event payloads, illustrating each envelope shape.user.created
user.created
user.liveness_completed
user.liveness_completed
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.virtual_account.deposit_funds_received
virtual_account.deposit_funds_received
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.payout.created
payout.created
payout.pending / payout.processing
payout.pending / payout.processing
payout.status_changed (terminal, V2 shape)
payout.status_changed (terminal, V2 shape)
payout.returned / payout.failed (terminal, flat shape)
payout.returned / payout.failed (terminal, flat shape)
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.