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 portalDeveloper 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 and click Simulate paid. 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", 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; 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,
  "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.70%, 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)

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 (0.70%) must not be passed on to the buyer; its fee assignment is locked to Merchant and cannot be changed.

Hosted payment page: no frontend needed at all

Every payment carries a hosted_url: a ready-made payment page you can send straight to the customer (WhatsApp, email, SMS). It shows your business name, the amount, 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.

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, or va. With any the customer picks on the hosted page.
va_bankstringRequired when rail=va; 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.
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.

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

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