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

# ID Forgery Check

> Assesses whether a photo of an Indonesian ID card (KTP) is a
forgery / presentation-attack (photocopy, photograph of the
original, edited image, or failed information checks).

A completed assessment is billed (`pricing=PAID`), including when
`data.is_forged` is `TRUE`. Validation failures 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 |
|---|---|
| Assessment completed | `code=SUCCESS`, `data.is_forged` / `data.reason`, `pricing=PAID` |
| Missing `request_id` / image, both `image_url` and `image_base64`, or wrong image format | `code=CLIENT_ERROR`, `pricing=FREE` (HTTP 400) |
| Image exceeds 8MB | `code=CLIENT_ERROR`, `pricing=FREE` (HTTP 413) |
| Temporary service issue | `code=SERVER_ERROR`, `pricing=FREE` |
| Same `request_id` reused after a **completed** check | `code=DUPLICATE_REFERENCE`, `pricing=FREE` (HTTP 409) |
| Same `request_id` reused after a **failed** attempt | Retried for real: the image you send this time is what actually gets processed and (if it succeeds) billed |
| 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, 8 MB or smaller. Send the photo either as:

- `application/json` — `request_id` plus exactly one of `image_url`
  (HTTPS URL fetched by the engine) or `image_base64` (raw base64,
  no line breaks, no `data:image/…` prefix)
- `multipart/form-data` — `request_id` as a text field, `image` as
  a file part

Do not mix the two encodings in one request.

### `data.reason` mapping

| `is_forged` | `reason` |
|---|---|
| `FALSE` | `PASS` |
| `TRUE` | Known labels joined with `"; "` — photocopy, photograph of the original, edited, format validation failed, consistency check failed. Empty string when the engine returned no mapped codes. |

Vendor warning codes are never returned to merchants.

### Idempotency

Use a unique `request_id` for each new check (UUID v4 is
recommended). Reusing a `request_id` that already resolved to a
**completed** check always returns `DUPLICATE_REFERENCE`. Reusing a
`request_id` whose prior attempt **failed** retries for real.




## OpenAPI

````yaml https://core.singapay.id/identity-verification/docs/swagger.json post /api/v1/kyc/id-forgery-check
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/id-forgery-check:
    post:
      tags:
        - ID Forgery Check
      summary: ID Forgery Check
      description: >
        Assesses whether a photo of an Indonesian ID card (KTP) is a

        forgery / presentation-attack (photocopy, photograph of the

        original, edited image, or failed information checks).


        A completed assessment is billed (`pricing=PAID`), including when

        `data.is_forged` is `TRUE`. Validation failures 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 |

        |---|---|

        | Assessment completed | `code=SUCCESS`, `data.is_forged` /
        `data.reason`, `pricing=PAID` |

        | Missing `request_id` / image, both `image_url` and `image_base64`, or
        wrong image format | `code=CLIENT_ERROR`, `pricing=FREE` (HTTP 400) |

        | Image exceeds 8MB | `code=CLIENT_ERROR`, `pricing=FREE` (HTTP 413) |

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

        | Same `request_id` reused after a **completed** check |
        `code=DUPLICATE_REFERENCE`, `pricing=FREE` (HTTP 409) |

        | Same `request_id` reused after a **failed** attempt | Retried for
        real: the image you send this time is what actually gets processed and
        (if it succeeds) billed |

        | 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, 8 MB or smaller. Send the photo either as:


        - `application/json` — `request_id` plus exactly one of `image_url`
          (HTTPS URL fetched by the engine) or `image_base64` (raw base64,
          no line breaks, no `data:image/…` prefix)
        - `multipart/form-data` — `request_id` as a text field, `image` as
          a file part

        Do not mix the two encodings in one request.


        ### `data.reason` mapping


        | `is_forged` | `reason` |

        |---|---|

        | `FALSE` | `PASS` |

        | `TRUE` | Known labels joined with `"; "` — photocopy, photograph of
        the original, edited, format validation failed, consistency check
        failed. Empty string when the engine returned no mapped codes. |


        Vendor warning codes are never returned to merchants.


        ### Idempotency


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

        recommended). Reusing a `request_id` that already resolved to a

        **completed** check always returns `DUPLICATE_REFERENCE`. Reusing a

        `request_id` whose prior attempt **failed** retries for real.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IdForgeryCheckRequest'
            examples:
              image_url:
                summary: Check via image URL
                value:
                  request_id: TXN-20260917-001
                  image_url: https://cdn.example.com/ktp.jpg
              image_base64:
                summary: Check via base64 image
                value:
                  request_id: TXN-20260917-001
                  image_base64: >-
                    iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
          multipart/form-data:
            schema:
              type: object
              required:
                - request_id
                - image
              properties:
                request_id:
                  type: string
                  description: |
                    Your unique reference for this check. Use a new UUID
                    for each new attempt.
                  example: TXN-20260917-001
                image:
                  type: string
                  format: binary
                  description: |
                    Photo of the ID card. JPEG or PNG only, 8MB maximum.
            encoding:
              image:
                contentType: image/jpeg, image/png
      responses:
        '200':
          description: |
            Assessment complete. `code` is `SUCCESS`. `data` holds
            `is_forged` and `reason`. `pricing` is `PAID`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IdForgeryCheckResponse'
              examples:
                pass:
                  summary: Document not flagged as forged
                  value:
                    code: SUCCESS
                    message: OK
                    data:
                      is_forged: false
                      reason: PASS
                    request_id: TXN-20260917-001
                    pricing: PAID
                forged:
                  summary: Document flagged (one or more reasons)
                  value:
                    code: SUCCESS
                    message: OK
                    data:
                      is_forged: true
                      reason: Document is a photocopy; Document is edited
                    request_id: TXN-20260917-001
                    pricing: PAID
        '400':
          description: |
            `CLIENT_ERROR` — the request is invalid (empty `request_id`,
            missing image, both `image_url` and `image_base64`, invalid
            base64, not JPEG/PNG, or a malformed multipart body).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
              examples:
                validation:
                  summary: Missing/invalid request field
                  value:
                    code: CLIENT_ERROR
                    message: image_url or image_base64 is required
                    request_id: TXN-20260917-001
                    pricing: FREE
        '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'
              example:
                code: UNAUTHORIZED
                message: You don't have authorization to access.
                request_id: TXN-20260917-001
                pricing: FREE
        '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'
              example:
                code: INSUFFICIENT_BALANCE
                message: Balance is not enough, please top up
                request_id: TXN-20260917-001
                pricing: FREE
        '409':
          description: |
            `DUPLICATE_REFERENCE` — this `request_id` already resolved to a
            completed check. Mint a new `request_id` to check again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
              example:
                code: DUPLICATE_REFERENCE
                message: Duplicate reference number
                request_id: TXN-20260917-001
                pricing: FREE
        '413':
          description: |
            `CLIENT_ERROR` — the uploaded image (or the whole request
            body) exceeds 8MB.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
              example:
                code: CLIENT_ERROR
                message: image must not exceed 8MB
                request_id: TXN-20260917-001
                pricing: FREE
        '429':
          description: |
            You have exceeded the rate limit. Wait for the number of
            seconds in `Retry-After` before retrying.
          headers:
            Retry-After:
              schema:
                type: integer
                minimum: 1
              description: Seconds to wait before retrying.
            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/FailureResponse'
        '500':
          description: |
            `INTERNAL_ERROR` — an unexpected error occurred. You were not
            charged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
              example:
                code: INTERNAL_ERROR
                message: Error, please try again later
                request_id: TXN-20260917-001
                pricing: FREE
        '502':
          description: |
            `SERVER_ERROR` — the OCR engine could not be reached or returned
            an unusable response. You were not charged.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
              example:
                code: SERVER_ERROR
                message: Error, please try again later
                request_id: TXN-20260917-001
                pricing: FREE
components:
  schemas:
    IdForgeryCheckRequest:
      type: object
      required:
        - request_id
      description: |
        Send exactly one of `image_url` or `image_base64`. Sending both, or
        neither, is `CLIENT_ERROR`.
      properties:
        request_id:
          type: string
          description: |
            Your unique reference for this check. Use a new UUID for each
            new attempt. Reusing a `request_id` that already completed
            returns `DUPLICATE_REFERENCE`.
          example: TXN-20260917-001
        image_url:
          type: string
          format: uri
          description: |
            Public HTTPS URL of the ID-card photo. JPEG or PNG. Mutually
            exclusive with `image_base64`. The image is fetched by the OCR
            engine; we do not store it.
          example: https://cdn.example.com/ktp.jpg
        image_base64:
          type: string
          description: |
            ID-card photo as raw base64 (JPEG or PNG). No line breaks and
            no `data:image/…` prefix. Maximum 8 MB after decoding.
            Mutually exclusive with `image_url`.
          example: >-
            iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==
    IdForgeryCheckResponse:
      type: object
      required:
        - code
        - message
        - data
        - request_id
        - pricing
      properties:
        code:
          type: string
          enum:
            - SUCCESS
          description: Always `SUCCESS` on this response.
          example: SUCCESS
        message:
          type: string
          enum:
            - OK
          description: Always `OK`. Use `code` and `data`, not this field, to branch.
          example: OK
        data:
          $ref: '#/components/schemas/IdForgeryCheckData'
        request_id:
          type: string
          description: The `request_id` you sent.
          example: TXN-20260917-001
        pricing:
          type: string
          enum:
            - PAID
          description: A completed check is always billed.
          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
    IdForgeryCheckData:
      type: object
      description: |
        Merchant-facing assessment. Vendor warning codes are not returned;
        they are mapped to `is_forged` and `reason` only.
      required:
        - is_forged
        - reason
      properties:
        is_forged:
          type: string
          enum:
            - true
            - false
          description: |
            `FALSE` when the engine did not flag the document as forged
            (`is_ps=false`). `TRUE` when it did (`is_ps=true`).
          example: false
        reason:
          type: string
          description: |
            `PASS` when `is_forged` is `FALSE`. When `is_forged` is `TRUE`,
            known reason labels joined with `"; "` (empty string when the
            engine returned no mapped codes).
          example: PASS
  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.