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

# Face Comparison

> Compares `first_image` with `second_image` and returns a
similarity score plus bounding boxes.

A scored comparison (`SUCCESS`) and billed detection outcomes
(`FACE_NOT_FOUND`, `FIRST_FACE_NOT_FOUND`, `SECOND_FACE_NOT_FOUND`,
`MULTIPLE_FACES_DETECTED`) are billed (`pricing=PAID`). Validation
errors and service failures are not billed (`pricing=FREE`).

This feature must be enabled on your account. If it is not, the
API returns `UNAUTHORIZED`.

### Outcomes

| Result | Response |
|---|---|
| Faces compared | `code=SUCCESS`, `data.first_face` / `data.second_face` bounding boxes, `data.similarity`, `pricing=PAID` |
| No face / multiple faces | `code` is the vendor detection code, `pricing=PAID` (no `data`) |
| Face quality rejected by vendor | `code=CLIENT_ERROR`, `pricing=FREE` |
| Temporary service issue | `code=SERVER_ERROR`, `pricing=FREE` |
| Invalid images or `request_id` | `code=CLIENT_ERROR`, `pricing=FREE` |
| Same `request_id` with different images | `code=DUPLICATE_REFERENCE`, `pricing=FREE` |
| Missing token, feature not enabled, or IP not allowlisted | `code=UNAUTHORIZED`, `pricing=FREE` |
| Prepaid balance too low | `code=INSUFFICIENT_BALANCE`, `pricing=FREE` |

### Images

JPEG and PNG only, 5 MB or smaller per file. Send them either as:

- `application/json` — `first_image` and `second_image` are raw base64
  (no line breaks, no `data:image/…` prefix)
- `multipart/form-data` — `request_id` as a text field, `first_image`
  and `second_image` as file parts (the same field names)

Do not mix the two encodings in one request.


### Idempotency

Use a unique `request_id` for each new comparison (UUID v4 is
recommended). Repeating the same `request_id` with the same
images returns the original result and is billed once — unless
the previous attempt did not complete, in which case the
comparison runs again. Different images with the same
`request_id` return `DUPLICATE_REFERENCE`.




## OpenAPI

````yaml https://core.singapay.id/identity-verification/docs/swagger.json post /api/v1/kyc/face-comparison
openapi: 3.0.3
info:
  title: Singapay Identity Verification API
  description: |
    Verify e-wallet accounts, bank accounts, ID photos, and ID-card
    authenticity with one API.

    ## Authentication

    1. Call `POST /api/v1/kyc/auth/get-auth-token` with your `client_id`,
       a UTC timestamp, and an HMAC signature. You receive a Bearer
       token that is valid for one hour.
    2. Send that token on every other request:

       `Authorization: Bearer <access_token>`

    `client_id` and `client_secret` are issued from the KYC dashboard.
    The secret is shown only once — if you lose it, regenerate it from
    the dashboard.

    Your account must have KYC access enabled. If it does not,
    `get-auth-token` returns HTTP 401 (`error=FEATURE_NOT_ENABLED`).
    Contact support to enable access.

    ### How to sign

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

    The timestamp must be within 5 minutes of our server clock.

    ## Idempotency

    Send a unique `request_id` on every new check. If you retry with the
    same `request_id` and the same body, you get the same result and are
    charged once.

    Reusing a `request_id` with a **different** body returns
    `DUPLICATE_REFERENCE` and is not charged.

    If a previous attempt did not complete (for example a temporary
    service error), retrying the same `request_id` runs the check again.

    ## Rate limits

    The standard limit is 60 requests per second. Above that, the API
    returns `429 Too Many Requests` with `Retry-After` and
    `X-RateLimit-*` headers. Contact support if you need a higher limit.

    ## IP allowlisting

    You can restrict a credential to specific IP addresses. Requests
    from other addresses are rejected. If you do not set an allowlist,
    any IP may use the credential.
  version: 1.0.0
  contact:
    name: Singapay Integration Support
    email: integration@singapay.id
  license:
    name: Proprietary
servers:
  - url: https://sandbox-apigw.singapay.id
    description: Sandbox — for integration testing.
  - url: https://apigw.singapay.id
    description: Production.
security:
  - bearerAuth: []
tags:
  - name: Authentication
    description: |
      Get an access token. These endpoints do not require a Bearer token.
  - name: E-Wallet
    description: |
      Check whether a name matches the registered holder of an e-wallet
      account. Requires a Bearer token.
  - name: Bank
    description: |
      Check whether a name matches the registered holder of a bank
      account. Requires a Bearer token. This feature must be enabled on
      your account.
  - name: Face Comparison
    description: |
      Compare a selfie with the photo on an ID card. Requires a Bearer
      token. This feature must be enabled on your account.
  - name: OCR
    description: |
      Extract structured fields from a photo of an Indonesian KTP (ID
      card). Requires a Bearer token. This feature must be enabled on
      your account. Extraction only — there is no name-matching verdict.
  - name: ID Forgery Check
    description: |
      Assess whether a photo of an Indonesian ID card is a forgery
      (photocopy, photograph of the original, edited image, or failed
      information checks). Requires a Bearer token. This feature must
      be enabled on your account.
paths:
  /api/v1/kyc/face-comparison:
    post:
      tags:
        - Face Comparison
      summary: Face Comparison
      description: >
        Compares `first_image` with `second_image` and returns a

        similarity score plus bounding boxes.


        A scored comparison (`SUCCESS`) and billed detection outcomes

        (`FACE_NOT_FOUND`, `FIRST_FACE_NOT_FOUND`, `SECOND_FACE_NOT_FOUND`,

        `MULTIPLE_FACES_DETECTED`) are billed (`pricing=PAID`). Validation

        errors and service failures are not billed (`pricing=FREE`).


        This feature must be enabled on your account. If it is not, the

        API returns `UNAUTHORIZED`.


        ### Outcomes


        | Result | Response |

        |---|---|

        | Faces compared | `code=SUCCESS`, `data.first_face` /
        `data.second_face` bounding boxes, `data.similarity`, `pricing=PAID` |

        | No face / multiple faces | `code` is the vendor detection code,
        `pricing=PAID` (no `data`) |

        | Face quality rejected by vendor | `code=CLIENT_ERROR`, `pricing=FREE`
        |

        | Temporary service issue | `code=SERVER_ERROR`, `pricing=FREE` |

        | Invalid images or `request_id` | `code=CLIENT_ERROR`, `pricing=FREE` |

        | Same `request_id` with different images | `code=DUPLICATE_REFERENCE`,
        `pricing=FREE` |

        | Missing token, feature not enabled, or IP not allowlisted |
        `code=UNAUTHORIZED`, `pricing=FREE` |

        | Prepaid balance too low | `code=INSUFFICIENT_BALANCE`, `pricing=FREE`
        |


        ### Images


        JPEG and PNG only, 5 MB or smaller per file. Send them either as:


        - `application/json` — `first_image` and `second_image` are raw base64
          (no line breaks, no `data:image/…` prefix)
        - `multipart/form-data` — `request_id` as a text field, `first_image`
          and `second_image` as file parts (the same field names)

        Do not mix the two encodings in one request.



        ### Idempotency


        Use a unique `request_id` for each new comparison (UUID v4 is

        recommended). Repeating the same `request_id` with the same

        images returns the original result and is billed once — unless

        the previous attempt did not complete, in which case the

        comparison runs again. Different images with the same

        `request_id` return `DUPLICATE_REFERENCE`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FaceCompareRequest'
            examples:
              match:
                summary: Comparison request (JSON)
                value:
                  request_id: TXN-20260727-001
                  first_image: /9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAg...
                  second_image: >-
                    iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
          multipart/form-data:
            schema:
              type: object
              required:
                - request_id
                - first_image
                - second_image
              properties:
                request_id:
                  type: string
                  description: |
                    Your unique reference for this comparison. Use a new
                    UUID for each new check.
                  example: TXN-20260727-001
                first_image:
                  type: string
                  format: binary
                  description: |
                    First image (typically a selfie) as a JPEG or PNG file.
                    Maximum 5 MB.
                second_image:
                  type: string
                  format: binary
                  description: |
                    Second image (typically an ID-card portrait) as a JPEG
                    or PNG file. Maximum 5 MB.
            encoding:
              first_image:
                contentType: image/jpeg, image/png
              second_image:
                contentType: image/jpeg, image/png
      responses:
        '200':
          description: |
            Comparison finished and billed (`pricing=PAID`).
            `SUCCESS` includes `data` (boxes + similarity). Detection
            codes (`FACE_NOT_FOUND` and related) have no `data`.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/FaceCompareResponse'
                  - $ref: '#/components/schemas/FaceComparePaidOutcome'
              examples:
                success:
                  summary: Scored comparison
                  value:
                    code: SUCCESS
                    message: OK
                    data:
                      first_face:
                        id: '1'
                        left: 854.12
                        top: 198.9
                        right: 1008.29
                        bottom: 389.44
                        gender: FEMALE
                      second_face:
                        id: '2'
                        left: 107.53
                        top: 148.77
                        right: 375.04
                        bottom: 536.12
                        gender: FEMALE
                      similarity: 20.18
                    request_id: TXN-20260727-001
                    pricing: PAID
                face_not_found:
                  summary: No face detected (still billed)
                  value:
                    code: FACE_NOT_FOUND
                    message: No Face detected from the uploaded pictures
                    request_id: TXN-20260727-001
                    pricing: PAID
        '400':
          description: |
            `CLIENT_ERROR` — the request is invalid (empty `request_id`,
            missing image, invalid base64, image larger than 5 MB,
            not JPEG/PNG, a malformed multipart body) or the vendor
            rejected face quality (`FACE_QUALITY_NOT_QUALIFIED`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
        '401':
          description: |
            `UNAUTHORIZED` — missing or invalid token, this feature is
            not enabled on your account, or your IP is not on the
            credential allowlist.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
        '402':
          description: |
            `INSUFFICIENT_BALANCE` — your prepaid balance is below the
            fee for this call. Top up from the dashboard.
            `CREDIT_LIMIT_EXCEEDED` — postpaid Running Tab credit ceiling
            would be exceeded. Wait for the next cycle or raise the limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
        '409':
          description: |
            `DUPLICATE_REFERENCE` — this `request_id` was already used
            with different images.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
        '500':
          description: |
            `INTERNAL_ERROR` — an unexpected error occurred. You were not
            charged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
        '502':
          description: |
            `SERVER_ERROR` — the comparison could not be completed. You
            were not charged. Retrying the same `request_id` runs the
            comparison again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
components:
  schemas:
    FaceCompareRequest:
      type: object
      required:
        - request_id
        - first_image
        - second_image
      properties:
        request_id:
          type: string
          description: |
            Your unique reference for this comparison. Use a new UUID for
            each new check. Sending the same key with the same images
            returns the original result and is billed once.
          example: TXN-20260727-001
        first_image:
          type: string
          description: |
            First image (typically a selfie) as raw base64 (JPEG or PNG).
            No line breaks and no `data:image/…` prefix. Maximum 5 MB
            after decoding.
          example: /9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAg...
        second_image:
          type: string
          description: |
            Second image (typically an ID-card portrait) as raw base64
            (JPEG or PNG). No line breaks and no `data:image/…` prefix.
            Maximum 5 MB after decoding.
          example: >-
            iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
    FaceCompareResponse:
      type: object
      required:
        - code
        - message
        - data
        - request_id
        - pricing
      properties:
        code:
          type: string
          enum:
            - SUCCESS
          description: >-
            A scored comparison finished. Bounding boxes and similarity are in
            `data`.
          example: SUCCESS
        message:
          type: string
          enum:
            - OK
          description: Always `OK`. Use `code`, not this field, to branch.
          example: OK
        data:
          $ref: '#/components/schemas/FaceCompareData'
        request_id:
          type: string
          description: The `request_id` you sent.
          example: TXN-20260727-001
        pricing:
          type: string
          enum:
            - PAID
          description: A scored comparison is always billed.
          example: PAID
    FaceComparePaidOutcome:
      type: object
      required:
        - code
        - message
        - request_id
        - pricing
      description: |
        The vendor finished the compare but did not produce a similarity
        score (no face / multiple faces). HTTP 200, `pricing=PAID`, no `data`.
      properties:
        code:
          type: string
          enum:
            - FACE_NOT_FOUND
            - FIRST_FACE_NOT_FOUND
            - SECOND_FACE_NOT_FOUND
            - MULTIPLE_FACES_DETECTED
          example: FACE_NOT_FOUND
        message:
          type: string
          example: No Face detected from the uploaded pictures
        request_id:
          type: string
          example: TXN-20260727-001
        pricing:
          type: string
          enum:
            - PAID
          example: PAID
    FailureResponse:
      type: object
      required:
        - code
        - message
        - request_id
        - pricing
      description: |
        Error response for e-wallet, bank, face-comparison, KTP OCR, and
        ID Forgery Check requests.
        These HTTP 4xx/5xx failures are never billed (`pricing=FREE`).
        Face-comparison detection codes (`FACE_NOT_FOUND` and related) are
        HTTP 200 with `pricing=PAID` and use a different schema.
      properties:
        code:
          type: string
          enum:
            - DUPLICATE_REFERENCE
            - SERVER_ERROR
            - INTERNAL_ERROR
            - CLIENT_ERROR
            - UNAUTHORIZED
            - INSUFFICIENT_BALANCE
            - CREDIT_LIMIT_EXCEEDED
          description: |
            What went wrong. Use this field to decide how to retry or
            surface the error.
          example: CLIENT_ERROR
        message:
          type: string
          description: Human-readable explanation.
          example: Error message
        request_id:
          type: string
          description: |
            The `request_id` you sent, when we could read it. Empty if the
            body could not be parsed.
          example: 8b1d6f3e-9a02-4c5d-9f7a-2c8e1b3d4f5a
        pricing:
          type: string
          enum:
            - FREE
          description: Failures are never billed.
          example: FREE
    FaceCompareData:
      type: object
      required:
        - first_face
        - second_face
        - similarity
      properties:
        first_face:
          description: |
            Bounding box of the face detected in `first_image` (vendor
            `firstFace`), including `gender` when the vendor returned it.
            `null` when the vendor did not return a box.
          nullable: true
          allOf:
            - $ref: '#/components/schemas/FaceBox'
        second_face:
          description: |
            Bounding box of the face detected in `second_image` (vendor
            `secondFace`), including `gender` when the vendor returned it.
            `null` when the vendor did not return a box.
          nullable: true
          allOf:
            - $ref: '#/components/schemas/FaceBox'
        similarity:
          type: number
          format: double
          minimum: 0
          maximum: 100
          description: Similarity score (0–100) as returned by the vendor.
          example: 20.18
    FaceBox:
      type: object
      required:
        - id
        - left
        - top
        - right
        - bottom
      properties:
        id:
          type: string
          description: Vendor face identifier for this detection.
          example: '1'
        left:
          type: number
          format: double
          example: 854.12
        top:
          type: number
          format: double
          example: 198.9
        right:
          type: number
          format: double
          example: 1008.29
        bottom:
          type: number
          format: double
          example: 389.44
        gender:
          type: string
          enum:
            - MALE
            - FEMALE
          description: |
            Detected gender from the vendor (`attributes.Gender.Type`:
            `0` = male, `1` = female). Omitted when the vendor did not
            return a recognised value.
          example: FEMALE
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Access token from `POST /api/v1/kyc/auth/get-auth-token`.
        Valid for one hour.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.