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 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:
| 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", 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; 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,
"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.70%, 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) |
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.
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.
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, or va. With any the customer picks on the hosted page. |
va_bank | string | Required when rail=va; 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. |
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.
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 | 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.
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