API Reference
eKYC Open API
Identity verification flows, verification results, and webhooks.
1 Introduction
1.1 Overview
The DTC eKYC OpenAPI is a RESTful JSON-based web service that allows third parties to integrate DTC eKYC solutions into their platforms.
This specification defines a set of interfaces and security standards.
1.2 Specification Titles
-
Name: The JSON field name in the request or response.
-
Type: The JSON type of the field.
- String, Integer, Long, Enum (an integer ID unless stated otherwise), Object, Array.
-
Required:
- M — Mandatory. Validated by the server.
- C — Conditional. Mandatory only under the condition stated in the Description column.
- O — Optional.
-
Description: The meaning, allowed values, or format of the field.
1.3 Security Standards
- HTTPS: TLS 1.2, mandatory for every connection.
- Whitelist: Optional IP whitelist, configurable on the
API Key Managementpage. - OAuth 2.0: Request session management.
- Signature: Request integrity checking.
2 Quick Start
2.1 Disclaimer
Use of these APIs is subject to dtcpay's review and approval. dtcpay reserves the right, at its sole discretion, to approve, restrict, suspend, or revoke access at any time.
2.2 Before Integration
- Request account registration on the DTC Wallet platform.
- Log in with the username/password issued by the DTC team and go to the
API Key Managementpage. - Click
+ Createto open the dialog, and choose Wallet / Card API (not Payments API — that's a separate credential for the Payment Open API). - Enter the Name and IP Whitelist, then submit.
- The
API Key,API Secret, andSign Keyare generated and shown once. Keep them safe before closing the dialog. - Request domain names — see Fetch Access Token below.
2.3 OAuth 2.0 Authentication
2.3.1 Usage
Fetch an access token first, then add Authorization: Bearer {access_token} to every other request.
If the token expires, re-fetch it, or use Fetch Access Token by Refresh Token.
NOTE: Only one access token can exist at a time per client_id. Cache it (e.g. in Redis) rather than fetching a fresh one per request.
2.3.2 Fetch Access Token
Host
- Sbx test environment:
https://open-api.sbx.dtcpayment.net - Production environment:
https://open-api.dtcpay.com
Request Body Sample
curl -X "POST" "https://{host}/auth/v1/oauth2/token" \
-H "Content-Type: multipart/form-data; charset=utf-8; boundary=__X_PAW_BOUNDARY__" \
-F "client_id=xxx" \
-F "client_secret=xxx" \
-F "grant_type=client_credentials"The client_id/client_secret are obtained via the web portal at https://business.dtcpay.com/login — navigate to the API menu and create a Wallet API to generate the credentials.
Response Body Sample
{
"access_token": "CkppLkyiqEUKITGdtCZRUZBl",
"expires_in": 21600,
"refresh_token": "CpCAhcCRtLU4kPjs54omWW3A",
"rt_expires_in": 43200,
"token_type": "bearer"
}NOTE:
Access denied(errCode 00006) usually means aclient_id/client_secretmismatch, or the calling IP does not match the whitelist configured for the API key.
2.3.3 Fetch Access Token by Refresh Token
Request Body Sample
curl -X "POST" "https://{host}/openapi/auth/oauth2/token" \
-H "Content-Type: multipart/form-data; charset=utf-8; boundary=__X_PAW_BOUNDARY__" \
-F "refresh_token=CpCAhcCRtLU4kPjs54omWW3A" \
-F "grant_type=refresh_token"Response Body Sample — same shape as Fetch Access Token, with fresh access_token/refresh_token.
2.4 Request Signature
For every request, a signature is mandatory in the HTTP header — this is enforced unconditionally for every CLIENT-token-authenticated call (WalletOauthInterceptor.checkSignature), not just a "sensitive" subset.
-
Components of the string to sign:
HTTP Method— uppercase.Timestamp— Timestamp number in millis, Singapore timezone. The request will be failed if the timestamp is before or after 2 minutes.URL— the request path, no scheme/host.Querystring— in-URL parameters without the leading?, un-encoded.Request Body— the exact raw body string, empty if none. Must match byte-for-byte, including invisible characters.
-
Concatenate in order:
Method + Timestamp + URL + ('?' + Querystring if present) + Body.
GET1636360576641/ekyc/v1/verification-result/demo_12345
POST1636360661729/ekyc/v1/get-verification-url{"query":{"externalId":"demo_12345"}}-
Sign the string with HMAC-SHA512 using the
Sign Keyfrom Before Integration, then Base64-encode the result. -
Attach headers to the request:
D-TIMESTAMP— Timestamp number in millis, Singapore timezone. The request will be failed if the timestamp is before or after 2 minutes.D-SIGNATURE— the generated signature.
NOTE: The server reads the raw body directly from the HTTP request stream for signature verification. The body used to compute the signature must be byte-identical to the body actually sent — any difference (an extra space, a different key order after re-serialization) breaks verification. Sign the exact same string you send, don't re-serialize a JSON object.
2.4.1 Example: POST Request (Java)
import com.google.common.hash.Hashing;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class SignatureTestGuavaPOST {
public static void main(String[] args) {
String signKey = "4mt0MUecOtOEDI42WjN4BmM0";
String method = "POST";
String uri = "/ekyc/v1/get-verification-url";
String query = "";
String body = "{\"query\":{\"externalId\":\"demo_12345\",\"email\":\"user@example.com\",\"mobile\":\"+60 123456789\",\"countryOfResidence\":\"SGP\",\"targetKycLevel\":100}}";
String timestamp = String.valueOf(System.currentTimeMillis());
String stringToSign = method + timestamp + uri;
if (!query.isEmpty()) {
stringToSign += "?" + query;
}
stringToSign += body;
String signature = Base64.getEncoder().encodeToString(
Hashing.hmacSha512(signKey.getBytes(StandardCharsets.UTF_8))
.hashBytes(stringToSign.getBytes(StandardCharsets.UTF_8))
.asBytes()
);
System.out.println("Timestamp : " + timestamp);
System.out.println("StringToSign: " + stringToSign);
System.out.println("Signature : " + signature);
}
}3 API
3.1 Message Structure
Every request body:
{
"query": {}
}Paginated requests additionally carry:
{
"query": {},
"page": {
"current": 1,
"size": 10
}
}Responses come in three shapes depending on the endpoint:
Single object
{
"header": {
"success": true,
"errCode": null,
"errMsg": null
},
"result": {}
}Plain list (no pagination — used by a small number of endpoints not covered in this document)
{
"header": {
"success": true
},
"resultList": []
}Paginated list
{
"header": {
"success": true
},
"resultList": [],
"pagination": {
"current": 1,
"size": 10,
"total": 100
}
}Failure:
{
"header": {
"success": false,
"errCode": "31005",
"errMsg": "Card Information is invalid"
}
}3.2 eKYC Process
This is used to obtain the eKYC verification URL, then poll (or receive a webhook for) the verification result.
3.2.1 Get Verification URL
Endpoint
[POST] /ekyc/v1/get-verification-url
Description
Creates (or resumes, if externalId was already registered by the same caller) an applicant's eKYC session and returns an H5 verification URL for them to complete identity verification.
Request Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| externalId | String | M | Unique applicant identifier as registered on your system. If repeated, no new data is created — the existing applicant's target level is updated instead (see below). |
| String | M | The applicant's email address. Example: user@example.com | |
| mobile | String | M | The applicant's mobile number. Must match ^\+\d{1,3} \d{4,14}$ — a + country calling code (1-3 digits) followed by exactly one space, then the subscriber number (4-14 digits). Confirmed on stg: both "91234567" (no country code) and "+6591234567" (no space) are rejected with 50001; only "+65 91234567" (with the space) succeeds. Example: +60 123456789 |
| countryOfResidence | String | M | Country code representing the applicant's country of residence. Format: ISO 3166-1 alpha-3 (e.g. SGP, MYS, USA). |
| targetKycLevel | Enum | M | Target KYC level for the applicant. Refer to Appendix C — ExternalKycLevel for the full value list. Use 100 (BASIC) for a first-time/standard onboarding (passport + proof-of-address + selfie); use 200 (STANDARD) when the applicant needs a higher transaction limit than Tier 1 (adds a transaction-volume questionnaire); use 900 (ADVANCED) when the applicant needs stablecoin/crypto services, which are only unlocked at Tier 3. |
| reverseSolicitation | String | C | Declares whether this applicant falls under a "reverse solicitation" scenario: T = confirmed reverse solicitation (the client proactively initiated and accepted the service), F = not a reverse solicitation case. Whether this field is required is decided per institution, per country, by an admin-configured setting in the DTC back-office (InstitutionConfig.rsCountryCodes, a country → "T"/"F" map maintained on your API key's institution record): if that map marks countryOfResidence as requiring RS declaration, this field becomes mandatory and F is rejected (50013); if the country is not marked (or the institution has no such config at all), this field is not enforced and can be omitted. This is unrelated to the separate country-of-residence whitelist below. |
| eKycVerifyType | Enum | O | eKYC verification type. Only meaningful when your institution's verification channel (admin-configured, not caller-controlled) is ADVANCE_AI — ignored otherwise. |
| country | String | O | Country (alpha-2 ISO code) used by the verification channel. Only meaningful for the ADVANCE_AI channel — ignored otherwise. |
| language | String | O | Preferred language for the verification page. Only meaningful for the ADVANCE_AI channel — ignored otherwise. Example: en |
| successRedirectUrl | String | C | Redirect URL upon successful verification. Only meaningful for the ADVANCE_AI channel, where it is required (00010 if missing) — ignored for other channels. |
| failureRedirectUrl | String | C | Redirect URL upon verification failure. Only meaningful for the ADVANCE_AI channel, where it is required (00010 if missing) — ignored for other channels. |
Country-of-residence whitelist: separately from reverse solicitation, an institution can also be configured (again in the DTC back-office,
InstitutionConfig.corWhitelist) with a whitelist of allowedcountryOfResidencevalues. When configured and non-empty, acountryOfResidenceoutside that whitelist is rejected with00025before any other validation — this is a different, independent admin setting from the reverse-solicitation map above.
Request Body Sample
{
"query": {
"externalId": "demo_12345",
"email": "user@example.com",
"mobile": "+60 123456789",
"countryOfResidence": "SGP",
"targetKycLevel": 100
}
}Response Parameters
| Name | Type | Description |
|---|---|---|
| url | String | The H5 page used for authentication. Sbx environment: expires in 10 minutes. Production: expires in 12 hours — see urlExpiredTime for the exact instant. |
| urlExpiredTime | String (yyyy-MM-dd HH:mm:ss, SGT) | The exact expiry instant of url, e.g. 2026-09-10 22:00:00. After this time the applicant must call this endpoint again to get a fresh URL. |
| externalId | String | Echoes back the request's externalId. Only populated on the ADVANCE_AI channel. |
| eKycVerifyType | Enum | Echoes back the request's eKycVerifyType. Only populated on the ADVANCE_AI channel. |
| requestId | String | Third-party verification session ID. Only populated on the ADVANCE_AI channel. |
Response Body Sample
{
"header": {
"success": true
},
"result": {
"url": "https://h5.example.com/openapi/ekyc-h5/?token=eyJhbGciOi...",
"urlExpiredTime": "2026-09-10 22:00:00"
}
}Possible Error Codes
| Code | Description | When it happens |
|---|---|---|
| 00010 | Invalid parameters | reverseSolicitation missing or not one of T/F for a country where the institution requires it (see reverseSolicitation above — an explicit F is a different code, 50013 below); or (ADVANCE_AI channel only) successRedirectUrl/failureRedirectUrl/externalId missing |
| 00025 | Sorry, our services are currently unavailable in your country or region. | countryOfResidence is not on the institution's configured country-of-residence whitelist |
| 01049 | Your account is invalid. Please contact our support for further inquiries. | externalId already resolves to an existing client whose account status is neither PENDING_KYC nor ACTIVATED |
| 50001 | Please provide mobile number in a correct format. | mobile missing, or fails ^\+\d{1,3} \d{4,14}$ validation (see mobile field above — most commonly a missing space between country code and number) |
| 50002 | Please provide email in a correct format. | email missing or fails format validation |
| 50003 | Please provide country of residence in a correct format. | countryOfResidence missing, or not a country configured to support SMS OTP |
| 50004 | Please provide externalId in a correct format. | externalId missing/blank |
| 50005 | Please provide targetKycLevel in a correct format. | targetKycLevel missing or not one of the ExternalKycLevel values |
| 50006 | Mobile number exist, please choose a different mobile number. | New applicant (externalId not seen before) but mobile is already used by another wallet user |
| 50007 | The applicant is currently undergoing verification. | Existing client's tier upgrade is already in progress and their proof-of-address is not currently marked invalid |
| 50008 | Email exist, please choose a different email. | New applicant (externalId not seen before) but email is already used by another wallet user |
| 50013 | Reverse solicitation not declared by end user. | Institution requires RS declaration for this countryOfResidence and reverseSolicitation was explicitly F |
| 00004 | Unknown API error | Institution has no channel configured for this institutionId, or an unexpected exception |
3.2.2 Get Verification Result
Endpoint
[GET] /ekyc/v1/verification-result/{externalId}
Description
Returns the current eKYC verification status and level for a previously registered applicant.
Request Parameters
| Name | Type | Required | Description |
|---|---|---|---|
| externalId | String | M | Unique applicant identifier as registered on your system (path parameter). |
Response Parameters
| Name | Type | Description |
|---|---|---|
| externalId | String | Echoes back the path's externalId. |
| clientId | Long | Unique applicant identifier as registered on DTC. This is a different concept from the OAuth client's client_id used to fetch the access token. |
| clientStatus | Enum | The client account status. Refer to Appendix C — ClientStatus. Distinct from verifyStatus (KYC progress) — this reflects the account's own status. Only populated once the applicant has an associated DTC client (clientId non-null). |
| nationality | String | Nationality of the applicant. Format: ISO 3166-1 alpha-3. |
| countryOfResidence | String | Country code representing the applicant's country of residence. Format: ISO 3166-1 alpha-3. |
| currentKycLevel | Enum | Current KYC level for the applicant. Refer to Appendix C — ExternalKycLevel. null until the applicant has been assigned a tier. |
| passportVerifyStatus | Enum | The KYC review status of the applicant's passport. Refer to Appendix C — EKycFileVerifyStatus. |
| passportVerifyFailReason | String | Reason the passport verification failed. Only set when passportVerifyStatus is a failure. |
| faceIdVerifyStatus | Enum | The KYC review status of the applicant's face ID. Refer to Appendix C — EKycFileVerifyStatus. |
| faceIdVerifyFailReason | String | Reason the Face ID verification failed. Only set when faceIdVerifyStatus is a failure. |
| proofOfAddressVerifyStatus | Enum | The KYC review status of the applicant's proof of address. Refer to Appendix C — EKycFileVerifyStatus. |
| proofOfAddressVerifyFailReason | String | Reason the proof-of-address verification failed. Only set when the status is a failure. |
| verifyStatus | Enum | Refer to Appendix C — EkycVerifyStatus. Known limitation: this field is currently always returned as null by this endpoint's implementation — tracked separately, not part of this review's scope. |
| updatedAt | String (yyyy-MM-dd HH:mm:ss) | Last-updated time of the underlying KYC record. null if the applicant has no associated DTC client yet. |
Response Body Sample
{
"header": {
"success": true
},
"result": {
"externalId": "demo_12345",
"clientId": 214234123552,
"clientStatus": 99,
"nationality": "SGP",
"countryOfResidence": "SGP",
"currentKycLevel": 100,
"passportVerifyStatus": 3,
"faceIdVerifyStatus": 3,
"proofOfAddressVerifyStatus": 3,
"verifyStatus": null,
"updatedAt": "2026-09-02 16:13:59"
}
}Possible Error Codes
| Code | Description | When it happens |
|---|---|---|
| 00006 | Access denied | No authenticated OAuth client resolved for this request |
| 50004 | Please provide externalId in a correct format. | No wallet user found for this externalId under either the caller-scoped or the raw external ID |
| 00004 | Unknown API error | Unexpected exception |
3.3 Webhook
3.3.1 Description
DTC supports webhook callbacks in various business scenarios. Customers can implement their own business logic in the callback interface.
The webhook URL is not part of any eKYC request — it is resolved server-side from your own institution's (the value configured for your API key on the API Key Management page), the same URL used by every other webhook this API sends you. To change it, update it on the API Key Management page.
Transport and Payload
DTC sends webhook callbacks using the HTTP POST method. The request body is formatted as JSON and encoded in UTF-8. The request Content-Type is application/json; charset=utf-8.
Acknowledgement and Failure Handling
Each webhook delivery attempt is considered successful only when all of the following conditions are met:
- The request is completed successfully.
- The endpoint returns HTTP status 200.
- The response body is exactly the plain-text string
OK, without quotation marks.
An attempt is considered unsuccessful if the request fails, times out, returns a non-200 status code, or returns an empty or different response body.
A webhook notification is marked as FAILED only after the initial delivery attempt and all configured retries have failed. A failed attempt that still has retries available is marked as RETRYING.
Delivery and Retry Model
Each delivery attempt is a synchronous HTTP request/response exchange. The callback endpoint must respond promptly and must not rely on fire-and-forget processing.
The initial delivery attempt is started during notification processing. If an attempt fails, DTC records the notification and schedules the next attempt asynchronously in the background. Therefore, the response from the notification submission API confirms that the notification was accepted for processing; it must not be interpreted as confirmation that the customer endpoint has successfully processed the callback.
DTC performs up to six automatic retries after the initial attempt, for a maximum of seven delivery attempts in total. The retry delay is applied after each failed attempt. Retry timing is best-effort and may be later than the stated interval because of scheduling or service availability.
Webhook endpoints should be idempotent because the same event may be delivered more than once.
Retry Intervals: 15s, 30s, 60s, 120s, 240s, 480s.
3.3.2 Signature Verification
The signature field in the webhook payload is generated using HMAC-SHA512 + Base64 encoding with the Sign Key from Before Integration, but not the same string-to-sign as Request Signature (that scheme is for inbound API calls; this is DTC signing an outbound webhook).
The string signed is POST + the exact webhook URL configured on the API Key Management page (the full URL, not a request path) + the data object only (never the whole envelope — event/clientId/signature are not part of the signed string), with data's JSON keys recursively sorted before signing. No timestamp is included.
Example: Webhook Signature Verification (Java)
import com.google.common.hash.Hashing;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
public class WebhookSignatureVerification {
public static void main(String[] args) {
String signKey = "4mt0MUecOtOEDI42WjN4BmM0";
String method = "POST";
String webhookUrl = "https://partner.example.com/webhooks/endpoint"; // exactly as configured in API Key Management
// `dataJson` must be the "data" object only, with its keys recursively sorted
// (including nested objects) — not the whole envelope, and not raw bytes as received.
String dataJson = "{\"clientStatus\":1,\"countryOfResidence\":\"SGP\",\"currentKycLevel\":100,\"externalId\":\"demo_12345\",\"faceIdVerifyStatus\":3,\"nationality\":\"SGP\",\"passportVerifyStatus\":3,\"proofOfAddressVerifyStatus\":3,\"verifyStatus\":1}"; // keys sorted alphabetically
String receivedSignature = "MxrYnCm9Q7JOAvOrISf8+T2kuTW1d/w0at8aaPaoiX08VWfun3XPokVlIx1TkHXdcitls09wzfUGtXQZq23xdg==";
String stringToSign = method + webhookUrl + dataJson;
String expectedSignature = Base64.getEncoder().encodeToString(
Hashing.hmacSha512(signKey.getBytes(StandardCharsets.UTF_8))
.hashBytes(stringToSign.getBytes(StandardCharsets.UTF_8))
.asBytes()
);
boolean isValid = expectedSignature.equals(receivedSignature);
System.out.println("StringToSign : " + stringToSign);
System.out.println("Expected : " + expectedSignature);
System.out.println("Received : " + receivedSignature);
System.out.println("Valid : " + isValid);
}
}To verify a real payload: take the
dataobject exactly as received, recursively sort its own keys, and rebuild the same string — do not sign the raw bytes of the whole request body, since key order there is not guaranteed to match what was actually signed.
Webhook Payload Sample
{
"event": "KYC_VERIFICATION",
"clientId": 852042402430001,
"signature": "MxrYnCm9Q7JOAvOrISf8+T2kuTW1d/w0at8aaPaoiX08VWfun3XPokVlIx1TkHXdcitls09wzfUGtXQZq23xdg==",
"data": {
"externalId": "demo_12345",
"clientStatus": 1,
"nationality": "SGP",
"countryOfResidence": "SGP",
"verifyStatus": 1,
"currentKycLevel": 100,
"passportVerifyStatus": 3,
"faceIdVerifyStatus": 3,
"proofOfAddressVerifyStatus": 3
}
}4 Appendix A: Data Structure
Every response object referenced above by name is fully specified inline at its first use (see the Response Parameters table for each endpoint). There are no shared cross-endpoint object shapes in this API beyond what is documented per endpoint.
5 Appendix B: Error Structure
All DTC Pay APIs return errors in a consistent structure, under the header field:
{
"header": {
"success": false,
"errCode": "<unique error code>",
"errMsg": "<description of the error>"
}
}Every endpoint above lists its own specific error codes with the condition that triggers each one; the table below lists common codes that can be returned by essentially any endpoint:
5.1 Common Error Codes (any endpoint)
| Code | Description |
|---|---|
| 00004 | Unknown API error |
| 00006 | Access denied |
| 00010 | Invalid parameters |
| 00025 | Sorry, our services are currently unavailable in your country or region. |
6 Appendix C: Enum List
ExternalKycLevel (used by targetKycLevel / currentKycLevel)
| Name | ID | Descriptor |
|---|---|---|
| BASIC | 100 | Complete the Passport + PoA + Selfie review. Unlocks Tier 1. |
| STANDARD | 200 | Adds a transaction-volume questionnaire on top of BASIC. Unlocks Tier 2. |
| ADVANCED | 900 | Adds stablecoin service eligibility on top of STANDARD. Unlocks Tier 3. |
EkycVerifyStatus (used by verifyStatus)
| Name | ID | Descriptor |
|---|---|---|
| UNVERIFIED | 1 | The applicant is not being verified. |
| VERIFYING | 2 | The applicant is being verified. |
| APPROVED | 3 | The applicant has been verified. |
| REJECT | 4 | The applicant's verification was rejected. |
EKycFileVerifyStatus (used by passportVerifyStatus / faceIdVerifyStatus / proofOfAddressVerifyStatus)
| Name | ID | Descriptor |
|---|---|---|
| UNVERIFIED | 1 | Document verification is not completed. |
| VERIFYING | 2 | Document verification is in progress. |
| VERIFY_SUCCESS | 3 | Document verification succeeded. |
| VERIFY_FAILURE | 4 | Document verification failed. |
ClientStatus (used by clientStatus)
| Name | ID | Descriptor |
|---|---|---|
| SUSPENDED | 0 | Suspended |
| PENDING_KYC | 1 | Pending KYC |
| DORMANT | 2 | Dormant |
| REGISTERED | 3 | Self Registered |
| REJECTED | 4 | Rejected |
| OFF_BOARD | 5 | Off Board |
| REFERRER | 8 | Referrer |
| TERMINATED | 9 | Terminated |
| DEACTIVATED | 10 | Deactivated |
| RESTRICTED | 11 | Restricted |
| DELETED | 12 | Deleted |
| FROZEN | 13 | Frozen |
| DROP | 14 | Drop |
| ACTIVATED | 99 | Activated |