# Doitpay merchant integration guide (for LLMs and developers) > Doitpay is an Indonesian payment gateway. Merchants and partners integrate over a REST API that follows Bank Indonesia's SNAP standard. One integration covers QRIS, Virtual Account, e-wallet, invoice payment links, and bank/e-money disbursement. All amounts are IDR. Human docs (Indonesian, with English field names): https://docs.doitpay.co/ Postman collection: linked from https://docs.doitpay.co/transactions/introduction This file is served at https://docs.doitpay.co/llms.txt Support: support@doitpay.co If you are an AI assistant writing integration code for a merchant, read "Rules that cause most failures" first. Do not invent endpoints, fields or response codes that are not listed here. If something is missing, tell the user to ask Doitpay support. ## Environments | Env | Base URL | Notes | | --- | --- | --- | | Sandbox | https://sandbox.doitpay.co | For integration testing. No IP whitelist. Use bank code 002 and the magic amounts below for disbursement tests. | | Staging | https://rest.doitpay.dev | Pre-production. Same API. | | Production | https://api.doitpay.co | HTTPS only. Plain HTTP requests fail. IP whitelist enforced per product. | Credentials differ per environment. Get them from the Doitpay merchant dashboard. ## What you need before the first call 1. A Doitpay merchant account and the dashboard. 2. An RSA 2048-bit key pair. Keep the private key. Upload the public key in the dashboard under Settings. 3. From Settings > API Key: your **client key** (sent as `X-CLIENT-KEY`, also used as the HMAC secret) and your **merchant ref** (sent as `X-PARTNER-ID` for most products). 4. For disbursement: the **disbursement secret key** (used as `X-PARTNER-ID`) and the **balance ID** (used as `sourceAccountNo` / `accountNo`). 5. Production only: send Doitpay the server IPs that will call each product (QRIS, virtual account, disbursement, e-wallet, payment link, account registration, utilities). A request from an unlisted IP is rejected. 6. A public HTTPS endpoint for each callback you want (see "Callbacks"). Generate keys: ``` openssl genrsa -out rsa_private_key.pem 2048 openssl rsa -in rsa_private_key.pem -out rsa_public_key.pem -pubout ``` ## Rules that cause most failures - Every request needs `X-TIMESTAMP` in ISO-8601 with offset, e.g. `2026-10-12T13:00:00+07:00`. It must parse as RFC 3339. - `X-EXTERNAL-ID` must be unique per request (max 64 chars). Reuse is rejected with a conflict in non-production and logged in production. Generate a fresh one on every call, retries included, unless you intend an idempotent replay. - `partnerReferenceNo` (or `trxId` for VA) must be unique per transaction on your side. Duplicates return a 409-class code. - Money is a string with two decimals: `{"value": "10000.00", "currency": "IDR"}`. Only IDR. - The body you sign must be byte-identical to the body you send. Minify the JSON before hashing. - `Endpoint` in the signature is the path only, with no host. Include the query string if there is one. - Get a new access token when you receive a 401 invalid-token code. Do not hard-code token lifetime. - Treat a callback as a hint. Confirm the final state with the status/query endpoint before fulfilling the order. - Verify the signature on every callback before trusting it. ## Authentication SNAP uses two steps: fetch an access token with an asymmetric signature, then call business endpoints with a symmetric signature. ### Step 1. Get access token `POST /auth/v1.0/access-token/b2b` (service code 73) Headers: | Header | Value | | --- | --- | | Content-Type | application/json | | X-TIMESTAMP | ISO-8601 | | X-CLIENT-KEY | your client key | | X-SIGNATURE | Base64(SHA256withRSA(privateKey, clientKey + "\|" + timestamp)) | Body: `{"grantType": "client_credentials"}` Success: response code `2007300`, body contains `accessToken`. Errors: `4007301` invalid grantType format, `4007302` missing grantType or bad client key/timestamp/signature, `4017300` invalid client key or signature. A B2B2C token endpoint also exists at `POST /auth/v1.0/access-token/b2b2c` (service code 74) for account-binding flows. Ask Doitpay before using it. ### Step 2. Sign each business request (symmetric) ``` stringToSign = HTTPMethod + ":" + EndpointPath + ":" + AccessToken + ":" + lowercase(hex(SHA256(minify(requestBody)))) + ":" + Timestamp signature = Base64(HMAC_SHA512(stringToSign, clientKey)) ``` Send it as `X-SIGNATURE`. The HMAC secret is the client key. For GET requests the body hash is the hash of an empty string. Standard headers on business requests: | Header | Required | Value | | --- | --- | --- | | Content-Type | yes | application/json | | Authorization | yes | `Bearer ` | | X-TIMESTAMP | yes | ISO-8601 | | X-SIGNATURE | yes | from the formula above | | X-PARTNER-ID | yes | merchant ref (disbursement: disbursement secret key). QRIS docs show the project/client ID. | | X-EXTERNAL-ID | yes | unique request ID, max 64 chars | | X-CLIENT-KEY | yes | your client key | | CHANNEL-ID | see product | `DOITPAY` for VA, e-wallet, disbursement, merchant. QRIS takes a provider code such as `NQ`. | ### Reference signing code (Go) ```go func sign(method, path, token, body, ts, clientKey string) string { var min bytes.Buffer _ = json.Compact(&min, []byte(body)) h := sha256.Sum256(min.Bytes()) s := method + ":" + path + ":" + token + ":" + hex.EncodeToString(h[:]) + ":" + ts m := hmac.New(sha512.New, []byte(clientKey)) m.Write([]byte(s)) return base64.StdEncoding.EncodeToString(m.Sum(nil)) } ``` ## Response codes Format is 7 digits: HTTP status (3) + service code (2) + case code (2). Example: `2004700` is HTTP 200, service 47 (QRIS generate), case 00. | Pattern | Meaning | | --- | --- | | 200XX00 | Success | | 400XX01 | Invalid field format | | 400XX02 | Missing mandatory field | | 401XX00 | Unauthorized signature or client | | 401XX01 | Invalid or expired access token | | 409XX00 | Conflict, duplicate external ID or reference | | 500XX00 | Doitpay internal error. Contact support with your X-EXTERNAL-ID. | Service codes: 73 token, 47 QRIS generate, 51 QRIS query, 52 QRIS notify, 27 VA create, 28 VA update, 30 VA inquiry, 31 VA delete, 26 VA status, 25 VA payment callback, 54 e-wallet payment, 55 e-wallet status, 56 e-wallet notify and cancel, 58 e-wallet refund, 16 bank account validation, 18 bank disbursement, 36 bank disbursement status, 37 e-money account validation, 38 e-money top-up, 39 e-money status, 11 balance inquiry, 10000 and 1011 and 1012 merchant create, detail, list. ## QRIS (merchant-presented, MPM) Generate a dynamic QR for the customer to scan with any QRIS app. `POST /qris/v1.0/qr/qr-mpm-generate` (service 47) Extra headers: `CHANNEL-ID` is the QRIS provider code (sandbox/production value assigned by Doitpay, e.g. `NQ`). Body: | Field | Type | Req | Notes | | --- | --- | --- | --- | | partnerReferenceNo | string(64) | yes | Your transaction number | | amount.value / amount.currency | string | yes | `"10000.00"`, `"IDR"` | | validityPeriod | string | yes | Expiry, ISO-8601. Minimum 30 minutes from now. | | merchantId | string | no | MPAN | | subMerchantId | string | no | MID | | storeId | string | no | NMID | | additionalInfo | object | no | Provider-specific, e.g. return URL | Response fields: `responseCode`, `responseMessage`, `referenceNo` (provider ref, keep it), `partnerReferenceNo`, `qrContent` (raw QR string), `qrImage` (base64), `qrUrl`, `merchantName`, `storeId`. Key errors: `4004700` service not enabled for this CHANNEL-ID, `4044713` amount does not equal item total, `4094700` duplicate external ID or expiry below 30 minutes. ### Query QRIS payment `POST /qris/v1.0/qr/qr-mpm-query` (service 51) Body: `originalPartnerReferenceNo`, `originalReferenceNo` (the `referenceNo` from generate), `serviceCode` (e.g. `SP`). Response: `latestTransactionStatus`, `paidTime`, `amount`, `feeAmount`. ## Virtual Account (close amount) Bank channels and their `partnerServiceId`: | Bank | partnerServiceId | Max customerNo length | | --- | --- | --- | | BCA | 1010001 | 6 | | BNI | 1020002 | 6 | | CIMB | 1030003 | 9 | | Permata | 1040004 | 8 | | BRI | 1050005 | 8 | | Mandiri | 1070007 | 7 | If `customerNo` is not usable, Doitpay generates a random one. Endpoints (all need `CHANNEL-ID: DOITPAY`): | Action | Method and path | | --- | --- | | Create | POST /va/v1.0/transfer-va/create-va | | Update | PUT /va/v1.0/transfer-va/update-va | | Inquiry | POST /va/v1.0/transfer-va/inquiry-va | | Delete (expire) | DELETE /va/v1.0/transfer-va/delete-va | | Payment status | POST /va/v1.0/transfer-va/status | Create body: ```json { "partnerServiceId": "1010001", "customerNo": "123456", "virtualAccountNo": "", "virtualAccountName": "Jane Doe", "virtualAccountEmail": "jane@example.com", "virtualAccountPhone": "6281828384858", "trxId": "order-0001", "totalAmount": {"value": "100000.00", "currency": "IDR"}, "virtualAccountTrxType": "C", "expiredDate": "2026-10-13T23:59:59+07:00", "additionalInfo": {"minAmount": "0.00", "maxAmount": "0.00"} } ``` Only `virtualAccountTrxType: "C"` (closed amount) is supported, with min and max set to `0.00`. Leave `virtualAccountNo` empty so Doitpay assigns it. The response returns `virtualAccountData.virtualAccountNo` to show the customer. Delete works only while the VA is active (not paid, not expired). It needs `partnerServiceId`, `customerNo`, `virtualAccountNo`, `trxId`. Status response `paymentFlagStatus`: `00` success, `01` processing, `02` expired. ## E-wallet (host-to-host direct debit) Channels: `OVO`, `DANA`, `SHOPEEPAY`. Doitpay returns a redirect link for the customer. | Action | Method and path | | --- | --- | | Create payment | POST /api/v1.0/debit/payment-host-to-host | | Payment status | POST /api/v1.0/debit/status | | Cancel | POST /api/v1.0/debit/cancel | | Refund | POST /api/v1.0/debit/refund | Create body: ```json { "partnerReferenceNo": "ORDER123456789", "amount": {"value": "100000.00", "currency": "IDR"}, "urlParam": [ {"url": "https://merchant.example/return", "type": "PAY_RETURN", "isDeeplink": "false"}, {"url": "https://merchant.example/notify", "type": "PAY_NOTIFY", "isDeeplink": "false"} ], "validUpTo": "2026-10-12T23:59:59+07:00", "pointOfInitiation": "Mobile App", "additionalInfo": { "channel": "DANA", "customerPhone": "081234567890", "customerName": "John Doe", "customerEmail": "john@example.com" } } ``` Response: `webRedirectUrl` or `appRedirectUrl` to send the customer to, plus `additionalInfo.originalReferenceNo` and `additionalInfo.expiredTime`. `isDeeplink` can only be `false` today. Status body: `originalPartnerReferenceNo` and `additionalInfo.{originalReferenceNo, channelCode}`. `latestTransactionStatus`: `00` success, `01` initiated, `02` paying, `03` pending, `04` refunded, `05` canceled, `06` failed, `07` not found. ## Invoice / payment link Hosted payment page that lets the customer choose VA, e-wallet, QRIS or card. | Action | Method and path | | --- | --- | | Create payment link | POST /payment-link/v1.0/debit/payment-host-to-host | | Check status | POST /payment-link/v1.0/debit/status | Create body fields: `partnerReferenceNo`, `merchantId`, `subMerchantId`, `amount`, `urlParams` (`PAY_RETURN`, `NOTIFICATION`), `validUpTo`, and `additionalInfo` with `paymentNumber`, `items[]` (`itemName`, `itemSku`, `quantity`, `price`), `customerName`, `customerEmail`, `customerMobileNo`, `notes`, `taxPercentage`, `discount`, `additionalFee`, `isFeeChargedToUser`. Response: `referenceNo` and `webRedirectUrl`. Redirect the customer to `webRedirectUrl`. Invoice statuses: PENDING, PAID, SETTLED, EXPIRED, CANCELLED, REFUND_PROCESSING, REFUNDED. Minimum amount is IDR 10,000 for most methods. Service codes: 54 create, 55 status, 56 notify. Full field list and the amount calculation rules are at https://docs.doitpay.co/transactions/payment-link. ## Disbursement (send money out) Needs the disbursement secret key as `X-PARTNER-ID` and a funded balance. Fund and look up the balance ID in the dashboard. Typical flow: validate the account, then create the transfer, then confirm by callback or status check. | Action | Method and path | Service | | --- | --- | --- | | Validate bank account | POST /disbursement/v1.0/account-inquiry-external | 16 | | Bank transfer | POST /disbursement/v1.0/transfer-interbank | 18 | | Bank transfer status | POST /disbursement/v1.0/transfer/status | 36 | | Validate e-money account | POST /disbursement/v1.0/emoney/account-inquiry | 37 | | E-money top-up | POST /disbursement/v1.0/emoney/topup | 38 | | E-money status | POST /disbursement/v1.0/emoney/topup-status | 39 | | Balance inquiry | POST /disbursement/v1.0/balance-inquiry | 11 | | Bulk bank transfer | POST /disbursement/v1.0/transfer-interbank-bulk | n/a | | Bulk status | POST /disbursement/v1.0/transfer-interbank-bulk/status | n/a | Validate body: `beneficiaryBankCode` (BI code like `002` or SWIFT like `BRINIDJA`), `beneficiaryAccountNo`, `partnerReferenceNo`. Response includes `beneficiaryAccountName`. Transfer body: ```json { "partnerReferenceNo": "111111111", "amount": {"value": "20000.00", "currency": "IDR"}, "beneficiaryAccountName": "Dummy Name", "beneficiaryAccountNo": "888801000003301", "beneficiaryBankCode": "002", "sourceAccountNo": "", "transactionDate": "2026-10-12T12:08:56+07:00", "additionalInfo": {"validationReferenceNo": ""} } ``` `validationReferenceNo` is required and must match the `partnerReferenceNo` you sent in the validate call. Status request: send `originalPartnerReferenceNo` or `originalReferenceNo`, plus `serviceCode` `"18"`. Status values: `00` success, `03` pending, `06` failed. Treat `03` as not final. Do not retry a transfer with a new reference while it is pending. Poll status instead. Duplicate `partnerReferenceNo` returns `4091801`. Bank code list: see https://docs.doitpay.co/transactions/disbursement. Sandbox uses `002`. Sandbox magic amounts (set `amount.value`): `199119.00` and `190145.00` succeed. `299229.00` fails with a generic error. `123986.00` fails with code `51`, insufficient funds. ## Callbacks (Doitpay calls you) Register your callback base URL in the dashboard. Doitpay posts to these fixed paths on your domain: | Event | Path on your server | | --- | --- | | QRIS payment | /v1.0/qr/qr-mpm-notify | | Virtual account payment | /v1.0/transfer-va/payment | | E-wallet and credit card | /v1.0/debit/notify | | Payment link | /v1.0/payment-link/debit/notify | | Disbursement | /v1.0/transfer-interbank/notify | Rules for your endpoint: - Return HTTP 200 with `{"responseCode": "...", "responseMessage": "..."}`. Success codes follow the pattern `200XX00` (for example VA `2002500`, e-wallet `2005600`). - Verify `X-SIGNATURE` before processing. Callbacks use `X-TIMESTAMP`, `X-SIGNATURE`, `X-PARTNER-ID`, `X-EXTERNAL-ID`, `CHANNEL-ID`. The signature is HMAC-SHA512 over `Method:Endpoint::hex(sha256(minify(body))):Timestamp`, Base64 encoded. The QRIS page also describes an asymmetric SHA256withRSA scheme verified with Doitpay's public key. Ask Doitpay which one is active for your account and implement that one. - Make processing idempotent. Doitpay retries when it does not get a 2xx, so you can see the same event more than once. Key on `originalPartnerReferenceNo` / `trxId` plus status. - Respond fast. Do slow work asynchronously. Callback bodies: - QRIS: `originalPartnerReferenceNo`, `originalReferenceNo`, `latestTransactionStatus` (`00` = paid), `amount`, `additionalInfo`. - VA: `partnerServiceId`, `customerNo`, `virtualAccountNo`, `paymentRequestId`, `paidAmount`, `additionalInfo.{reference, paymentCode}`. - E-wallet: `originalPartnerReferenceNo`, `originalReferenceNo`, `latestTransactionStatus`, `amount`, `additionalInfo.channelCode`. - Disbursement: `originalReferenceNo`, `originalPartnerReferenceNo`, `latestTransactionStatus` (`00`, `03`, `06`), `transactionStatusDesc`, `beneficiaryAccountNo`, `beneficiaryBankCode`, `amount`, `sourceAccountNo`. ## Credit card Uses the same host-to-host endpoints as e-wallet (`POST /api/v1.0/debit/payment-host-to-host`, `POST /api/v1.0/debit/status`) and the same `/v1.0/debit/notify` callback. Field details: https://docs.doitpay.co/transactions/credit-card. ## Other APIs | Area | Endpoint | Notes | | --- | --- | --- | | Merchant create | POST /api/v1.0/merchant | Body `name`, `business_name`. Returns merchant, balance, `qr_nmid`. | | Merchant detail | GET /api/v1.0/merchant/:merchant_ref | | | Merchant list | GET /api/v1.0/merchant?page=1&limit=10&search= | Paginated. `X-PARTNER-ID` is your merchant ref. | | Data export | POST /api/v1.0/data/export, GET /api/v1.0/data/export/:reportId | Async report, poll status. | | Simulate QRIS payment | POST /qris/v1.0/simulate-payment | Sandbox only. Marks a generated QR as paid and fires your callback. | | Account binding (direct debit) | POST /api/v1.0/registration-account-binding, -inquiry, -unbinding | Needs account_registration IP whitelist. | | Card binding | POST /api/v1.0/registration-card-bind, -inquiry, -bind-limit, -unbind, /api/v1.0/otp-verification | | | Direct debit balance | POST /api/v1.0/debit/balance-inquiry | | Rate limits are applied per business and per product (QRIS, VA, e-wallet, credit card, direct debit, disbursement, export, management, auth). Limits are set per account. If you get HTTP 429, back off and retry with exponential delay. ## Testing checklist 1. Fetch a token in sandbox. A `2007300` response means your RSA signature is right. 2. Call one endpoint with a fresh `X-EXTERNAL-ID`. If you get `401XX00`, recheck the string-to-sign: method, path, token, body hash, timestamp, in that order, joined by colons. 3. For QRIS, generate a QR, then call simulate-payment and confirm your callback fires and verifies. 4. For VA, create a VA, simulate the bank payment from the dashboard, check the status endpoint. 5. For disbursement, validate `002` accounts in sandbox, send a transfer, and confirm both the callback and the status endpoint agree. 6. Send your production server IPs to Doitpay before go-live. ## Common mistakes - Hashing a pretty-printed body but sending a minified one. - Putting the full URL (with host) in the string-to-sign. - Reusing `X-EXTERNAL-ID` on retries. - Treating a pending disbursement (`03`) as failed and sending it again. - Using the access token as the HMAC key. The key is the client key. - Fulfilling an order on the redirect return URL alone. Use the callback plus a status check. - Sending amounts as numbers. They are strings with two decimals. - Setting QRIS `validityPeriod` less than 30 minutes ahead.