Skip to content
StraventaDocs
Now in Sandbox.
API Reference

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

EnvironmentHostname
Sandboxhttps://sandbox.505pay.link
Productionhttps://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/json

Rather than adding DanaraPay-specific columns, the integration reuses the existing tenant_psp_credentials shape:

Credential fieldDanaraPay meaningStorage
client_idthe x-username headerplaintext (an identity)
secret_keythe x-api-key headerAES-GCM encrypted at rest
base_urlthe provider originplaintext 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:

HostRole
partner.danarapay.comlive partner origin
api-stg.danarapay.comstaging
api-demo.danarapay.comsecond 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 /api composes /api/api/… and the gateway answers 404.
  • https is mandatory for any of the three public hosts. The scheme rule is never relaxed.
  • Suffix matching is not used, so partner.danarapay.com.example is refused rather than accepted.
  • Anything else returns 400 invalid_base_url listing the allowed hosts.
  • On dev and test stacks only, DANARAPAY_ALLOW_PRIVATE_ORIGINS=true widens the host rule (and only the host rule) to non-routable destinations so the adapter can be pointed at mock-danarapay-service. It defaults to false and 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, not routes, and unknown fields are rejected outright (invalid_json). A silently-decoded typo once wiped a tenant's routing table.
  • An empty routing array 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 ErrCredentialMissing rather 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 code 203, 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 990 that 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_url is a hard failure. need_frontend: false promises it; without it the payer would have a page and nothing to scan, so the charge is not reported as created.
  • sender_email is deliberately not sent. It is optional, does nothing when DanaraPay hosts no page, and the live gateway rejects addresses it dislikes with an undocumented code 247 that 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.code answers only "did the gateway answer at all". It stays "000" on an EXPIRED transaction, so it can never decide settlement. A non-000 code (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_status decides settlement, alone.
payment_statusMapped statusNotes
WAITING_PAYMENTpendingwhat the live gateway actually sends for an unpaid charge
PENDINGpendingkept only so a gateway that emits it is not misread
COMPLETEpaidthe only paid signal
EXPIREDexpiredterminal, unpaid
anything elsependingan 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.

BehaviourValue
Enabled byPAYMENTS_ACCEPTANCE_POLLER_ENABLED (default false)
Tenant scopePAYMENTS_ACCEPTANCE_POLLER_TENANTS (empty = every tenant)
Sweep interval30s
Attempt eligible after2 minutes old
Minimum gap between polls of one attempt1 minute (enforced in SQL via last_polled_at)
Rows claimed per sweep200
Consecutive endpoint failures before retirement10
Providers sweptdanarapay 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

CapabilityStatus
RefundsUnsupported — the API publishes no refund endpoint
Inbound webhooksUnsupported — nothing is signed, so nothing can be authenticated
Disputes / evidenceUnsupported — no dispute API
DisbursementNot 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?

On this page