Signa webhooks

Register your KYC webhook URL, read the session events, and verify the signature.

Blaaiz sends the result of every verification session to a webhook URL that you register. The webhook is the only push channel for the result. You can also poll the session endpoints, but the webhook arrives as soon as the review completes.

Register your KYC URL

Signa events go to a third webhook URL, kyc_url. It is separate from collection_url and payout_url.

Set kyc_url when you register your webhook URLs:

curl -X POST https://api-prod.blaaiz.com/api/external/webhook \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "collection_url": "https://yourapp.com/webhooks/collection",
    "payout_url": "https://yourapp.com/webhooks/payout",
    "kyc_url": "https://yourapp.com/webhooks/kyc"
  }'

If you already registered your webhook URLs, add kyc_url with an update:

curl -X PUT https://api-prod.blaaiz.com/api/external/webhook/WEBHOOK_ID \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "collection_url": "https://yourapp.com/webhooks/collection",
    "payout_url": "https://yourapp.com/webhooks/payout",
    "kyc_url": "https://yourapp.com/webhooks/kyc"
  }'

Both calls need the webhook:write scope. The URL must be HTTPS and must resolve on the public internet.

kyc_url is optional. If you omit it, Blaaiz records the session result and does not deliver it. Read the result from GET a session instead.

Signa webhooks are signed with the same signing_secret as your collection and payout webhooks. You need no second secret.

Events

EventMeaning
merchant.kyc.session.completedThe review finished. data.result is APPROVED or REJECTED.
merchant.kyc.session.expiredThe session passed its deadline before it completed.

Blaaiz sends no webhook for a session that you cancel, and no webhook for a verdict that arrives after a session is already terminal.

Payload

The body holds two fields: event and data. The timestamp travels in the x-blaaiz-timestamp header, not in the body.

{
  "event": "merchant.kyc.session.completed",
  "data": {
    "session_id": "9f2c7b41-6d3e-4c8a-9a20-1e6f0b5d7c33",
    "business_id": "9d4c4ec5-572d-49de-a362-f01ed09f2b1b",
    "customer_reference": "user_10482",
    "requirements": ["DOCUMENTS", "SELFIE", "FACE_MATCH"],
    "result": "APPROVED",
    "rejection_reason": null,
    "rejection_type": null,
    "completed_at": "2026-08-29T09:31:05.000Z"
  }
}
FieldTypeDescription
data.session_idstringThe session id.
data.business_idstringYour business id.
data.customer_referencestringThe reference you sent on create.
data.requirementsarrayThe requirement set of the session.
data.resultstringAPPROVED or REJECTED.
data.rejection_reasonstring or nullText you can show to the person. Only set when result is REJECTED.
data.rejection_typestring or nullFINAL or RETRYABLE. Only set when result is REJECTED.
data.completed_atstringWhen the review completed, in ISO 8601.

A rejected session looks like this:

{
  "event": "merchant.kyc.session.completed",
  "data": {
    "session_id": "3b81f0d5-9c47-42ae-b6e1-7d2a5c9f4801",
    "business_id": "9d4c4ec5-572d-49de-a362-f01ed09f2b1b",
    "customer_reference": "user_10513",
    "requirements": ["DOCUMENTS"],
    "result": "REJECTED",
    "rejection_reason": "The document image is too blurred to read.",
    "rejection_type": "RETRYABLE",
    "completed_at": "2026-08-29T11:02:44.000Z"
  }
}

An expired session carries a smaller payload:

{
  "event": "merchant.kyc.session.expired",
  "data": {
    "session_id": "5c19ab73-2f60-4d8e-8a11-c07be4d95f22",
    "business_id": "9d4c4ec5-572d-49de-a362-f01ed09f2b1b",
    "customer_reference": "user_10604",
    "expired_at": "2026-08-30T09:14:22.000Z"
  }
}

Headers

HeaderDescription
Content-TypeAlways application/json.
User-AgentAlways Blaaiz.
x-blaaiz-timestampThe time Blaaiz signed the request, as Unix seconds.
x-blaaiz-signatureThe HMAC-SHA256 signature, in lowercase hexadecimal.

Verify the signature

Blaaiz builds the signature over the timestamp, a full stop, and the request body:

signature = HMAC_SHA256(x-blaaiz-timestamp + "." + raw_request_body, signing_secret)

Use the raw request body. If you decode the JSON and encode it again, key order and spacing can change, and the signature no longer matches.

Reject the request when the signature does not match. A request with a bad signature did not come from Blaaiz.

const crypto = require('crypto');
const express = require('express');

const app = express();
const secret = process.env.BLAAIZ_WEBHOOK_SIGNING_SECRET;
const REPLAY_WINDOW_SECONDS = 300;

// Keep the raw body. The signature covers the exact bytes Blaaiz sent.
app.post('/webhooks/kyc', express.raw({ type: 'application/json' }), (req, res) => {
    const timestamp = req.headers['x-blaaiz-timestamp'];
    const receivedSignature = req.headers['x-blaaiz-signature'];

    if (!timestamp || !receivedSignature) {
        return res.status(400).send('Missing signature headers.');
    }

    const age = Math.floor(Date.now() / 1000) - Number(timestamp);
    if (!Number.isFinite(age) || Math.abs(age) > REPLAY_WINDOW_SECONDS) {
        return res.status(400).send('Timestamp outside the replay window.');
    }

    const expectedSignature = crypto
        .createHmac('sha256', secret)
        .update(`${timestamp}.${req.body.toString('utf8')}`)
        .digest('hex');

    const expected = Buffer.from(expectedSignature, 'utf8');
    const received = Buffer.from(receivedSignature, 'utf8');

    if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
        return res.status(401).send('Invalid signature.');
    }

    const payload = JSON.parse(req.body.toString('utf8'));

    // Answer first, then process the event in the background.
    res.status(200).send('OK');

    if (payload.event === 'merchant.kyc.session.completed') {
        handleSessionResult(payload.data);
    }
});
<?php

$secret = getenv('BLAAIZ_WEBHOOK_SIGNING_SECRET');
$replayWindowSeconds = 300;

// The raw body, not a decoded and re-encoded copy.
$rawBody = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_BLAAIZ_TIMESTAMP'] ?? null;
$receivedSignature = $_SERVER['HTTP_X_BLAAIZ_SIGNATURE'] ?? null;

if ($timestamp === null || $receivedSignature === null) {
    http_response_code(400);
    exit('Missing signature headers.');
}

if (abs(time() - (int) $timestamp) > $replayWindowSeconds) {
    http_response_code(400);
    exit('Timestamp outside the replay window.');
}

$expectedSignature = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

if (! hash_equals($expectedSignature, $receivedSignature)) {
    http_response_code(401);
    exit('Invalid signature.');
}

$payload = json_decode($rawBody, true);

http_response_code(200);

if (($payload['event'] ?? null) === 'merchant.kyc.session.completed') {
    handleSessionResult($payload['data']);
}

Replay window

The signature does not expire on its own. Compare x-blaaiz-timestamp against your own clock and reject anything older than 5 minutes. This stops an attacker from replaying a request that they captured earlier.

Keep your server clock in sync with NTP. A drifting clock rejects valid webhooks.

Delivery and retries

Reply with any HTTP 2xx status to confirm the delivery. Blaaiz retries every other outcome, including a timeout and a connection error.

AttemptDelay after the previous attempt
1Immediate
21 minute
35 minutes
415 minutes
51 hour

After the fifth attempt fails, Blaaiz marks the event as failed and stops. Read the session with GET a session to recover the result.

Signa webhooks use their own retry schedule. It is shorter than the schedule for collection and payout events.

Handle duplicates

Blaaiz delivers each event at least once, so the same event can arrive twice. Make your handler idempotent.

Deduplicate on data.session_id together with event. A session sends merchant.kyc.session.completed one time, so a second delivery of that pair is a duplicate.

Best practice

  1. Verify the signature before you read the payload.
  2. Check the timestamp against your replay window.
  3. Reply with 2xx within 30 seconds.
  4. Do the slow work in a background job.
  5. Treat a repeated session_id and event pair as a duplicate.

On this page