> ## 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.

# Verify the holder name on an e-wallet account.

> Looks up the registered holder name on the named e-wallet for
the given phone number and compares it against the submitted
`name`.

### Behaviour by outcome

| Upstream result | Response shape |
|---|---|
| Account exists, real name | `status="found with kyc"`, `similarity` ≥ 0 |
| Account exists, no KYC (placeholder name) | `status="found without kyc"`, `similarity=0`, `suggestion="review"` |
| Account not found (definitive `success=false` from upstream) | `status="not found"`, `similarity=0`, `suggestion="reject"` |
| Upstream unavailable (5xx / timeout / transport error) | HTTP 502/503/504; no row persisted for this attempt |
| Internal validation error (bad phone / name / wallet code) | HTTP 400 |

### Phone number format

Accept both `0xxx...` and `62xxx...` forms (9-13 digits total).
The service canonicalises and re-formats per-wallet before
calling each upstream.

### Idempotency

Two requests with the same `(merchant_id, request_id)` return
the same response and are billed once. Choose a UUID v4 for
`request_id`.




## OpenAPI

````yaml https://core.singapay.id/identity-verification/docs/swagger.json post /api/v1/kyc/ewallet/verify
openapi: 3.0.3
info:
  title: Singapay Identity Verification API
  description: |
    The Identity Verification API exposes Singapay's e-wallet account-name
    verification feature to integrating merchants.

    ## Authentication

    Programmatic clients use a two-step flow:

    1. **Exchange credentials for a short-lived JWT** via
       `POST /api/v1/kyc/auth/get-auth-token`. The request carries a
       HMAC-SHA256 signature of `{client_id}:{timestamp}` keyed with the
       merchant's `client_secret`. The response is an access token with
       `Bearer` token type.
    2. **Call business endpoints with the JWT** in an
       `Authorization: Bearer <token>` header. JWT default lifetime
       is one hour.

    Credentials (`client_id` / `client_secret`) are issued from the
    merchant KYC dashboard. `client_secret` is shown ONCE at creation —
    if lost, regenerate from the dashboard.

    ### Signature computation

    ```
    timestamp     = now in RFC 3339 with second precision (UTC)
    string_to_sign = "{client_id}:{timestamp}"
    signature     = hex(HMAC-SHA256(client_secret, string_to_sign))
    ```

    During sandbox integration the helper
    `POST /api/v1/kyc/auth/sample-gen-sign` (deliberately omitted from
    this spec; see the docs/api/README) can compute a signature for you
    given the secret — but never call it in production: it requires you
    to send your secret over the wire, which defeats the purpose of HMAC.

    ## Idempotency

    `POST /api/v1/kyc/ewallet/verify` is idempotent on the merchant-
    supplied `request_id`. Two requests with the same `(merchant_id,
    request_id)` pair return the same response and are billed once.

    ## Rate limits

    Standard tier: 60 requests per second per merchant. Bursts beyond
    the bucket return `429 Too Many Requests` with `Retry-After` and
    `X-RateLimit-*` headers. Contact support to upgrade the tier.

    ## IP allowlisting

    A credential may carry an IP allowlist (CIDR or bare IP). When the
    list is non-empty, requests from other IPs are rejected with
    `403 IP_NOT_ALLOWED`. Empty list = no IP restriction.
  version: 1.0.0
  contact:
    name: Singapay Integration Support
    email: integration@singapay.id
  license:
    name: Proprietary
servers:
  - url: https://sandbox.singapay.id
    description: Sandbox — for integration testing.
  - url: https://api.singapay.id
    description: Production.
security:
  - bearerAuth: []
tags:
  - name: Authentication
    description: |
      Exchange long-lived credentials for short-lived access tokens.
      Anonymous endpoints (no Bearer required).
  - name: E-Wallet
    description: |
      Verify the registered holder name on an e-wallet account against
      a name the merchant submits. Requires a valid Bearer token with
      the `kyc.ewallet.verify` scope.
paths:
  /api/v1/kyc/ewallet/verify:
    post:
      tags:
        - E-Wallet
      summary: Verify the holder name on an e-wallet account.
      description: >
        Looks up the registered holder name on the named e-wallet for

        the given phone number and compares it against the submitted

        `name`.


        ### Behaviour by outcome


        | Upstream result | Response shape |

        |---|---|

        | Account exists, real name | `status="found with kyc"`, `similarity` ≥
        0 |

        | Account exists, no KYC (placeholder name) | `status="found without
        kyc"`, `similarity=0`, `suggestion="review"` |

        | Account not found (definitive `success=false` from upstream) |
        `status="not found"`, `similarity=0`, `suggestion="reject"` |

        | Upstream unavailable (5xx / timeout / transport error) | HTTP
        502/503/504; no row persisted for this attempt |

        | Internal validation error (bad phone / name / wallet code) | HTTP 400
        |


        ### Phone number format


        Accept both `0xxx...` and `62xxx...` forms (9-13 digits total).

        The service canonicalises and re-formats per-wallet before

        calling each upstream.


        ### Idempotency


        Two requests with the same `(merchant_id, request_id)` return

        the same response and are billed once. Choose a UUID v4 for

        `request_id`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/VerifyRequest'
            examples:
              dana_match:
                summary: Successful DANA lookup with a strong name match
                value:
                  request_id: 8b1d6f3e-9a02-4c5d-9f7a-2c8e1b3d4f5a
                  phone_number: '081234567890'
                  name: Budi Santoso
                  ewallet_code: DANA
              ovo_partial:
                summary: OVO lookup, name partially matches
                value:
                  request_id: f1c2a3b4-5d6e-7f80-9012-345678901234
                  phone_number: '6281122334455'
                  name: Siti R.
                  ewallet_code: OVO
      responses:
        '200':
          description: |
            Verification complete (whether the account exists or not).
            Inspect `status` for the outcome and `suggestion` for the action.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerifyResponse'
              examples:
                found_with_kyc:
                  summary: Account exists with a real, matching name → pass
                  value:
                    request_id: 8b1d6f3e-9a02-4c5d-9f7a-2c8e1b3d4f5a
                    status: found with kyc
                    similarity: 96.5
                    suggestion: pass
                found_without_kyc:
                  summary: >-
                    Account exists but holder hasn't completed KYC → review
                    (similarity forced to 0)
                  value:
                    request_id: a7e3c1d0-2b4f-4a6c-8e90-1f2a3b4c5d6e
                    status: found without kyc
                    similarity: 0
                    suggestion: review
                not_found:
                  summary: No account on the wallet → reject
                  value:
                    request_id: f1c2a3b4-5d6e-7f80-9012-345678901234
                    status: not found
                    similarity: 0
                    suggestion: reject
        '400':
          description: |
            `BAD_REQUEST` (missing field), `INVALID_PHONE_FORMAT`
            (not 9-13 digits / not 0xxx-or-62xxx), `INVALID_NAME`
            (empty after trim), or unknown `ewallet_code`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: |
            Missing, expired, or malformed `Authorization: Bearer` token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '402':
          description: |
            `INSUFFICIENT_BALANCE` — the merchant's prepaid balance is
            below the call's fee. Top up via the dashboard.
            *(Only returned when billing is enabled service-wide.)*
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: |
            `SCOPE_DENIED` (credential lacks `kyc.ewallet.verify`) or
            `IP_NOT_ALLOWED` (client IP not in the credential's allowlist).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: |
            `IDEMPOTENCY_MISMATCH` — the same `request_id` was previously
            used with a DIFFERENT payload. Retries must keep the payload
            byte-identical.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: |
            Rate limit exceeded for this merchant + product. The
            `Retry-After` header indicates when to try again.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
              description: Seconds until the bucket resets.
            X-RateLimit-Limit:
              schema:
                type: integer
            X-RateLimit-Remaining:
              schema:
                type: integer
            X-RateLimit-Reset:
              schema:
                type: integer
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '502':
          description: |
            `UPSTREAM_UNAVAILABLE` — the wallet provider returned a 5xx
            or a transport failure on both attempts (we retry once).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '504':
          description: |
            `UPSTREAM_TIMEOUT` — the wallet provider didn't respond
            within the 110s upstream window.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    VerifyRequest:
      type: object
      required:
        - request_id
        - phone_number
        - name
        - ewallet_code
      properties:
        request_id:
          type: string
          description: |
            Merchant-supplied idempotency key. Recommended: UUID v4.
            Two requests with the same key return the same answer and
            are billed once.
          example: 8b1d6f3e-9a02-4c5d-9f7a-2c8e1b3d4f5a
        phone_number:
          type: string
          pattern: ^(0|62)[1-9][0-9]{7,11}$
          description: |
            E-wallet phone number. Accept `0xxx...` or `62xxx...` (9-13
            digits total). Service canonicalises before lookup.
          example: '081234567890'
        name:
          type: string
          minLength: 1
          maxLength: 200
          description: |
            Name to verify against the registered holder name. Trimmed,
            case-insensitive, normalised (whitespace + honorifics).
          example: Budi Santoso
        ewallet_code:
          type: string
          enum:
            - DANA
            - SHOPEEPAY
            - GOPAY
            - OVO
          description: |
            Primary wallet to check. The other three wallets are queried
            in parallel and returned under `other_ewallet_similarity`.
    VerifyResponse:
      type: object
      required:
        - request_id
        - status
        - similarity
        - suggestion
      properties:
        request_id:
          type: string
          description: Echo of the request's `request_id`.
        status:
          type: string
          enum:
            - not found
            - found without kyc
            - found with kyc
          description: |
            Account state on the named wallet:
            - `not found` — no account for this phone.
            - `found without kyc` — account exists but the holder never
              completed KYC, so the upstream returned a placeholder (a bare
              phone for DANA/OVO/GOPAY, or a machine-generated handle for
              SHOPEEPAY; a name-like nickname is NOT flagged).
            - `found with kyc` — account exists with a real registered name.
        similarity:
          type: number
          format: float
          minimum: 0
          maximum: 100
          description: |
            Name-match score (0.0–100.0). `0` when `status` is `not found`
            or `found without kyc` (no real name to match); otherwise the
            score for the `found with kyc` name. Internally a 0–1 float,
            multiplied by 100 at the API boundary.
        suggestion:
          type: string
          enum:
            - pass
            - review
            - reject
          description: |
            Recommended action, from `similarity` vs the wallet's configured
            pass threshold (GOPAY/OVO/DANA 80, SHOPEEPAY 70):
            - `pass` — similarity >= pass.
            - `review` — pass-10 <= similarity < pass, OR status is
              `found without kyc`.
            - `reject` — similarity < pass-10, OR status is `not found`.
        other_ewallet_similarity:
          type: array
          items:
            $ref: '#/components/schemas/VerifyResultOther'
    Error:
      type: object
      required:
        - error
        - message
      properties:
        error:
          type: string
          description: |
            Machine-readable error code. See the table in the docs/api
            README for the full enum.
          example: INVALID_PHONE_FORMAT
        message:
          type: string
          description: Human-readable explanation; safe to surface to merchant operators.
          example: phone_number must be 9-13 digits starting with 0 or 62
    VerifyResultOther:
      type: object
      required:
        - ewallet_code
        - status
        - no_kyc
      properties:
        ewallet_code:
          type: string
          enum:
            - DANA
            - SHOPEEPAY
            - GOPAY
            - OVO
        result:
          type: boolean
          nullable: true
          description: |
            `true` = account exists, `false` = upstream definitively
            says no account, `null` = upstream was unavailable (don't
            confuse with `false`).
        similarity_percentage:
          type: number
          format: float
          minimum: 0
          maximum: 100
          nullable: true
          description: |
            `null` when `result=null` or `result=false`. Otherwise the
            same 0.0–100.0 scale as the primary wallet's score.
        no_kyc:
          type: boolean
          description: |
            `true` when this wallet's account exists but returned a
            placeholder instead of a name (holder hasn't completed KYC):
            a phone number for DANA/OVO/GOPAY, or a machine-generated
            handle for SHOPEEPAY. `false` for real names/nicknames,
            `account_not_found`, and `unavailable`.
        status:
          type: string
          enum:
            - ok
            - account_not_found
            - unavailable
          description: |
            - `ok` — upstream answered and account exists.
            - `account_not_found` — upstream definitively says no.
            - `unavailable` — upstream did not return a usable answer.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        RS256-signed JWT obtained from `POST /api/v1/kyc/auth/get-auth-token`.
        Default lifetime one hour. JWKS is published at
        `/.well-known/jwks.json` on the service side (used by the gateway,
        not by merchants).

````