DanaraPay Integration Guide
Registering the DanaraPay tenant credential, routing a payment intent to the danarapay rail, the QRIS flow, and the poll-only acceptance model
DanaraPay is an acceptance (money-in) PSP served by the Payments Service. It has no endpoints of its own — you drive it entirely through the payments-service PSP-credential and routing operations, and through ordinary payment intents. This page is the integration guide; the request/response reference for every operation named here lives on the Payments Service page.
Two halves of DanaraPay live in different services, and the split is deliberate. Acceptance rides the psp.PaymentProvider seam in payments-service; disbursement rides rails.Rail in the Disbursement Service. The acceptance adapter therefore returns ErrDisbursementNotSupported for CreateDisbursement and DisbursementStatus — one provider with two payout paths would mean two ledgers and no shared dedup handle.
Base URLs
| Environment | Hostname |
|---|---|
| Sandbox | https://sandbox.505pay.link |
| Production | https://api.505pay.link |
All operations on this page authenticate with a Bearer JWT issued by the Auth Service. They are tenant-admin console operations, not merchant server-to-server calls.
1. Register the tenant credential
DanaraPay authenticates with a header pair, not a signature. Every request the adapter makes carries x-username and x-api-key:
x-username: <your DanaraPay partner username>
x-api-key: <your DanaraPay API key>
Accept: application/jsonRather than adding DanaraPay-specific columns, the integration reuses the existing tenant_psp_credentials shape:
| Credential field | DanaraPay meaning | Storage |
|---|---|---|
client_id | the x-username header | plaintext (an identity) |
secret_key | the x-api-key header | AES-GCM encrypted at rest |
base_url | the provider origin | plaintext config, not a secret |
Register it with createPspCredential (POST /v1/payments/tenant/psp-credentials):
curl -X POST https://sandbox.505pay.link/v1/payments/tenant/psp-credentials \
-H "Authorization: Bearer $PAYOPS_JWT" \
-H "Content-Type: application/json" \
-d '{
"provider_name": "danarapay",
"client_id": "<YOUR_DANARAPAY_USERNAME>",
"secret_key": "<YOUR_DANARAPAY_API_KEY>",
"base_url": "https://api-stg.danarapay.com"
}'await fetch('https://sandbox.505pay.link/v1/payments/tenant/psp-credentials', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYOPS_JWT}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
provider_name: 'danarapay',
client_id: process.env.DANARAPAY_USERNAME, // -> x-username
secret_key: process.env.DANARAPAY_API_KEY, // -> x-api-key
base_url: 'https://api-stg.danarapay.com', // staging
}),
});webhook_secret is optional here and there is nothing to put in it: DanaraPay signs nothing it sends, so demanding a value would only make operators invent one that protects nothing.
Rotate the secret with rotatePspCredential (PUT /v1/payments/tenant/psp-credentials/{id}/rotate). A base_url supplied on the rotate body decides where the new secret is sent, so it is re-validated against the same closed set as on create. Omitting base_url on a rotate preserves the stored value — the query coalesces it onto the current contents rather than falling back to production, precisely so that rotating a secret cannot silently move a staging credential onto the live gateway.
An EMPTY base_url falls back to PRODUCTION
An empty base_url means REAL MONEY
base_url is not optional in practice. When it is empty or whitespace-only, the adapter substitutes ProductionBaseURL = "https://partner.danarapay.com" — DanaraPay's live partner origin. A credential you intended as staging will transact against production.
Always set base_url explicitly. For staging that is https://api-stg.danarapay.com; production is https://partner.danarapay.com.
The origin allowlist
base_url is tenant-editable and the transport puts x-username and x-api-key on every request to it — so the origin a credential names is the origin that receives that credential. It is therefore matched by equality against a closed set of three hosts:
| Host | Role |
|---|---|
partner.danarapay.com | live partner origin |
api-stg.danarapay.com | staging |
api-demo.danarapay.com | second staging host (balance, tx-details) |
Notes that matter when you configure this:
- Host only, no path. Every internal path constant already begins
/api/…and the client joins base URL and path by plain concatenation, so a trailing/apicomposes/api/api/…and the gateway answers 404. httpsis mandatory for any of the three public hosts. The scheme rule is never relaxed.- Suffix matching is not used, so
partner.danarapay.com.exampleis refused rather than accepted. - Anything else returns
400 invalid_base_urllisting the allowed hosts. - On dev and test stacks only,
DANARAPAY_ALLOW_PRIVATE_ORIGINS=truewidens the host rule (and only the host rule) to non-routable destinations so the adapter can be pointed atmock-danarapay-service. It defaults tofalseand no production manifest sets it. Cloud instance-metadata addresses stay refused even with the opt-in on.
The check runs in two places: at credential-write time in the handler, and again in the adapter constructor for every path that builds a provider from a stored row.
2. Route a channel to danarapay
Registering a credential does not route anything. Routing is a separate, per-tenant table written with updatePaymentRouting (PUT /v1/payments/tenant/routing) and read with getPaymentRouting:
curl -X PUT https://sandbox.505pay.link/v1/payments/tenant/routing \
-H "Authorization: Bearer $PAYOPS_JWT" \
-H "Content-Type: application/json" \
-d '{
"routing": [
{ "channel_code": "qris", "provider_name": "danarapay", "priority": 1, "enabled": true }
]
}'Three things this endpoint refuses, all with 400:
- The body key is
routing, notroutes, and unknown fields are rejected outright (invalid_json). A silently-decoded typo once wiped a tenant's routing table. - An empty
routingarray needs an explicit{"clear": true}(empty_routing_requires_clear_flag), so a wipe is never an accident. - Each entry's provider must have a registered credential (
provider_not_registered) and a compiled adapter (provider_not_available). A credential row alone is not enough: accepting a route with no adapter would store a money path that can never carry money while reporting success.
PUT replaces the table — send every route you want to keep.
How a payment intent reaches the rail:
Three things to know about Router.Resolve:
- Routes are sorted by ascending priority (lower wins) and v1 has no automatic failover. If the priority-1 provider has no live credential you get
ErrCredentialMissingrather than a silent fallback to priority 2 — the operator is expected to fix routing. - The router re-checks the provider's own
Capabilities()as defence in depth. AvailableChannels()— which builds the public checkout channel grid — applies the same capability intersection, so a channel the provider does not claim never reaches a payer.
Which channels DanaraPay actually claims
Capabilities() currently returns QRIS only.
The adapter also implements six virtual-account channels (va_bri, va_mandiri, va_bni, va_permata, va_bca, va_cimb, mapped to DanaraPay bank codes 002, 008, 009, 013, 014, 022), but the constant vaAcceptanceSettleable is false, so they are withheld from the advertised set. The reason is settlement, not creation: a DanaraPay VA read reports counter_incoming_payment — a count of payments, never a sum — so every VA read returns a paid amount of 0 and the acceptance poller refuses to settle it. Withholding the channel is what stops a payer from paying a real bank VA that could never be credited.
3. The QRIS flow
CreateCharge for qris posts to DanaraPay's POST /api/payment-routing/create-transaction with list_enable_payment_method and list_enable_sof both set to "QRIS" — that is DanaraPay's complete allowed-value list for both fields — and need_frontend: false, so DanaraPay returns payment_info.qris_url rather than hosting its own checkout page. 505pay keeps the checkout surface and only borrows the QR.
Behaviour worth designing against:
- Idempotency is
partner_trx_id, set to the payment attempt id. DanaraPay publishes no idempotency header; this is its only dedup handle. A repeat of the same attempt returns business code203, which the adapter treats as success, not failure. - Amount bounds are pre-flighted. QRIS is accepted between 10,000 and 10,000,000 IDR. An out-of-range amount is refused locally before an attempt row exists, because the remote refusal is a generic code
990that does not say which bound was crossed. - Expiry is clamped to 1–60 minutes, defaulting to 30 when the intent carries no
expires_at. A zero would be read by DanaraPay as "expire immediately". - A missing
qris_urlis a hard failure.need_frontend: falsepromises it; without it the payer would have a page and nothing to scan, so the charge is not reported as created. sender_emailis deliberately not sent. It is optional, does nothing when DanaraPay hosts no page, and the live gateway rejects addresses it dislikes with an undocumented code247that fails the whole charge.
The charge comes back with provider_charge_id prefixed routing: (VA charges use va:). The prefix is how the poller knows which product minted the charge, since it receives nothing but the id — so the prefixes are permanent once a row carries one.
4. There are no callbacks — settlement is discovered by polling
DanaraPay signs nothing
DanaraPay does not sign its callbacks, so there is no signature to verify and no way to tell its POST from anyone else's. The integration registers no inbound webhook route at all: VerifyWebhook returns an unsupported-channel error. Terminal money state is confirmed only by an outbound check-status read.
Consequence: if the acceptance poller is not running, DanaraPay charges never settle. For other providers the poller is a backstop; for DanaraPay it is the only trigger.
Reading a status: two independent axes
POST /api/payment-routing/check-status returns two fields that look related and are not:
status.codeanswers only "did the gateway answer at all". It stays"000"on an EXPIRED transaction, so it can never decide settlement. A non-000code (for example"404"/"Trx not found", returned as a business code inside an HTTP 200 body) is surfaced as an error, leaving the attempt in whatever state it already had so that a transient fault can neither settle nor fail a charge.payment_statusdecides settlement, alone.
payment_status | Mapped status | Notes |
|---|---|---|
WAITING_PAYMENT | pending | what the live gateway actually sends for an unpaid charge |
PENDING | pending | kept only so a gateway that emits it is not misread |
COMPLETE | paid | the only paid signal |
EXPIRED | expired | terminal, unpaid |
| anything else | pending | an unpublished value must never fail a payer who may have paid |
On COMPLETE the paid amount is taken from received_amount — what the payer actually paid — never from request_amount, which would make an underpayment look exact and credit it in full. payment_received_time is parsed as WIB (UTC+7) despite the spec marking it UTC; the wire wins. settlement_time is deliberately not used as the paid timestamp: it is when funds reach the balance, not when the payer paid. A transaction can be fully paid with settlement_status: WAITING.
Poll cadence and retirement
The acceptance poller runs in-process inside payments-service, so a polled charge and a delivered webhook commit through one audited settle path.
| Behaviour | Value |
|---|---|
| Enabled by | PAYMENTS_ACCEPTANCE_POLLER_ENABLED (default false) |
| Tenant scope | PAYMENTS_ACCEPTANCE_POLLER_TENANTS (empty = every tenant) |
| Sweep interval | 30s |
| Attempt eligible after | 2 minutes old |
| Minimum gap between polls of one attempt | 1 minute (enforced in SQL via last_polled_at) |
| Rows claimed per sweep | 200 |
| Consecutive endpoint failures before retirement | 10 |
| Providers swept | danarapay only |
An attempt is eligible only while it is pending and its intent is open or partially_paid; the queue is ordered by last_polled_at ASC NULLS FIRST, so a never-polled row goes first and no cohort can starve the tail.
Terminal statuses map to charge events: paid, failed and expired. There is deliberately no cancelled case — cancellation is a webhook-only signal, and a polled charge that is no longer payable comes back expired or failed.
Paid with no amount is refused, not settled
If a provider reports a charge as paid but reports no amount, the poller refuses to settle and leaves the attempt pending. The settle core reads a zero paid-amount as "not reported" and credits the full expected amount, which is correct for a signed webhook and wrong for a poll — nothing has attested to the figure, so an underpayment would be credited in full. This is why the sweep is scoped to providers whose GetCharge actually populates the amount.
Single replica
The sweep's SELECT does not take SKIP LOCKED. Double-crediting is prevented downstream, but two replicas can issue duplicate GetCharge calls against a rate-limited gateway. Add SKIP LOCKED before scaling payments-service past one replica.
5. What DanaraPay does not offer
| Capability | Status |
|---|---|
| Refunds | Unsupported — the API publishes no refund endpoint |
| Inbound webhooks | Unsupported — nothing is signed, so nothing can be authenticated |
| Disputes / evidence | Unsupported — no dispute API |
| Disbursement | Not here — it rides the Disbursement Service danarapay rail |
Account inquiry is billable
ValidateAccount calls POST /api/account-inquiry to resolve the holder name registered with a beneficiary's bank. Every call raises an invoice, including one that finds nothing. Do not wire it to keystroke-level validation. The resulting invoices are projected into a read model and settled by an explicit operator action — see disbursementListRailInvoices, disbursementSyncRailInvoices and disbursementPayRailInvoice on the Disbursement Service page. A response with code 000 but an empty account_name is reported as not verified.
Was this page helpful?