Skip to main content
Register a callback URL and receive signed event notifications. Webhooks let you react to user verification, virtual account, deposit, and payout lifecycle changes without polling.

Register a webhook

Register your endpoint with a single call. The webhooks API lives on a different stack, so it uses no /v1/ prefix:
curl https://api.balampay.com/sandbox/webhooks/register \
  -H "x-api-key: $KIRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "client_uuid": "<your client_id>",
    "webhook_url": "https://example.com/kira/webhooks"
  }'
The request body has two fields:
FieldDescription
client_uuidYour client_id. This is the only place the API uses the name client_uuid.
webhook_urlThe HTTPS URL that receives event deliveries.
A successful registration returns 200 with { "message": "Webhook registered successfully" } — no id is returned.
No id is returned, and there is no GET / PATCH / DELETE for webhooks today — registration is the only webhook management operation currently exposed.

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. 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.
import crypto from "node:crypto";

function verifyWebhook(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody) // raw bytes — do NOT re-serialize
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Delivery semantics

Single delivery, NO retry on this pin — your endpoint must be highly available. A missed delivery is not re-sent.
  • 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

Two envelope shapes exist. In both, event_id lives at data.event_id.

Standard flat shape

Most events use a flat { event, data } envelope, with event_id at data.event_id:
{
  "event": "<event.name>",
  "data": {
    "event_id": "...",
    "...": "..."
  }
}

payout.status_changed V2 shape

The payout.status_changed event uses a V2 envelope that double-nests its payload at data.data:
{
  "event": "payout.status_changed",
  "data": {
    "event_id": "...",
    "event_type": "payout.status_changed",
    "created_at": "...",
    "data": {
      "status": "<UPPERCASE>",
      "previous_status": "<UPPERCASE>",
      "...": "..."
    }
  }
}
The payload is at data.data, the status is UPPERCASE and includes previous_status — but event_id is still at data.event_id.
For the full event catalog with per-event envelope details and the complete state machines, see State machines & webhook catalog.

Event catalog

These are the events emitted today.
  • user.created
  • user.updated
  • user.status_changed
  • user.verification.failed
  • user.verification.accepted
  • user.document.download.failed
Automatic verification failure flips the user to the terminal REJECTED status.
  • virtual_account.created
  • virtual_account.activated — the only funds-ready VA event
  • virtual_account.deposit_scheduled
  • virtual_account.deposit_funds_received
  • virtual_account.microdeposit_funds_received
  • virtual_account.deposit_in_review
  • virtual_account.deposit_funds_in_transit
  • virtual_account.deposit_funds_in_destination
  • virtual_account.deposit_funds_failed
  • virtual_account.deposit_returned
  • virtual_account.deposit_funds_refunded
  • payout.created
  • payout.pending
  • payout.processing
  • payout.completed
  • payout.failed
  • payout.returned
  • payout.expired
  • payout.deposit_received
  • payout.status_changed

Status and casing notes

A returned payout surfaces as payout.returned and resolves to status FAILEDRETURNED is not a payout status.
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.
  • Not emitted: virtual_account.failed, virtual_account.deactivated, payout.kyt_pending, payout.in_review, and any dispute events.

Casing

Status casing differs by event family — always compare statuses case-insensitively:
Event familyStatus locationCasingExamples
Flat payout / VA eventsdata.statuslowercasecreated, pending, processing, activating
payout.status_changeddata.data.statusUPPERCASEIN_REVIEW, PROCESSING
user.* eventsdata.statusUPPERCASECREATED
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.
{
  "event": "user.created",
  "data": {
    "event_id": "0af1a2f4-49c4-41a3-accf-d4ba74691bbe",
    "type": "individual",
    "email": "user@example.com",
    "phone": "",
    "status": "CREATED",
    "user_id": "5f575683-93b6-4a4d-b70c-d71c402b5a90",
    "metadata": {},
    "created_at": "2026-05-23T00:37:38.769Z",
    "missing_fields": {
      "usa-virtual-accounts": ["birth_date", "phone", "nationality", "address_street", "address_city", "address_state", "address_zip_code", "address_country", "document_type", "document_number", "document_country", "identifying_information:front", "identifying_information:back:unless_doc_type:passport", "identifying_information:file_proof_of_address"],
      "usa-virtual-accounts-act": ["birth_date", "phone", "nationality", "address_street", "address_city", "address_state", "address_zip_code", "address_country", "document_type", "document_number", "document_country", "immigration_status", "additional_info:has_us_bank_account", "additional_info:has_denied_bank_account", "employment_status"]
    },
    "verification_mode": "automatic",
    "verification_status": "unverified",
    "verification_triggered": false
  }
}
{
  "event": "virtual_account.deposit_funds_received",
  "data": {
    "event_id": "491e0d6e-a5e1-4158-a331-db8accc80a57",
    "amount": "123.45000000",
    "source": {
      "imad": "24314815FMFNUS815455",
      "omad": "44473246FMFNUS272296",
      "sender_name": "Simulated Sender",
      "payment_rail": "wire",
      "wire_message": "Simulated wire deposit from Simulated Sender"
    },
    "status": "completed",
    "currency": "USD",
    "created_at": "2026-05-23T00:37:45.897Z",
    "deposit_id": "72b6581c-76f4-41a3-8169-8ba6c36c138d",
    "virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd"
  }
}
{
  "event": "payout.created",
  "data": {
    "event_id": "ee02c66f-56dd-4a30-a209-35c5d8e8d0d7",
    "fees": {
      "total": "30.00",
      "base_fees": {
        "total": "30.00",
        "fixed_fee": "30.00",
        "percentage_fee": "0.00",
        "bank_account_fee": "0.00",
        "bank_account_fee_percentage": "0"
      },
      "total_fees": "30.00",
      "network_fee": "0.00",
      "client_markup": {
        "total": "0.00",
        "fixed_fee": "0.00",
        "percentage_fee": "0.00"
      }
    },
    "amount": "100.00",
    "status": "created",
    "currency": "USD",
    "payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
    "created_at": "2026-05-23T00:37:46.373Z",
    "recipient_id": "e67383b6-04c0-42f9-b199-f4523909178f",
    "recipient_amount": "70.00",
    "recipient_currency": "USD",
    "virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd"
  }
}
{
  "event": "payout.processing",
  "data": {
    "event_id": "50df79a7-832d-4567-a63e-f62e4bb0ad74",
    "fees": {
      "total": "30.00",
      "base_fees": {
        "total": "30.00",
        "fixed_fee": "30.00",
        "percentage_fee": "0.00",
        "bank_account_fee": "0.00",
        "bank_account_fee_percentage": "0"
      },
      "total_fees": "30.00",
      "network_fee": "0.00",
      "client_markup": {
        "total": "0.00",
        "fixed_fee": "0.00",
        "percentage_fee": "0.00"
      }
    },
    "amount": "100.00",
    "status": "processing",
    "currency": "USD",
    "payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
    "recipient_id": "e67383b6-04c0-42f9-b199-f4523909178f",
    "recipient_amount": "70.00",
    "recipient_currency": "USD",
    "virtual_account_id": "f236ae11-ce2d-4bb8-a580-c8601af98cbd"
  }
}
{
  "event": "payout.status_changed",
  "data": {
    "event_id": "f6e3c92c-43b5-49e5-8545-de31dc1105c9",
    "data": {
      "amount": "100.00",
      "status": "IN_REVIEW",
      "currency": "USD",
      "provider": "kira",
      "payout_id": "e2503e1d-6a42-4602-bc83-4eddc15a18aa",
      "recipient": {
        "name": "Eu Recipient",
        "bank_name": "CaixaBank",
        "company_name": null,
        "account_number": "****1332",
        "routing_number": null
      },
      "reference": "",
      "wallet_id": "",
      "review_reason": "HTTP 500 - payout provider is not configured",
      "previous_status": "PROCESSING",
      "destination_amount": "70.00",
      "destination_currency": "USD"
    },
    "created_at": "2026-05-23T00:37:56.874Z",
    "event_type": "payout.status_changed"
  }
}