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

# OCR KTP Check

> Reads the fields printed on an Indonesian KTP (ID card) from an
uploaded photo. A completed extraction is billed (`pricing=PAID`).
Validation failures, an image the OCR engine cannot read as an ID
card, 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 |
|---|---|
| Extraction succeeded | `code=SUCCESS`, `pricing=PAID` |
| Image isn't a recognizable ID card (per the OCR engine) | `code=CLIENT_ERROR`, `pricing=FREE` |
| Extracted `id_number` isn't a valid 16-digit NIK | `code=CLIENT_ERROR`, `pricing=FREE` |
| Missing `request_id` / `image`, 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** extraction | `code=DUPLICATE_REFERENCE`, `pricing=FREE` (HTTP 409) — always, regardless of what image you send this time |
| 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` |

### Image requirements

JPEG or PNG only (checked against the actual bytes, not just the
declared `Content-Type`), 8MB maximum.

### Idempotency

Use a unique `request_id` for each new extraction (UUID v4 is
recommended). Reusing a `request_id` that already resolved to a
**completed** extraction always returns `DUPLICATE_REFERENCE`,
regardless of what image you send this time — mint a new `request_id`
for every extraction attempt. Reusing a `request_id` whose prior
attempt **failed** retries for real — the image you send this time is
what actually gets processed and (if it succeeds) billed.




## OpenAPI

````yaml https://core.singapay.id/identity-verification/docs/swagger.json post /api/v1/kyc/ocr-ktp-verify
openapi: 3.0.3
info:
  title: Singapay Identity Verification API
  description: |
    Verify e-wallet accounts, bank accounts, and ID photos 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.
paths:
  /api/v1/kyc/ocr-ktp-verify:
    post:
      tags:
        - OCR
      summary: OCR KTP Check
      description: >
        Reads the fields printed on an Indonesian KTP (ID card) from an

        uploaded photo. A completed extraction is billed (`pricing=PAID`).

        Validation failures, an image the OCR engine cannot read as an ID

        card, 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 |

        |---|---|

        | Extraction succeeded | `code=SUCCESS`, `pricing=PAID` |

        | Image isn't a recognizable ID card (per the OCR engine) |
        `code=CLIENT_ERROR`, `pricing=FREE` |

        | Extracted `id_number` isn't a valid 16-digit NIK |
        `code=CLIENT_ERROR`, `pricing=FREE` |

        | Missing `request_id` / `image`, 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** extraction |
        `code=DUPLICATE_REFERENCE`, `pricing=FREE` (HTTP 409) — always,
        regardless of what image you send this time |

        | 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`
        |


        ### Image requirements


        JPEG or PNG only (checked against the actual bytes, not just the

        declared `Content-Type`), 8MB maximum.


        ### Idempotency


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

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

        **completed** extraction always returns `DUPLICATE_REFERENCE`,

        regardless of what image you send this time — mint a new `request_id`

        for every extraction attempt. Reusing a `request_id` whose prior

        attempt **failed** retries for real — the image you send this time is

        what actually gets processed and (if it succeeds) billed.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required:
                - request_id
                - image
              properties:
                request_id:
                  type: string
                  description: |
                    Your unique reference for this extraction. Use a new
                    UUID for each new attempt.
                  example: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                image:
                  type: string
                  format: binary
                  description: |
                    Photo of the KTP. JPEG or PNG only, 8MB maximum.
            encoding:
              image:
                contentType: image/jpeg, image/png
      responses:
        '200':
          description: |
            Extraction complete. `code` is `SUCCESS`. `data` holds the
            extracted fields. `pricing` is `PAID`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KtpVerifyResponse'
              examples:
                extracted:
                  summary: Extraction succeeded
                  value:
                    code: SUCCESS
                    message: OK
                    data:
                      request_id: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                      id_number: '3175012345670001'
                      name: Budi Santoso
                      blood_type: O
                      religion: ISLAM
                      gender: LAKI-LAKI
                      birth_place_birthday: JAKARTA, 17-08-1990
                      province: DKI JAKARTA
                      city: JAKARTA SELATAN
                      district: KEBAYORAN BARU
                      village: GUNUNG
                      rtrw: 003/004
                      occupation: KARYAWAN SWASTA
                      expiry_date: SEUMUR HIDUP
                      nationality: WNI
                      marital_status: KAWIN
                      address: JL. SENOPATI RAYA NO. 10
                      place_of_birth: JAKARTA
                      birthday: 17-08-1990
                      issue_date: 12-03-2019
                      place_of_issue: JAKARTA SELATAN
                    request_id: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                    pricing: PAID
        '400':
          description: |
            `CLIENT_ERROR` — the request is invalid (empty `request_id`,
            missing or wrong-format image, malformed multipart body), the
            OCR engine could not find a recognizable ID card in the image,
            or the extracted `id_number` isn't a valid 16-digit NIK.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
              examples:
                validation:
                  summary: Missing/invalid request field
                  value:
                    code: CLIENT_ERROR
                    message: image is required
                    request_id: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                    pricing: FREE
                not_a_card:
                  summary: Image isn't a recognizable ID card
                  value:
                    code: CLIENT_ERROR
                    message: >-
                      uploaded image does not appear to contain a recognizable
                      Indonesian ID card
                    request_id: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                    pricing: FREE
                invalid_nik:
                  summary: Extracted id_number isn't a valid NIK
                  value:
                    code: CLIENT_ERROR
                    message: extracted id_number is not a valid 16-digit NIK
                    request_id: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                    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: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                pricing: FREE
        '402':
          description: |
            `INSUFFICIENT_BALANCE` — your prepaid balance is below the
            fee for this call. Top up from the dashboard.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
              example:
                code: INSUFFICIENT_BALANCE
                message: Balance is not enough, please top up
                request_id: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                pricing: FREE
        '409':
          description: |
            `DUPLICATE_REFERENCE` — this `request_id` already resolved to a
            completed extraction. Mint a new `request_id` to extract again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FailureResponse'
              example:
                code: DUPLICATE_REFERENCE
                message: Duplicate reference number
                request_id: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                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: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                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: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                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: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
                pricing: FREE
components:
  schemas:
    KtpVerifyResponse:
      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/KtpVerifyData'
        request_id:
          type: string
          description: The `request_id` you sent.
          example: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
        pricing:
          type: string
          enum:
            - PAID
          description: Always `PAID` on this response.
          example: PAID
    FailureResponse:
      type: object
      required:
        - code
        - message
        - request_id
        - pricing
      description: >
        Error response for e-wallet, bank, face-comparison, and KTP OCR
        requests.

        Failures are never billed (`pricing=FREE`).
      properties:
        code:
          type: string
          enum:
            - DUPLICATE_REFERENCE
            - SERVER_ERROR
            - INTERNAL_ERROR
            - CLIENT_ERROR
            - UNAUTHORIZED
            - INSUFFICIENT_BALANCE
          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
    KtpVerifyData:
      type: object
      description: |
        Extracted KTP fields. Every value is a plain string, printed on
        the card verbatim (uppercase, Indonesian). A field the OCR engine
        could not read comes back as `""`, never omitted or `null` — this
        is not a matching verdict like e-wallet/bank's `VerifyData`, so
        there is no `status` / `similarity` / `suggestion` here.
      required:
        - request_id
        - id_number
        - name
        - blood_type
        - religion
        - gender
        - birth_place_birthday
        - province
        - city
        - district
        - village
        - rtrw
        - occupation
        - expiry_date
        - nationality
        - marital_status
        - address
        - place_of_birth
        - birthday
        - issue_date
        - place_of_issue
      properties:
        request_id:
          type: string
          description: The `request_id` you sent.
          example: c3d4e5f6-1a2b-3c4d-5e6f-7a8b9c0d1e2f
        id_number:
          type: string
          description: >-
            NIK. Guaranteed 16 digits when `code=SUCCESS` — a non-conforming
            read is rejected as `CLIENT_ERROR` instead.
          example: '3175012345670001'
        name:
          type: string
          example: Budi Santoso
        blood_type:
          type: string
          example: O
        religion:
          type: string
          example: ISLAM
        gender:
          type: string
          example: LAKI-LAKI
        birth_place_birthday:
          type: string
          description: The card's combined place-and-date-of-birth field, verbatim.
          example: JAKARTA, 17-08-1990
        province:
          type: string
          example: DKI JAKARTA
        city:
          type: string
          description: >-
            Same value as `place_of_issue` — see the field notes on the verify
            endpoint.
          example: JAKARTA SELATAN
        district:
          type: string
          example: KEBAYORAN BARU
        village:
          type: string
          example: GUNUNG
        rtrw:
          type: string
          example: 003/004
        occupation:
          type: string
          example: KARYAWAN SWASTA
        expiry_date:
          type: string
          example: SEUMUR HIDUP
        nationality:
          type: string
          example: WNI
        marital_status:
          type: string
          example: KAWIN
        address:
          type: string
          example: JL. SENOPATI RAYA NO. 10
        place_of_birth:
          type: string
          description: >-
            Derived by splitting `birth_place_birthday` on its comma. `""` if
            that field wasn't comma-separated.
          example: JAKARTA
        birthday:
          type: string
          description: >-
            Derived by splitting `birth_place_birthday` on its comma. `""` if
            that field wasn't comma-separated.
          example: 17-08-1990
        issue_date:
          type: string
          example: 12-03-2019
        place_of_issue:
          type: string
          description: Same value as `city` — see the field notes on the verify endpoint.
          example: JAKARTA SELATAN
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Access token from `POST /api/v1/kyc/auth/get-auth-token`.
        Valid for one hour.

````