doitAPI Docs

doit.id API Documentation

Accept QRIS and Virtual Account payments with one simple API. No complicated signatures, no waiting: sign up, grab an API key, and run your first test transaction in minutes.

Start in 10 minutes

1. Create an account at doit.id/daftar (your sandbox dashboard goes live as soon as your email and WhatsApp number are verified), then sign in to the portal → Developer menu → create an application → copy the API key (pb_test_…).

2. Create your first payment:

curl -X POST https://pay.doit.id/v1/payments \
  -H "Authorization: Bearer pb_test_xxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: inv-001" \
  -d '{"amount": 150000, "rail": "qris", "reference": "INV/001"}'

The response contains qr_content: render it as a QR image (e.g. the npm qrcode package) and show it to the payer.

3. Set a webhook URL in the Developer menu, then open the payment in the portal, Transactions menu: click the payment row, the detail panel opens on the right, then click Simulate paid (sandbox). The button lives in the portal, not on the checkout page your customer sees. The payment.paid webhook arrives at your server. Integration done.

Authentication

Every request uses an API key in the Authorization header:

Authorization: Bearer pb_test_xxx

API keys are issued per application in the portal and shown once at creation. No signatures, timestamps, or extra headers; just this key over HTTPS.

Test vs live

The environment is determined by the key prefix, not the base URL, so your integration code never changes:

KeyBehaviour
pb_test_…Sandbox: no real money; payments can be settled via the simulate button in the portal.
pb_live_…Production: real funds, available once your business verification is approved.

Idempotency

Every POST must include an Idempotency-Key header. Use a unique ID from your own system (e.g. an invoice number). Repeating a request with the same key returns the original result (HTTP 200) and never creates a duplicate transaction. Hit a timeout? Just resend with the same key.

Rate limits

120 requests per minute per API key. Exceeding it returns HTTP 429 with a Retry-After header (seconds). Respect the value, then repeat the same request (safe, thanks to idempotency).

Create a payment

POST /v1/payments

You do not need the API just to create a pay link. In the portal → Payments there is a + Create invoice button: enter an amount and a customer name, and a pay link is generated together with a ready-to-send WhatsApp billing message. Great for non-technical staff; the API below is for system integrations.
FieldTypeDescription
amountinteger, requiredWhole rupiah: 150000 = Rp150,000. No decimals.
railstring"qris", "va", "transfer", or "any" (the default when omitted). With "any", the customer picks the method themselves on the hosted payment page, fees shown per method.
referencestring, requiredThe invoice ID in your system (≤128 chars). Echoed back in webhooks.
va_bankstringRequired when rail=va or rail=transfer (bca/mandiri/bni/bsi); see the bank code table below.
expires_inintegerValidity of the invoice in seconds, ≥60. Defaults: QRIS 3600, VA 86400. Note: the QR image itself is shorter-lived, see the box below.
customerobjectOptional: {"name","phone"}; the name appears on the VA.
return_urlstringOptional http(s) URL; adds a "back to merchant" button on the hosted payment page.
metadataobjectAnything you like; returned verbatim in responses & webhooks.

201 response:

{
  "id": "pay_x8Kj2…",
  "status": "pending",
  "amount": 150000,
  "rail": "qris",
  "reference": "INV/001",
  "qr_content": "00020101…",        // rail=qris → render as a QR image
  "va_number": null,                 // rail=va → the VA number
  "va_bank": null,                   // rail=transfer → destination bank (bca/mandiri/bni/bsi)
  "hosted_url": "https://pay.doit.id/p/pay_x8Kj2…",
  "return_url": "https://yourstore.id/invoice/42",
  "provider": "pjp",             // the licensed provider that issued the instrument
  "provider_ref": "A48957157…",  // the provider's own reference, for reconciliation
  "fee_amount": 4000,            // service fee for this method
  "fee_payer": "customer",       // who bears it (configured in the portal)
  "total_amount": 154000,        // what the customer pays; = amount when the merchant bears the fee
  "expires_at": "2026-08-19T15:30:00.000Z",
  "paid_at": null,
  "metadata": null,
  "created_at": "2026-08-19T14:30:00.000Z"
}
Dynamic QR codes are short-lived (±10 minutes), separate from the invoice validity. If the QR expires while the invoice is still valid, the payment stays pending; only the instrument is released: rail and qr_content revert to null. The hosted payment page handles this automatically (the customer generates a fresh QR in one click), so the safest integration is to send payers to hosted_url. If you render qr_content in your own UI, direct the payer to hosted_url once your QR expires. There is no webhook for this event because the invoice has not ended.
You never choose a payment provider: routing, failover, and all the complexity behind them are doit.id's job. If one route has an outage, new transactions automatically flow through a healthy one.

Payment methods & service fees

Methodva_bank codeDefault service fee
QRIS (all banks & e-wallets)…0% up to Rp100.000, 0.70% above that; always borne by merchant (BI regulation)
Mandiri Virtual AccountbmriRp4,000
BSI Virtual AccountbsyiRp4,000
BNI Virtual AccountbniaRp3,000
BRI Virtual Account (BRIVA)brinRp3,000
CIMB Niaga Virtual AccountcimbRp3,000
Permata Virtual AccountpermataRp3,000
Maybank Virtual AccountmaybankRp3,000
Danamon Virtual AccountdanamonRp3,000
BNC Virtual Account (Bank Neo Commerce)neonot yet available
BCA Virtual Accountbcanot yet available (method_unavailable)
BCA Bank Transfer (Account Mutation)bca with rail=transferRp0 per transaction; the unique code is part of the amount, not a fee. Requires an Account Mutation subscription (see Bank transfer).
Mandiri Bank Transfer (Account Mutation)mandiri with rail=transferRp0 per transaction; the unique code is part of the amount, not a fee. Requires an Account Mutation subscription.
BNI Bank Transfer (Account Mutation)bni with rail=transferRp0 per transaction; the unique code is part of the amount, not a fee. Requires an Account Mutation subscription.
BSI Bank Transfer (Account Mutation)bsi with rail=transferRp0 per transaction; the unique code is part of the amount, not a fee. Requires an Account Mutation subscription.

Fees above are platform defaults; negotiated per-application pricing may differ; the effective rate for your application is always shown in portal → Payment Methods and in the fee_amount field of every response.

In the portal → Payment Methods, each application can switch methods on/off (per-bank VA, QRIS) and choose who bears the service fee: Merchant (the customer's bill is unchanged; the fee reduces your net) or Customer (the fee is added to the total paid, shown transparently on the payment page and in fee_amount/total_amount). Disabled methods reject new transactions with method_disabled; methods not yet available on the platform appear inactive and return method_unavailable.

QRIS specifically: under Bank Indonesia regulation, the QRIS MDR must not be passed on to the buyer; its fee assignment is locked to Merchant and cannot be changed. From 1 October 2026, the QRIS MDR is 0% on transactions up to Rp100.000 and 0.70% above that. The new rate applies automatically to invoices issued from that date; invoices already issued keep the fee recorded on them, so fee_amount on older invoices does not change.

Bank transfer (Account Mutation)

The customer transfers straight into your own business bank account (BCA, Mandiri, BNI, or BSI), and doit.id confirms it automatically by reading that account's mutations. Funds never pass through doit.id, so there is no settlement: the money lands in your account immediately.

How it works: create a payment with rail=transfer and va_bank set to bca, mandiri, bni, or bsi. The system appends a 3-digit unique code to the amount, so total_amount in the response (for example Rp150,123) is the EXACT sum the customer must transfer. The hosted_url page shows the destination account number, holder name, and that amount, plus an "I have transferred" button that triggers an immediate check. Once a mutation with that amount is read, the status becomes paid and you receive the same payment.paid webhook as on any other rail, so your integration code does not change.

Exact amount = no confirmation needed. A customer who transfers exactly total_amount does not need to send proof or confirm anything. Confirmation timing follows the bank statement reading schedule, so do not promise customers a specific number of minutes. If a customer transfers the wrong amount (for example, forgets the unique code), the payment page offers an upload proof of transfer box. You are notified by email, the proof appears in the payment details in the portal, and after checking your own account you click Tandai lunas (mark as paid). The same payment.paid webhook is sent; the payment carries the lunas_manual flag in flags.

BCA Business API Direct

For a company account already using BCA API services, select BCA Business API Direct in Portal → Account Mutation. Once active, the account also appears in GET /v1/mutations?bank=bca with a live API key. This is different from BCA internet banking: do not enter an internet-banking username or KeyBCA. doit.id only calls the balance and account-statement endpoints, never a fund-transfer endpoint.

  1. Enable the BCA service. Apply for Balance Inquiry and Account Statement for your company account. Request read-only access if BCA offers that option.
  2. Whitelist the connector. When BCA asks for a source IP, use the egress address supplied by the doit.id team. You do not need to provide SSH or access to your company server; doit.id operates the connector and its connection rotation.
  3. Pay the subscription, then enter the configuration. Select the BCA Business subscription in the portal, then enter the account number, account name, Corporate ID, Client ID, Client Secret, API Key, and API Secret. Enter credentials only in the portal form, never in chat, a ticket, or application source code.
  4. Test, then enable payment acceptance. The account card is available for monitoring after configuration is saved. The Accept payments switch tests the API again before BCA appears as a transfer destination at checkout. This route is BCA-only; Mandiri, BNI, and BSI accounts using other readers are not moved.
Displayed data. A Direct connection using BCA's classic response shows the current balance, mutation date, amount, type, and a description combining BCA's transaction name and trailer/reference. The portal initially reads the previous seven days. The classic response does not include transaction time or a running balance for every row, so doit.id does not infer either value. Instead, since 13 Sep 2026 doit.id records when a mutation was first read (checked every 5 minutes) together with the account balance at that moment, and shows them in the Terdeteksi and Saldo columns as well as in detected_at and balance on GET /v1/mutations. That is our read time, not the bank's transaction time, and mutations from before this feature do not have it. Since 14 Sep 2026 the BCA mutation list is served from those doit.id records instead of calling BCA each time the list is opened, because the BCA API is billed per call. Freshness is still kept by the 5 minute check, and a customer who just paid can still force a check through POST /v1/payments/{id}/recheck. Stored BCA history starts on 13 Sep 2026; requests older than that come back empty. BCA marks transactions it has not booked yet as PEND, with no date. For those rows doit.id fills date with the date the mutation was first read and sends posted: false; once the bank books it, the bank's official date replaces it and posted becomes true.

Need transaction time and opening/closing balance per statement? Ask BCA to enable Bank Statement SNAP for your company. BCA documents that feature as returning an ISO-8601 timestamp, remark, opening/closing balances, and transaction details. SNAP requires additional BCA authorisation and credentials; contact care@doit.id after the facility is active so we can migrate the connection without disrupting checkout.

While a BCA transfer invoice is pending, doit.id reads mutations automatically every 10 minutes. The check-status button on the payment page also requests an immediate check. A payment is marked paid only when a credit mutation exactly equals the unique amount of a pending invoice.

Checkout link: the hosted payment page

Every payment carries a hosted_url: a ready-made payment page you can fully redirect to or send straight to the customer (WhatsApp, email, SMS), the pattern other gateways call a checkout link. It shows your business name, the amount, a method picker, a rendered QR (or the VA number with a copy button), refreshes its own status when the payment lands, and handles expiry. If you don't want to build a payment UI, just forward this link; your minimum integration is two things: create a payment, listen for the webhook.

No code at all? Also possible. In the portal, Transactions → + Invoice, fill in the amount and reference and you get the same payment link to share manually; handy for occasional invoices or before your API integration is ready.

Automatic return. If you set return_url, the page sends the customer back to your system 5 seconds after the payment is received (with a countdown and a manual link). This matters: without it, customers often leave the payment tab open and never see your own confirmation, even though the money has already arrived.

Bilingual. The page renders in Indonesian by default; append ?lang=en to the hosted_url for English, or let the customer use the ID | EN switch in the top-right corner.

Status lifecycle

pending ──► paid ──► refunded
   ├──────► expired ──► paid   (late payment)
   └──────► failed

Only five statuses, never a sixth. One transition you should anticipate: expired can become paid. VA numbers and QR codes stay alive on the bank side longer than the invoice's expiry, so a customer who already copied the number sometimes pays after the invoice expired. Money that has actually moved is always credited: the payment.paid webhook fires as usual (possibly after you received payment.expired), and the payment carries the bayar_telat flag. If your order was already cancelled, use that flag to decide: fulfil the order, or refund.

Unusual provider cases go into the flags array; a simple integration can ignore it and still be correct. Current values: bayar_ganda (double payment: the same payment was paid through two different instruments, e.g. the customer switched methods and both went through; the funds are recorded and reconciled, nothing is lost) and bayar_telat (paid after expiry, see above).

Retrieve payments

GET /v1/payments/{id}: a single payment.
GET /v1/payments?reference=INV/001: look up by your reference.
GET /v1/payments?status=paid&limit=100: list, newest first.

The returned object always has the same shape as the create response. Use this as a safety net (e.g. a nightly reconciliation cron); webhooks are the primary mechanism.

Refunds

POST /v1/payments/{id}/refunds

curl -X POST https://pay.doit.id/v1/payments/pay_x8Kj2/refunds \
  -H "Authorization: Bearer pb_test_xxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: rf-001" \
  -d '{"amount": 50000, "reason": "Overpayment"}'

amount is optional (default: full). Partial refunds may repeat until the total equals the original amount. Only payments with status paid can be refunded.

Recurring Billing

Bill your customers on a repeating schedule (subscriptions, dues, installments) without creating an invoice by hand each month. You define a plan (amount + interval), then subscribe a customer. doit.id issues invoices automatically on schedule and sends an invoice.created webhook carrying the hosted_url.

doit.id does not message your customers. This model is link-based: each invoice yields a hosted payment page (hosted_url) and you forward it through your own channel (your own WhatsApp business number, email, SMS). No stored cards and no auto-debit; the customer pays it themselves, exactly like a normal payment. For email specifically, doit.id can send it for you; set kirim_email:true on the plan.
1. Create plan   POST /v1/billing/plans          (once)
2. Subscribe     POST /v1/billing/subscriptions  (per customer)
3. doit.id issues an invoice each period -> invoice.created webhook
4. You send the hosted_url to the customer via your channel
5. Customer pays -> payment.paid webhook (as usual)

Plans

POST /v1/billing/plans

FieldTypeDescription
namestring, requiredPlan name (≤100 chars), e.g. "Internet 20 Mbps".
amountinteger, requiredWhole rupiah per period.
intervalobject, required{"unit","count"}. unit: day/week/month; count 1–366. Monthly: {"unit":"month","count":1}.
railstringany (default), qris, va, or transfer. With any the customer picks on the hosted page.
va_bankstringRequired when rail=va or rail=transfer (bca/mandiri/bni/bsi); see the bank code table.
expires_inintegerValidity of each invoice (seconds, ≥3600). Default 604800 (7 days).
kirim_emailbooleanDefault false. When true, doit.id emails the payment link directly to customer_email on each invoice, no integration on your side. WhatsApp still goes through your own channel (webhook).
curl -X POST https://pay.doit.id/v1/billing/plans \
  -H "Authorization: Bearer <api key>" -H "Content-Type: application/json" \
  -H "Idempotency-Key: plan-internet-20" \
  -d '{"name":"Internet 20 Mbps","amount":150000,"interval":{"unit":"month","count":1}}'

GET /v1/billing/plans lists them. PATCH /v1/billing/plans/{id} with {"active":false} deactivates a plan (existing subscriptions stop being invoiced but are not deleted).

Subscriptions

POST /v1/billing/subscriptions: subscribe one customer to a plan.

FieldTypeDescription
plan_idstring, requiredPlan ID.
customer_namestring, requiredCustomer name (shown on VA & hosted page).
customer_phonestringOptional; you use it to send invoices yourself.
customer_emailstringOptional; included in the webhook payload.
referencestringYour own customer ID; becomes the prefix of each invoice's reference.
start_atstringISO-8601. Empty/now → first invoice issues now. Future → starts later (e.g. trial end).
metadataobjectFree-form; stored on the subscription.

The 201 response includes the subscription plus first_invoice (a full payment with hosted_url) when the first invoice issues immediately, so you can forward it right away without waiting for the webhook.

Manage: GET /v1/billing/subscriptions (filter ?status=active), GET /v1/billing/subscriptions/{id} (detail + its invoices), and POST .../{id}/pause · .../resume · .../cancel. Pause halts issuance; resume continues from the next period (does not dump missed periods); cancel is permanent.

The invoice.created event fires each time a recurring invoice issues. Payload = the normal payment object (including hosted_url, reference, amount) plus a subscription field (id, plan, customer data). When the customer pays, you still receive payment.paid as usual.

Receiving webhooks

Set a webhook URL per application in the portal. Events arrive as POST JSON:

EventWhen
payment.paidPayment received. Complete payload, enough to mark the invoice paid & unlock service without another API call.
payment.expiredValidity window passed.
invoice.createdA new recurring invoice issued. Payload = payment (with hosted_url) + a subscription field. See Recurring Billing.
payment.proof_uploadedThe customer uploaded a proof of transfer on the payment page (transfer rail only). Payload = payment + a proof field (id, filename, mime, size, amount, note, uploaded_at, url). The status does NOT change; see Account mutations & manual confirmation. Not using it yet? Answer 200 and ignore it.
refund.succeeded / refund.failedRefund outcome.
webhook.testSent when you save or test the webhook URL in the portal (and by the onboarding probes). Signed like any real event; reply 2xx and ignore its data.
{
  "id": "evt_9aB3…",             // dedupe on this id
  "type": "payment.paid",
  "created_at": "2026-08-19T14:35:12.000Z",
  "data": { …the full payment object… }
}

Reply 2xx quickly (do heavy work async). Two rules that save you from the classic incidents:

Signature verification

Every webhook carries a PayBridge-Signature header:

PayBridge-Signature: t=1755612912,v1=5f8a…

v1 = HMAC-SHA256(signing secret, t + "." + body). The signing secret is in the Developer menu. Node.js:

const crypto = require('node:crypto');
function verify(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(',').map(s => s.split('=')));
  const calc = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(calc));
}

PHP:

function verify(string $rawBody, string $header, string $secret): bool {
  parse_str(str_replace(',', '&', $header), $p);
  $calc = hash_hmac('sha256', $p['t'] . '.' . $rawBody, $secret);
  return hash_equals($calc, $p['v1']);
}

Retries & replay

Webhooks not answered with 2xx are retried with backoff: 1m → 5m → 15m → 1h → 3h → 6h → 12h → 24h. After that they land in a dead-letter queue and can be replayed anytime from the Webhooks page in the portal, alongside the full delivery history, status, and attempt count.

Embed checkout

Every payment already has a hosted_url: a ready-made payment page. With embed.js that page opens as a modal on your own site, so customers pay without leaving the page. No dependencies, one script tag.

<script src="https://doit.id/embed.js"></script>

<!-- option 1: no JS: a plain link holding the hosted_url -->
<a href="https://pay.doit.id/p/pay_xxx" data-doit-checkout>Pay</a>

<!-- option 2: from code, with callbacks -->
<script>
doit.open('https://pay.doit.id/p/pay_xxx', {
  onPaid:    function (ev) { /* ev = {id, status, reference} */ },
  onExpired: function (ev) { /* payment expired/failed */ },
  onClose:   function (ev) { /* modal closed; ev = final status or null */ },
  closeDelay: 2500          // ms before the modal closes itself after payment
});
</script>

When the payment reaches a terminal status, the payment page sends a postMessage {source:'doit-checkout', id, status, reference} to the parent window; the modal closes itself once the customer has seen the paid checkmark.

Browser callbacks are not the source of truth. Use onPaid for UX only (close the modal, show success). To unlock services or mark invoices paid in your database, rely on the signed payment.paid webhook.

AI agents & MCP

Your doit.id integration can be built (or driven directly) by AI agents:

ToolWhat for
llms.txt A compact plain-text API reference. Paste the URL into Claude, ChatGPT, or any agent so it understands the whole doit.id API: endpoints, fields, webhooks, and error codes, without reading these HTML pages.
MCP server A single-file, dependency-free Model Context Protocol server (Node.js ≥ 18). Agents like Claude can create payments, check statuses, and manage recurring billing directly as tools.

Install the MCP server:

# download
curl -O https://doit.id/mcp/doit-mcp.mjs

# Claude Code / Claude Desktop configuration (mcpServers):
{
  "doit": {
    "command": "node",
    "args": ["/path/to/doit-mcp.mjs"],
    "env": { "DOIT_API_KEY": "pb_test_xxx" }
  }
}

Available tools: create_payment, get_payment, list_payments, create_billing_plan, list_billing_plans, create_subscription, get_subscription. Start with a pb_test_ key; every tool honours idempotency automatically.

API keys are secrets. Keep them in the MCP config env, never in prompts. For read-only agents, create a separate app in the portal so the key is easy to revoke.

Account mutations & manual confirmation (API)

For support teams: read your own business account's bank mutations from your system, then mark an invoice as paid. The typical flow: the customer transfers, then tells your CS; CS finds the mutation, matches it, and marks the invoice paid. Every endpoint below requires a live API key and only touches accounts belonging to your own account. Prerequisites are the same as Bank transfer: the account is added in the portal and the Account Mutation subscription is active.

List mutations

GET /v1/mutations?bank=mandiri&from=2026-09-01&to=2026-09-12&type=credit&limit=25&page=1

All parameters are optional: bank (bca, mandiri, bni, bsi, or a slot name such as bca_bisnis; empty means all of your accounts), from and to (YYYY-MM-DD), type (credit or debit), amount (exact amount), q (search the description), limit (max 100), page.

{
  "data": [
    { "id": "mt_9f2…",
      "bank": "mandiri", "jenis_rekening": "mandiri_bisnis", "account_number": "1240007571525",
      "date": "2026-09-11",          // date on the bank statement
      "detected_at": "2026-09-11 21:05:31",  // when the system read it (WIB)
      "type": "credit", "amount": 150123, "balance": 933620488,
      "description": "TRSF E-BANKING CR ...",
      "payment": { "id": "pay_x8Kj2…", "reference": "INV/001", "status": "pending" } }
  ],
  "page": 1, "has_more": true,
  "accounts": [ { "bank": "mandiri", "jenis_rekening": "mandiri_bisnis", "label": "Mandiri Bisnis",
                  "account_number": "1240007571525", "account_name": "Cloud Hosting Indonesia" } ]
}

payment is filled in when a credit mutation's amount exactly matches one of your bank-transfer invoices from the last 60 days (the unique code is what makes matching possible). Banks only record the date, not the transaction time; detected_at is when the system read the mutation, not the time of the transfer at the bank.

Ask the system to check now

POST /v1/payments/{id}/recheck

Use it when a customer says they have transferred. The system reads the account mutations right away and matches the amount. The response is the payment object plus changed. If the transferred amount is exact, the status becomes paid and the usual payment.paid webhook is sent.

Proof of transfer from the customer

A customer who transferred the wrong amount can upload proof of transfer on the payment page. You can fetch that proof through the API, so your support team never has to ask for confirmation again: once the proof is there, check it and mark the invoice paid.

GET /v1/payments?has_proof=true&status=pending     # your support queue
GET /v1/payments/{id}                               # details + proofs array
GET /v1/payments/{id}/proofs                        # proofs only
GET /v1/payments/{id}/proofs/{proof_id}             # the file itself (JPG/PNG/PDF)
{
  "proofs": [
    { "id": "bkt_7Qa…", "filename": "receipt.jpg", "mime": "image/jpeg", "size": 184320,
      "amount": 150000,                       // amount according to the customer
      "note": "transfer from BCA, Silvia",
      "uploaded_at": "2026-09-12T09:15:22.000Z",
      "url": "https://pay.doit.id/v1/payments/pay_x8Kj2…/proofs/bkt_7Qa…" }
  ]
}

url is opened with the same API key (Authorization header), so you can render it straight into your internal dashboard. When a customer uploads a proof we also send a payment.proof_uploaded webhook containing the payment object plus proof, so your support queue can appear without polling. A system that does not know this event type can simply answer 200 and ignore it.

Mark as paid manually

POST /v1/payments/{id}/confirm
{ "by": "cs-rani", "note": "transferred Rp150,000 without the unique code", "mutation_id": "mt_9f2…" }

Bank transfer only, because the funds land directly in your account, so you are the one who verifies them. VA and QRIS are confirmed by the provider and answer 400 confirm_not_allowed. Make sure the money has really arrived before calling it. The status becomes paid, the payment.paid webhook is sent, and the payment carries the lunas_manual flag in flags. All three body fields are optional and are stored only as an audit note.

Without any coding: the proof of transfer uploaded by the customer and the Tandai lunas button are available in the portal, under Payments.

WooCommerce plugin

A WooCommerce store can accept QRIS, Virtual Account, and bank transfer without writing code: install the plugin, enter your API key, copy the webhook URL. Customers are sent to the doit.id payment page, and the order switches to Processing the moment the payment lands. Works with classic checkout and Checkout Blocks, and with HPOS.

Download doit-woocommerce.zip Open source (GPLv2), PHP 7.4+, WooCommerce 7+.

  1. Install. WordPress → Plugins → Add New → Upload Plugin → choose the zip → Activate.
  2. Keys. WooCommerce → Settings → Payments → doit.id. Enter the API key and signing secret from the doit.id portal (Developer menu). Start in test mode: pb_test_ keys, no real money.
  3. Webhook. Copy the Webhook URL shown on that settings page (it looks like https://your-store/?wc-api=doit) into your app's webhook URL in the portal. Without it orders will not be marked paid automatically; the plugin also checks the status when the customer returns to the thank-you page, but the webhook is the primary path.
  4. Store currency must be IDR. Amounts are rounded to whole rupiah.
  5. Test. Place an order in test mode, open the payment page, then press Simulate paid in the portal: the order becomes Processing and the order note shows the payment id.
  6. Go live. Once your account is approved, enter the live keys and turn test mode off.

Behaviour worth knowing. Invoices expire per your setting (default 60 minutes); after that the order is marked Failed and the customer can pay again from My Account, which creates a new invoice. A still-valid invoice is reused, never duplicated. Every webhook event is verified with the signature and de-duplicated per event id. The invoice reference on doit.id is the WooCommerce order number.

Error codes

Every error has the same shape: a stable code for machines, a message that names the offending value, and a doc_url linking here:

{ "error": { "code": "…", "message": "…", "doc_url": "…" } }
HTTPCodeMeaning & fix
401missing_api_keyNo Authorization: Bearer … header. Add your API key.
401invalid_api_keyUnknown key; check it pasted whole and the application still exists.
403app_disabledThe application owning this key is disabled. Re-enable it in the portal → Developer.
403rail_not_liveThis method isn't approved for live on your account yet (approval is per rail; VA may go live before QRIS). The message lists your active rails; full status in the portal. Sandbox is unaffected.
400method_unknownUnknown rail/va_bank combination; see the methods table.
403method_unavailableThis method isn't available on the platform yet (e.g. bank partnership pending).
403method_disabledYou switched this method off; re-enable it in portal → Payment Methods.
400live_key_requiredAccount mutations can only be read with a live API key; sandbox keys are rejected.
400no_bank_accountNo Account Mutation bank account is set up yet. Add one in the portal → Mutasi Rekening.
400bank_not_foundThe bank parameter matches none of your accounts.
503mutations_unavailableThe mutation reader is temporarily unavailable; try again later.
502mutations_failedReading mutations failed; retry, the message carries the reason.
502recheck_failed/recheck could not reach the provider or read mutations; the payment is unchanged.
400confirm_not_allowedManual confirmation is for the transfer rail only; VA and QRIS are confirmed by the provider.
409payment_not_pendingThe payment is already paid, cancelled, or processed; it cannot be confirmed again.
404proof_not_foundNo proof of transfer with that id exists on this payment.
403platform_disabledPlatform is not enabled on your account; activation is per account, contact doit.id.
409submerchant_existsThis email is already registered as a sub-merchant; the id is in the message and the Location header.
404submerchant_not_foundNo such sub-merchant under your account.
403submerchant_not_yoursThe for-sub-merchant header points at a sub-merchant that is not yours.
409submerchant_app_missingThat sub-merchant has no app in this environment yet; the live app opens once their KYC is approved.
409bank_lockedThe payout account is already registered and locked; changes go through doit.id with re-verification.
400onboarding_incompleteRequired sub-merchant fields or documents are still missing; details are in the message and in GET .../onboarding.
409onboarding_lockedThe sub-merchant registration has been submitted and is under review, so it cannot be changed.
400document_kind_invalidUnknown document kind; valid choices are in the message and in documents.
400document_type_invalidUnsupported file type. Also document_empty (empty body) and 413 document_too_large (over 25 MB).
400name_requiredThe name field is required.
400email_invalidInvalid email format.
400type_invalidtype must be "personal" or "business".
400bank_incompletebank_code, account_no, and account_name are required.
400missing_idempotency_keyEvery POST needs an Idempotency-Key header. Use a unique ID from your system.
400invalid_requestA field is wrong; the message names which one and its correct format.
404payment_not_foundNo such ID in your account. Test keys can't read live payments (and vice versa; check tenant & env).
409payment_not_refundableRefunds apply to paid payments only.
400invalid_refund_amountAmount exceeds what's left to refund.
502provider_refund_failedThe provider rejected the refund; message carries the original reason.
429rate_limitedOver the 120 req/min limit. Wait per Retry-After, then repeat the same request.
500internal_errorOur fault, already in our logs. Retry with the same Idempotency-Key; it's safe.
503service_unavailableThe service is recovering (for example, failing over to the standby data center). This is temporary: wait per Retry-After, then repeat the same request with the same Idempotency-Key. Do not mark the transaction permanently failed.

Sandbox

New accounts start in sandbox mode; nothing to wait for before you write code. Sandbox behaviour is identical to production (endpoints, formats, errors), except:

Business entities: several companies in one account

Not sub-merchants. If you yourself own more than one company, add each as a business entity under the same account (portal, Business entities menu). Every entity goes through its own verification (KYC) and gets its own payout account; the integration, API keys, payment connection, and team stay one.

No new API parameter. The entity is chosen when an app is created in the portal, and every invoice born from that app is stamped as belonging to that entity: it shows the entity name on the payment page and is forwarded to the entity's account. So pick the right app API key; everything else is exactly like a regular invoice.

Know whose it is. POST/GET /v1/payments responses and webhook payloads for invoices from an entity's app carry entity ({ id, name }). Invoices from the main entity's apps do not carry this field.

POST /v1/payments   (API key of an app owned by the entity)
{ "amount": 150000, "reference": "INV-0001" }

→ 201
{ "id": "pay_...", "status": "pending", "amount": 150000, "reference": "INV-0001",
  "entity": { "id": "ent_...", "name": "PT Contoh Sistem Terintegrasi" }, ... }

webhook payment.paid
{ "id": "evt_...", "type": "payment.paid",
  "entity": { "id": "ent_...", "name": "PT Contoh Sistem Terintegrasi" },
  "data": { ... } }

Rules we keep. A live app can only be created for an entity that is approved with its payout account in place; sandbox can come first. An app's entity cannot be changed once the app has transactions: create a new app. The Rp10,000 payout fee applies per transfer, so per entity per period.

Platform: sub-merchants via API

Early access, activated per account. For SaaS, billing vendors, and aggregators opening payments to their own customers. Prerequisites: a verified account, a platform agreement, then activation by doit.id; without it every endpoint below answers 403 platform_disabled. More on the product page.

Register a sub-merchant. From the portal (Sub-merchant menu) or through the API. Idempotent per email: an already-registered email returns 409 with the existing id. sandbox.api_key is shown once.

POST /v1/submerchants
{ "type": "personal", "name": "Warung Kopi Sudirman",
  "email": "owner@warkop.id", "phone": "0812xxxxxx",
  "profile": {
    "category": "goods", "industry": "food_beverage",
    "description": "Coffee shop, orders placed through our app",
    "monthly_transactions": 400, "monthly_amount": 12000000,
    "address": { "city": "Jakarta Selatan", "postal_code": "12190" },
    "owner": { "name": "Budi Santoso", "id_number": "3174..." } } }

→ 201
{ "id": "ten_a1b2c3", "status": "draft",
  "onboarding_url": "https://doit.id/daftar/?ref=...",
  "prefilled": ["category", "industry", "address.city", ...],
  "sandbox": { "app_id": "app_...", "api_key": "pb_test_..." } }

Pre-fill on your sub-merchant's behalf. Everything you send in profile shows up already filled in on their verification form; the business owner only reviews it and uploads the documents. Known fields: business_type, category (goods | services | both), industry (hosting_saas | digital_goods | isp_telco | education | retail | food_beverage | professional_services | health_beauty | travel | property | donation | other), entity_type (pt | pt_perorangan | cv | koperasi | yayasan | ud), annual_revenue (under_500m | 500m_2.5b | 2.5b_10b | over_10b), description, monthly_transactions, monthly_amount, website, social_media, other_channels, established_at (YYYY-MM-DD); address {street, province, city, district, village, postal_code}; owner {name, email, id_number, phone, birth_place, birth_date, gender (male | female), id_address, tax_id}; commissioner {name, email, id_number, phone}; legal {company_tax_id, nib, deed_number, deed_date, notary, decree_number, decree_date, amendment}; bank {name, account_no, account_name, branch}; plus legal_name, brand, applicant_name, phone. Unknown fields or invalid choice values are rejected with 400 profile_invalid listing the problems.

Two ways to complete verification (KYC). Both end in the same doit.id review queue, and you can mix them for a single sub-merchant. (1) Show onboarding_url: the business owner fills in the data and uploads the documents on a doit.id page; we also send the same link to their email as a fallback. (2) Verification inside your app: the sub-merchant never opens doit.id at all. Sandbox works without waiting for verification; production unlocks once approved.

Verification inside your app (without opening doit.id)

You collect the data and documents in your own app and pass them on through the API. In the portal, the Lengkapi KYC button in the Sub-merchant menu does the same thing. One requirement cannot be skipped: the business owner's consent. Before submission we email the owner a summary of what was entered (business name, owner, payout account, list of documents) together with a six-digit code. The owner enters that code in your app and you pass it to us. Without the code, the registration cannot be submitted.

GET   /v1/submerchants/{id}/onboarding        fields, documents, and what is still missing
PATCH /v1/submerchants/{id}/onboarding        save fields (same shape as "profile")
PUT   /v1/submerchants/{id}/documents/{kind}  upload one document (raw file body)
POST  /v1/submerchants/{id}/consent           send the consent code to the owner's email
POST  /v1/submerchants/{id}/submit            { "consent_code": "123456" }

1. Read the requirements. GET .../onboarding returns fields (each with field, label, section, type, options, required, value), so you can render your form from this response and it follows the sub-merchant type automatically (individuals are not asked for a deed or decree). It also returns missing_fields, documents, missing_documents, ready, and blocking (why it cannot be submitted yet; code: fields_missing | documents_missing | bank_name_mismatch | type_mismatch). Labels are in Indonesian.

{ "kyc_status": "draft", "editable": true, "type": "personal",
  "missing_fields": ["owner.id_number", "bank.account_no", ...],
  "documents": [ { "kind": "ktp_direktur", "label": "KTP pemilik",
                   "required": true, "uploaded": false }, ... ],
  "missing_documents": ["ktp_direktur", "selfie_ktp", "buku_rekening"],
  "ready": false,
  "blocking": { "code": "fields_missing", "message": "Isian wajib belum lengkap: ..." },
  "consent": { "sent_to": "ow•••@warkop.id", "requested": false, "given_at": null } }

2. Save fields. PATCH .../onboarding accepts the same object as profile, partial and repeatable. The response is the latest state plus accepted. The payout account goes in bank; the account holder name must match the business name (or the ID card name for individuals), otherwise blocking.code becomes bank_name_mismatch.

3. Upload documents. Send the file as a raw body (not multipart) with the file's Content-Type, 25 MB at most. Accepted: PDF, JPG, PNG, HEIC, WEBP, ZIP. Pass the file name in the x-filename header; if omitted, it is derived from the content type. Uploading the same kind replaces the previous file.

curl -X PUT https://pay.doit.id/v1/submerchants/ten_a1b2c3/documents/ktp_direktur \
  -H "Authorization: Bearer pb_live_..." \
  -H "Content-Type: image/jpeg" -H "x-filename: ktp.jpg" \
  --data-binary @ktp.jpg

4. Ask for consent, then submit. Once ready: true, call POST .../consent; the code is valid for 30 minutes, expires after five wrong attempts, and can be re-requested once per minute. Then POST .../submit with consent_code. The code is bound to what the owner saw: if fields or documents change after the code was sent, the answer is 409 consent_stale and a new code must be requested. After submission, kyc_status becomes masuk and the fields are locked (409 onboarding_locked); repeating submit is safe and returns already_submitted. If our team asks for fixes, the status goes back to perbaikan, review_note carries the note, and editing reopens.

Every file and registration you send is recorded under your platform's name, and the owner's consent is kept as evidence. doit.id's document review does not change: the only difference is who types and uploads.

What verification asks for (on par with a typical payment provider's sub-merchant form, so your sub-merchants can prepare it up front): business identity (name, type, category, industry, a short description), transaction profile (estimated monthly count and value; this sets the transaction limit), full address down to the village and postal code as printed on the ID card or NIB, proof of business (website, social media, logo), then legal documents: individuals need only an ID card, a selfie, and proof of bank account (tax number optional), while companies add the deed, ministry approval, NIB, company tax ID, plus the director's ID card and tax number. Finally, a payout account in the business owner's name.

Track status. GET /v1/submerchants to list, GET /v1/submerchants/{id} for detail (kyc_status: draft → masuk → diproses → disetujui | ditolak, or perbaikan when fixes are requested). Set your app's webhook_url and every status change is delivered as a submerchant.updated event carrying { id, name, kyc_status } with the standard signature, so there is no need to poll.

Payout account. POST /v1/submerchants/{id}/bank with bank_code, account_no, account_name. The name must match the business owner and is verified at activation. Registered once, then locked; a second call answers 409 bank_locked. Account changes go through doit.id with re-verification.

Payout status. GET /v1/submerchants/{id}/settlements returns the payout history (pending | processing | sent | failed) for you to render in your app. Settlement runs automatically to each sub-merchant's bank account; there is no balance and no withdrawal flow to build.

One API key for every sub-merchant. Send the header for-sub-merchant: <child id> on any payments endpoint and the request runs as that sub-merchant: the invoice, methods, routing and webhooks are all theirs. The API key must belong to the parent, otherwise 403 submerchant_not_yours. Without the header, each sub-merchant's own API key keeps working as before.

POST /v1/payments
Authorization: Bearer <PARENT api key>
for-sub-merchant: ten_abc123
Idempotency-Key: inv-2026-0001

{ "amount": 150000, "reference": "INV-0001" }

The response and the sub-merchant's webhook payload carry sub_merchant ({ id, name }), so you may point EVERY child's callbacks at a single endpoint and still know where each one came from without mapping ids yourself.

Commission. PATCH /v1/submerchants/{id}/komisi with flat (rupiah) and/or percent (two decimals at most, e.g. 2.75). Commission is frozen when the payment is paid, so changing your rate today does not move yesterday's transactions and cannot be used to bill retroactively.

Commission is charged to the merchant: it reduces the sub-merchant's net and never increases what the buyer pays. The sub-merchant's net is kept positive; if your rule exceeds the net of a given transaction, the amount charged is trimmed and the difference is visible in the breakdown. In the same settlement cycle the sub-merchant receives their net and you receive the sum of all your children's commission in your own bank account. There is still no stored balance and no withdrawal flow to build.

Duitku compat mode: migrate without changing code

Already running a Duitku integration? You don't need to rewrite anything. Enable it yourself in the portal: Developer → New app → answer “Migrating from an existing payment gateway?” with Duitku. The credentials appear once, right next to the API key. Already have an app and didn't pick it at creation? Same link shows up on that app's row once activated, so you can look it up again if you forgot to copy it. doit.id exposes endpoints compatible with the Duitku Payment Gateway API v2; just change three config values:

ConfigBeforeAfter
Base URLhttps://passport.duitku.comhttps://pay.doit.id
merchantCodefrom Duitkushown when you enable this mode in the portal
apiKeyfrom Duitkuprovided together, as one package

Faithfully mirrored: POST /webapi/api/merchant/v2/inquiry (signature MD5(merchantCode + merchantOrderId + paymentAmount + apiKey); the response carries reference, paymentUrl, vaNumber, qrString), POST /webapi/api/merchant/transactionStatus, and the callback to your callbackUrl as form-urlencoded with signature MD5(merchantCode + amount + merchantOrderId + apiKey), the same formulas your code verifies today.

paymentMethod mapping: VA codes (BC=BCA, M2=Mandiri, BR=BRI, I1=BNI, B1=CIMB, BT=Permata, VA=Maybank, NC=BNC, DM=Danamon, BV=BSI) → the matching bank's Virtual Account; QRIS/e-wallet codes (SP, SA, NQ, DQ, OV, DA, LF, LA, etc.) → QRIS payable by every app. paymentUrl points to the doit.id hosted payment page.

Callback fields echoed back verbatim

Just like Duitku, the callback echoes back the values you sent at inquiry time. Many systems rely on these to identify what the payment was for, so they come back exactly as received:

Callback fieldWhere it comes from
additionalParamYour additionalParam at inquiry; commonly used to carry your internal account or invoice ID.
merchantUserIdYour merchantUserId at inquiry.
productDetailYour productDetails at inquiry.
merchantOrderIdYour own order ID.
reference / publisherOrderIdThe doit.id payment ID.

The contents of additionalParam and productDetails also appear on the payment detail in the portal and are included in search, which helps finance teams match a payment to an invoice, since reference numbers are usually meaningless digits.

The callback goes to the callbackUrl in your inquiry request, per transaction, not to the webhook URL in the Developer menu. They are different channels: the native webhook sends JSON with a PayBridge-Signature header, while your Duitku endpoint expects form-urlencoded + MD5. Putting your Duitku callback URL in the native webhook field makes your endpoint receive a payload it cannot parse. While you use compat mode, leave the native webhook field empty.
Compat mode is a migration bridge; new integrations should use the simpler doit.id API directly. Both write to the same ledger, so you can migrate gradually: legacy systems through the compat endpoints, new features through the main API, one dashboard.

Xendit compat mode: the Invoice API lives on here

Xendit is retiring its legacy Invoice API and asking users to rewrite against Payment Session. If your system calls POST /v2/invoices, that code keeps working here as-is; change two config values. Enable it yourself in the portal: Developer → New app → answer “Migrating from an existing payment gateway?” with Xendit, and fill in your legacy system's callback URL in the same field (required for Xendit; their own dashboard sets it once too, not per request). The credentials appear once, right next to the API key.

Xenditdoit.id
Base URLhttps://api.xendit.cohttps://pay.doit.id
Secret key (Basic auth)from the Xendit dashboardshown when you enable this mode in the portal
Callback token (x-callback-token)from the Xendit dashboardprovided together, as one package
Callback URLset in the dashboardset at activation in the portal (contact us to change it)

What is emulated, faithfully to the legacy contract:

EndpointBehaviour
POST /v2/invoicesexternal_id, amount, invoice_duration, payer_email, description, success_redirect_url → response carries invoice_url (our hosted payment page: the payer picks VA or QRIS), status PENDING
GET /v2/invoices/{id}status PENDING / PAID / EXPIRED
GET /v2/invoices?external_id=…invoices sharing that external_id (it is not unique; every POST creates a new invoice, just like the original)
POST /invoices/{id}/expirecancel a pending invoice (the /v2/…/expire variant is accepted too)
Paid callbackJSON to your callback URL with an x-callback-token header; carries external_id, status: "PAID", paid_amount, paid_at, payment_method (BANK_TRANSFER/QR_CODE), payment_channel, bank_code. Non-2xx responses are retried with increasing backoff.
The limits, so there are no surprises: only the Invoice API is emulated (hosted checkout, VA + QRIS). The dedicated Fixed VA API, QR Code API, direct e-wallets, cards, and payouts/disbursements are not; payouts never will be, because we never hold funds. The SETTLED status is unused; paid is always reported as PAID.

Onboarding → live

Live access doesn't wait for emails or sign-off meetings. The Onboarding page in the portal lists 8 items that are checked off automatically from real evidence as your integration performs them in sandbox:

ItemHow to pass
Create a paymentYour first POST /v1/payments.
IdempotencyResend a request with the same Idempotency-Key.
Read statusGET /v1/payments/… (the safety-net pattern).
Webhook configuredSet a webhook URL in the Developer menu.
Receive payment.paidSimulate paid; your endpoint replies 2xx.
Receive payment.expiredCreate a payment with expires_in: 60, let it expire.
Reject forged signaturesThe Webhook test button sends an event with a bad signature; your endpoint must reject it.
Survive duplicate webhooksThe webhook test sends the same event twice; both must get a 2xx.

Eight checks complete → live access opens that very second: create a pb_live_ key, swap the key prefix in your config, done.

Payment-method activation on live happens per rail, following provider approval: VA may be approved before QRIS is done processing. The status is visible in the portal; unapproved rails return rail_not_live on live and remain fully usable in sandbox.

These items aren't box-ticking; they are precisely the most common causes of payment incidents, so completing onboarding means your integration already survives the scenarios that usually only surface in production.

doit.id · pay.doit.id · Questions? care@idcloudhost.com