> ## Documentation Index
> Fetch the complete documentation index at: https://docs.singapay.id/llms.txt
> Use this file to discover all available pages before exploring further.

# Sub-Account KYB Verification Webhook

> Real-time notifications when a managed sub-account's KYB (Know Your Business) submission is approved or rejected by our verification team.

<Note>
  **Sub-Account Operation** — This webhook fires on KYB review outcomes for your **managed sub-accounts** (`business_managed`). It does not apply to your own merchant-level KYB, or to `owned`/`personal_managed` sub-accounts (which never require KYB).
</Note>

## Information

<div className="overflow-x-auto">
  | Method                                                                                                              | Path                                | Format | Authentication        |
  | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------ | --------------------- |
  | <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-blue-500 text-white">POST</span> | `https://your-webhook-url/callback` | json   | HMAC SHA512 Signature |
</div>

SingaPay sends a `POST` request to your configured `kyb_notif_url` whenever a managed sub-account's KYB submission finishes review — either approved, or rejected at one of the review stages.

***

## Events

This single webhook covers two events, distinguished by the `event` field:

<div className="overflow-x-auto">
  | `event`        | Fired when                                                                                        | `data` contains                             |
  | -------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- |
  | `kyb.approved` | The sub-account passes **all** review stages (Document → DTTOT → Blacklist) and becomes `active`. | `account`, `verified_at`                    |
  | `kyb.rejected` | **Any** review stage (Document, DTTOT, or Blacklist) rejects the submission.                      | `account`, `stage`, `reason`, `rejected_at` |
</div>

<Note>
  KYB review runs through three sequential stages: **Document Check → DTTOT Check → Blacklist Check**. `kyb.rejected` can fire at any of the three — you will receive a **separate webhook call for each rejection**, not just one at the end. `kyb.approved` fires only once, after the final stage passes.
</Note>

***

## Request Details

### Headers

| Field           | Value                     | Type         | Mandatory | Description                                                                |
| --------------- | ------------------------- | ------------ | :-------: | -------------------------------------------------------------------------- |
| `Content-Type`  | `application/json`        | Alphabetic   |    Yes    | Specifies JSON format for the request body                                 |
| `User-Agent`    | `SingaPaymentGateway/1.0` | Alphabetic   |    Yes    | Identifies the source of the webhook                                       |
| `Accept`        | `application/json`        | Alphabetic   |    Yes    | Expected response format                                                   |
| `X-PARTNER-ID`  | —                         | Alphanumeric |    Yes    | Your API Key from the merchant dashboard                                   |
| `X-Signature`   | —                         | Alphanumeric |    Yes    | HMAC SHA512 signature (128 chars) for request verification                 |
| `X-Timestamp`   | —                         | Numeric      |    Yes    | Unix timestamp in **seconds** when the request was sent                    |
| `Authorization` | `Bearer <random_token>`   | Alphanumeric |    Yes    | System-generated random bearer token; used as a component in the signature |

<Note>
  The `Authorization` token for KYB webhooks is a **randomly generated string** — not a user access token. KYB review outcomes are triggered by an internal reviewer action, not a merchant request. Extract the token as-is and use it in the string to sign. See [Security and signature validation](/api-reference/webhooks/security-and-signature).
</Note>

***

### Body Parameters — common envelope

Every KYB webhook shares the same top-level envelope:

<ParamField body="status" type="number" required>
  HTTP Status Code. Always `200`.
</ParamField>

<ParamField body="success" type="boolean" required>
  Indicates a successful event. Always `true`.
</ParamField>

<ParamField body="event" type="string" required>
  Event type identifier. One of `"kyb.approved"`, `"kyb.rejected"`.
</ParamField>

<ParamField body="timestamp" type="string" required>
  Event timestamp in format `"d M Y H:i:s"`. Example: `"28 Aug 2026 14:33:55"`
</ParamField>

<ParamField body="data" type="object" required>
  Container for the account details (and, for rejections, the rejection details).
</ParamField>

***

### `data.account` (present on all events)

<ParamField body="account" type="object" required>
  The sub-account the event relates to. Shape is identical to the `Account` object returned by [`GET /accounts/{id}`](/api-reference).

  <Expandable title="account fields">
    <ParamField body="id" type="string" required>
      Account ULID. Example: `"01K946KF851RK7FX075GJHBVKF"`
    </ParamField>

    <ParamField body="account_number" type="string">
      Account number. May be `null`.
    </ParamField>

    <ParamField body="name" type="string" required>
      Account name.
    </ParamField>

    <ParamField body="status" type="string" required>
      `"active"` or `"inactive"`.
    </ParamField>

    <ParamField body="account_type" type="string" required>
      Always `"business_managed"` in the context of this webhook.
    </ParamField>

    <ParamField body="kyb_status" type="string">
      One of `"kyb_waiting_document"`, `"kyb_in_review"`, `"kyb_rejected"`, `"kyb_verified"`.
    </ParamField>

    <ParamField body="kyb_rejection_reason" type="string">
      The currently outstanding rejection reason. `null` unless `kyb_status` is `"kyb_rejected"`.
    </ParamField>

    <ParamField body="business_type" type="string">
      `"individual"` or `"company"`. `null` until the sub-merchant makes this choice during onboarding.
    </ParamField>

    <ParamField body="legal_name" type="string">
      Legal company name (`business_type = "company"`) or the primary owner's full name (`business_type = "individual"`). `null` until `kyb_status = "kyb_verified"`.
    </ParamField>

    <ParamField body="brand_name" type="string">
      Business/brand name. `null` until `kyb_status = "kyb_verified"`.
    </ParamField>

    <ParamField body="kyb_onboarding_url" type="string">
      The sub-merchant's self-onboarding link. Stays active (and identical) across reject → resubmit cycles; becomes `null` once `kyb_status = "kyb_verified"` (the link is revoked).
    </ParamField>

    <ParamField body="invite_members" type="string[]">
      Email addresses of your dashboard members who have access to this sub-account.
    </ParamField>
  </Expandable>
</ParamField>

***

### `data.verified_at` (`kyb.approved` only)

<ParamField body="verified_at" type="string" required>
  Timestamp the final review stage was approved. Format `"d M Y H:i:s"`.
</ParamField>

***

### `data.stage`, `data.reason`, `data.rejected_at` (`kyb.rejected` only)

<ParamField body="stage" type="string" required>
  Which review stage rejected the submission. One of `"document"`, `"dttot"`, `"blacklist"`.
</ParamField>

<ParamField body="reason" type="string" required>
  Free-text reason written by the reviewer. Not a structured error code — read it directly to determine what needs correcting.
</ParamField>

<ParamField body="rejected_at" type="string" required>
  Timestamp the rejection was recorded. Format `"d M Y H:i:s"`.
</ParamField>

***

### Payload Examples

```json kyb.approved theme={null}
{
  "status": 200,
  "success": true,
  "event": "kyb.approved",
  "timestamp": "28 Aug 2026 14:33:55",
  "data": {
    "account": {
      "id": "01K946KF851RK7FX075GJHBVKF",
      "account_number": "000000000123",
      "name": "SingaPay Demo Store",
      "status": "active",
      "account_type": "business_managed",
      "kyb_status": "kyb_verified",
      "kyb_rejection_reason": null,
      "business_type": "company",
      "legal_name": "PT SingaPay Demo Indonesia",
      "brand_name": "SingaPay Demo Store",
      "kyb_onboarding_url": null,
      "invite_members": ["owner@merchant.com"]
    },
    "verified_at": "28 Aug 2026 14:33:55"
  }
}
```

```json kyb.rejected theme={null}
{
  "status": 200,
  "success": true,
  "event": "kyb.rejected",
  "timestamp": "28 Aug 2026 14:35:12",
  "data": {
    "account": {
      "id": "01K946KF851RK7FX075GJHBVKF",
      "account_number": "000000000123",
      "name": "SingaPay Demo Store",
      "status": "inactive",
      "account_type": "business_managed",
      "kyb_status": "kyb_rejected",
      "kyb_rejection_reason": "KTP tidak terbaca / dokumen buram",
      "business_type": "company",
      "legal_name": null,
      "brand_name": null,
      "kyb_onboarding_url": "https://sandbox-payment-b2b.singapay.id/kyb/0b0e5ad5957a7c2de066c2cde97e91089b605aae9afeba3398ff871cd1e99d54",
      "invite_members": ["owner@merchant.com"]
    },
    "stage": "document",
    "reason": "KTP tidak terbaca / dokumen buram",
    "rejected_at": "28 Aug 2026 14:35:12"
  }
}
```

***

## Security and responses

Return HTTP `200` promptly after validating the request. For retry behavior, see [Webhook retry mechanism](/api-reference/webhooks/retry-mechanism).

Verify every webhook using [Security and signature validation](/api-reference/webhooks/security-and-signature). Use your configured `kyb_notif_url` path when building `StringToSign`.

Handle duplicate deliveries idempotently using `data.account.id` combined with the `event` (and, for rejections, `data.stage`) as a stable identifier.

***

## Sub-Account KYB Specific Notes

<AccordionGroup>
  <Accordion title="Dedicated callback URL" icon="link">
    `kyb_notif_url` is a dedicated field — it does not share a callback URL with any other webhook type. Configure it separately in your dashboard under **Settings → Credentials → Webhook & Notification URLs**.
  </Accordion>

  <Accordion title="Multiple rejections per sub-account" icon="rotate-left">
    Review runs through three sequential stages. If a sub-merchant is rejected, corrects the issue, and resubmits, and is rejected again at a later stage, you will receive a **new** `kyb.rejected` webhook for that stage — each rejection in the account's history triggers its own call.
  </Accordion>

  <Accordion title="The onboarding link survives rejection" icon="link-slash">
    `kyb_onboarding_url` stays the same across reject → resubmit cycles — no new link is generated on rejection. The sub-merchant reopens the same URL to fix and resubmit their data. The link is only revoked (becomes `null`) once `kyb_status` reaches `kyb_verified`.
  </Accordion>

  <Accordion title="kyb_status alone doesn't tell you why" icon="circle-question">
    `kyb_status = "kyb_rejected"` tells you a rejection is outstanding, but the specific reason lives in `kyb_rejection_reason` — a **free-text string** written by a human reviewer, not a structured per-field error code. If you need to know exactly which document or field was flagged, parse the reason text or direct your sub-merchant to read it carefully before resubmitting.
  </Accordion>

  <Accordion title="legal_name and brand_name are null until verified" icon="id-card">
    Both fields only populate once `kyb_status = "kyb_verified"` — they are `null` throughout `kyb_waiting_document`, `kyb_in_review`, and `kyb_rejected`, including in the `kyb.rejected` payload itself.
  </Accordion>

  <Accordion title="Timestamp format & timezone" icon="clock">
    All timestamps (`timestamp`, `verified_at`, `rejected_at`) use the format `"d M Y H:i:s"` (e.g. `"28 Aug 2026 14:33:55"`) in **Asia/Jakarta (WIB)** timezone — not UTC.
  </Accordion>
</AccordionGroup>
