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:
| Key | Behaviour |
|---|---|
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
| Field | Type | Description |
|---|---|---|
amount | integer, required | Whole rupiah: 150000 = Rp150,000. No decimals. |
rail | string | "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. |
reference | string, required | The invoice ID in your system (≤128 chars). Echoed back in webhooks. |
va_bank | string | Required when rail=va or rail=transfer (bca/mandiri/bni/bsi); see the bank code table below. |
expires_in | integer | Validity of the invoice in seconds, ≥60. Defaults: QRIS 3600, VA 86400. Note: the QR image itself is shorter-lived, see the box below. |
customer | object | Optional: {"name","phone"}; the name appears on the VA. |
return_url | string | Optional http(s) URL; adds a "back to merchant" button on the hosted payment page. |
metadata | object | Anything 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"
}
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.Payment methods & service fees
| Method | va_bank code | Default service fee |
|---|---|---|
| QRIS (all banks & e-wallets) | … | 0% up to Rp100.000, 0.70% above that; always borne by merchant (BI regulation) |
| Mandiri Virtual Account | bmri | Rp4,000 |
| BSI Virtual Account | bsyi | Rp4,000 |
| BNI Virtual Account | bnia | Rp3,000 |
| BRI Virtual Account (BRIVA) | brin | Rp3,000 |
| CIMB Niaga Virtual Account | cimb | Rp3,000 |
| Permata Virtual Account | permata | Rp3,000 |
| Maybank Virtual Account | maybank | Rp3,000 |
| Danamon Virtual Account | danamon | Rp3,000 |
| BNC Virtual Account (Bank Neo Commerce) | neo | not yet available |
| BCA Virtual Account | bca | not yet available (method_unavailable) |
| BCA Bank Transfer (Account Mutation) | bca with rail=transfer | Rp0 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=transfer | Rp0 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=transfer | Rp0 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=transfer | Rp0 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.
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.
- Prerequisites: add the account in the portal under Mutasi Rekening (enter your internet-banking credentials; they only transit for login and are never stored), and keep an active Account Mutation subscription (prepaid, Rp300,000/month or Rp3,000,000/year per account). The method only appears on the payment page once the account is verified.
- Usable before KYC completes. Because funds go directly to your account, this method is available while VA and QRIS verification is still pending.
- Rp0 service fee per transaction; the unique code is part of the amount, not a fee, and its assignment is locked to the merchant.
- BRI is deliberately not offered.
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.
- Enable the BCA service. Apply for Balance Inquiry and Account Statement for your company account. Request read-only access if BCA offers that option.
- 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.
- 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.
- 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.
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.
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
| Field | Type | Description |
|---|---|---|
name | string, required | Plan name (≤100 chars), e.g. "Internet 20 Mbps". |
amount | integer, required | Whole rupiah per period. |
interval | object, required | {"unit","count"}. unit: day/week/month; count 1–366. Monthly: {"unit":"month","count":1}. |
rail | string | any (default), qris, va, or transfer. With any the customer picks on the hosted page. |
va_bank | string | Required when rail=va or rail=transfer (bca/mandiri/bni/bsi); see the bank code table. |
expires_in | integer | Validity of each invoice (seconds, ≥3600). Default 604800 (7 days). |
kirim_email | boolean | Default 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.
| Field | Type | Description |
|---|---|---|
plan_id | string, required | Plan ID. |
customer_name | string, required | Customer name (shown on VA & hosted page). |
customer_phone | string | Optional; you use it to send invoices yourself. |
customer_email | string | Optional; included in the webhook payload. |
reference | string | Your own customer ID; becomes the prefix of each invoice's reference. |
start_at | string | ISO-8601. Empty/now → first invoice issues now. Future → starts later (e.g. trial end). |
metadata | object | Free-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.
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:
| Event | When |
|---|---|
payment.paid | Payment received. Complete payload, enough to mark the invoice paid & unlock service without another API call. |
payment.expired | Validity window passed. |
invoice.created | A new recurring invoice issued. Payload = payment (with hosted_url) + a subscription field. See Recurring Billing. |
payment.proof_uploaded | The 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.failed | Refund outcome. |
webhook.test | Sent 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:
- Dedupe by event
id: delivery is at-least-once; duplicates can happen. - Never rely on arrival order: the truth is in the
statusfield, not the order.
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.
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:
| Tool | What 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.
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+.
- Install. WordPress → Plugins → Add New → Upload Plugin → choose the zip → Activate.
- 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. - 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. - Store currency must be IDR. Amounts are rounded to whole rupiah.
- 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.
- 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": "…" } }
| HTTP | Code | Meaning & fix |
|---|---|---|
| 401 | missing_api_key | No Authorization: Bearer … header. Add your API key. |
| 401 | invalid_api_key | Unknown key; check it pasted whole and the application still exists. |
| 403 | app_disabled | The application owning this key is disabled. Re-enable it in the portal → Developer. |
| 403 | rail_not_live | This 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. |
| 400 | method_unknown | Unknown rail/va_bank combination; see the methods table. |
| 403 | method_unavailable | This method isn't available on the platform yet (e.g. bank partnership pending). |
| 403 | method_disabled | You switched this method off; re-enable it in portal → Payment Methods. |
| 400 | live_key_required | Account mutations can only be read with a live API key; sandbox keys are rejected. |
| 400 | no_bank_account | No Account Mutation bank account is set up yet. Add one in the portal → Mutasi Rekening. |
| 400 | bank_not_found | The bank parameter matches none of your accounts. |
| 503 | mutations_unavailable | The mutation reader is temporarily unavailable; try again later. |
| 502 | mutations_failed | Reading mutations failed; retry, the message carries the reason. |
| 502 | recheck_failed | /recheck could not reach the provider or read mutations; the payment is unchanged. |
| 400 | confirm_not_allowed | Manual confirmation is for the transfer rail only; VA and QRIS are confirmed by the provider. |
| 409 | payment_not_pending | The payment is already paid, cancelled, or processed; it cannot be confirmed again. |
| 404 | proof_not_found | No proof of transfer with that id exists on this payment. |
| 403 | platform_disabled | Platform is not enabled on your account; activation is per account, contact doit.id. |
| 409 | submerchant_exists | This email is already registered as a sub-merchant; the id is in the message and the Location header. |
| 404 | submerchant_not_found | No such sub-merchant under your account. |
| 403 | submerchant_not_yours | The for-sub-merchant header points at a sub-merchant that is not yours. |
| 409 | submerchant_app_missing | That sub-merchant has no app in this environment yet; the live app opens once their KYC is approved. |
| 409 | bank_locked | The payout account is already registered and locked; changes go through doit.id with re-verification. |
| 400 | onboarding_incomplete | Required sub-merchant fields or documents are still missing; details are in the message and in GET .../onboarding. |
| 409 | onboarding_locked | The sub-merchant registration has been submitted and is under review, so it cannot be changed. |
| 400/409 | consent_required | consent_code was not sent, or no valid code exists yet; call POST .../consent first. |
| 409 | consent_stale | Fields or documents changed after the consent code was sent; request a new code. |
| 400 | consent_invalid | The consent code is wrong or expired; after five wrong attempts the code is void. |
| 429 | consent_too_soon | A code was just sent; wait one minute before requesting another. |
| 400 | document_kind_invalid | Unknown document kind; valid choices are in the message and in documents. |
| 400 | document_type_invalid | Unsupported file type. Also document_empty (empty body) and 413 document_too_large (over 25 MB). |
| 400 | name_required | The name field is required. |
| 400 | email_invalid | Invalid email format. |
| 400 | type_invalid | type must be "personal" or "business". |
| 400 | bank_incomplete | bank_code, account_no, and account_name are required. |
| 400 | missing_idempotency_key | Every POST needs an Idempotency-Key header. Use a unique ID from your system. |
| 400 | invalid_request | A field is wrong; the message names which one and its correct format. |
| 404 | payment_not_found | No such ID in your account. Test keys can't read live payments (and vice versa; check tenant & env). |
| 409 | payment_not_refundable | Refunds apply to paid payments only. |
| 400 | invalid_refund_amount | Amount exceeds what's left to refund. |
| 502 | provider_refund_failed | The provider rejected the refund; message carries the original reason. |
| 429 | rate_limited | Over the 120 req/min limit. Wait per Retry-After, then repeat the same request. |
| 500 | internal_error | Our fault, already in our logs. Retry with the same Idempotency-Key; it's safe. |
| 503 | service_unavailable | The 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:
- Payments involve no real money.
- Settlement is triggered manually: open the payment in the portal → Simulate paid,
a real
payment.paidwebhook is sent to your server. - The road to production runs through Onboarding: self-service, no manual review to wait for.
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:
| Config | Before | After |
|---|---|---|
| Base URL | https://passport.duitku.com | https://pay.doit.id |
| merchantCode | from Duitku | shown when you enable this mode in the portal |
| apiKey | from Duitku | provided 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 field | Where it comes from |
|---|---|
additionalParam | Your additionalParam at inquiry; commonly used to carry your internal account or invoice ID. |
merchantUserId | Your merchantUserId at inquiry. |
productDetail | Your productDetails at inquiry. |
merchantOrderId | Your own order ID. |
reference / publisherOrderId | The 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.
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.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.
| Xendit | doit.id | |
|---|---|---|
| Base URL | https://api.xendit.co | https://pay.doit.id |
| Secret key (Basic auth) | from the Xendit dashboard | shown when you enable this mode in the portal |
Callback token (x-callback-token) | from the Xendit dashboard | provided together, as one package |
| Callback URL | set in the dashboard | set at activation in the portal (contact us to change it) |
What is emulated, faithfully to the legacy contract:
| Endpoint | Behaviour |
|---|---|
POST /v2/invoices | external_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}/expire | cancel a pending invoice (the /v2/…/expire variant is accepted too) |
| Paid callback | JSON 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. |
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:
| Item | How to pass |
|---|---|
| Create a payment | Your first POST /v1/payments. |
| Idempotency | Resend a request with the same Idempotency-Key. |
| Read status | GET /v1/payments/… (the safety-net pattern). |
| Webhook configured | Set a webhook URL in the Developer menu. |
Receive payment.paid | Simulate paid; your endpoint replies 2xx. |
Receive payment.expired | Create a payment with expires_in: 60, let it expire. |
| Reject forged signatures | The Webhook test button sends an event with a bad signature; your endpoint must reject it. |
| Survive duplicate webhooks | The 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.
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