@solunesia-id/doku-payments
v0.2.0
Published
Reusable DOKU payment API client for Solunesia projects — QRIS, webhook handling, and credential management
Maintainers
Readme
@solunesia-id/doku-payments
Reusable DOKU payment API client for Solunesia projects. Handles B2B token management, request signing, QRIS payments, webhook verification, and multi-tenant credential resolution — zero runtime dependencies, ESM + CJS.
Installation
This package is published to GitHub Packages (private registry). Add the registry to your project's .npmrc:
@solunesia:registry=npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}Then install:
npm install @solunesia-id/doku-paymentsRequirements
- Node.js >= 18 (uses native
fetchandnode:crypto)
Quick Start
import {
DokuClient,
DokuQris,
type DokuCredentials,
} from '@solunesia-id/doku-payments'
const credentials: DokuCredentials = {
clientId: 'BRN-xxx',
secretKey: 'SK-xxx',
privateKeyPem: '-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----',
apiUrl: 'https://api-sandbox.doku.com',
merchantId: '24800',
terminalId: 'POS001',
}
const client = new DokuClient(credentials)
const qris = new DokuQris(client, credentials)
// Generate a QR code for payment
const { qrContent, validityPeriod } = await qris.generate({
partnerReferenceNo: 'ORD-20260826-001',
amount: 50000,
})
console.log(`QR expires at ${validityPeriod}`)API Reference
All exports are available from @solunesia-id/doku-payments.
DokuClient
new DokuClient(credentials: DokuCredentials)Universal DOKU API client. Handles B2B token request (RSA-SHA256 signing), token caching (per-instance, 60-second buffer before expiry), API call signing (HMAC-SHA512), and retry with exponential backoff.
// Get a valid access token (cached per-instance, shared across concurrent calls)
const token = await client.getToken()
// Read the credentials used by this client (read-only, immutable)
const creds = client.getCredentials()
// POST request with full Snap header set
const response = await client.post<QrisResponse>(
'/snap-adapter/b2b/v1.0/qr/qr-mpm-generate',
{ partnerReferenceNo: 'ORD-001', amount: { value: '50000.00', currency: 'IDR' } },
)
// GET request
const data = await client.get<SomeResponse>('/some-endpoint')Retry semantics:
- HTTP 429 — retries after
retryAfterseconds (default 60) - HTTP 5xx — exponential backoff (1s, 2s, 4s), max 3 attempts
- Other errors — thrown immediately, no retry
DokuQris
new DokuQris(client: DokuClient, credentials: DokuCredentials)Client for QRIS payment operations: generate QR code, query status, cancel, and refund.
generate(input: GenerateInput): Promise<QrisResponse>
const result = await qris.generate({
partnerReferenceNo: 'ORD-001', // max 64 chars, unique per order
amount: 50000, // serialized as "50000.00"
currency: 'IDR', // optional, defaults to 'IDR'
validityPeriod: 'PT15M', // optional, ISO-8601 duration or timestamp
postalCode: '12345', // optional, max 5 numeric (defaults to '12345')
feeType: '1', // optional, DOKU only accepts '1' (No Tips)
report: [{ key: 'area', value: 'STG' }], // optional metadata
})
// result.qrContent — raw QR string (not a URL), render with any QR library
// result.validityPeriod — ISO-8601 expiry timestamp
// result.referenceNo — DOKU-assigned reference number
// result.terminalId — terminal ID echoed back by DOKUquery(input: QueryInput): Promise<QrisStatus>
const status = await qris.query({
originalPartnerReferenceNo: 'ORD-001',
originalReferenceNo: result.referenceNo,
})
// status.status — 'SUCCESS' | 'PENDING' | 'FAILED' | 'EXPIRED'
// status.amount — transaction amount as number
// status.currency — 'IDR' (or whatever was set on generate)
// status.transactionDate — ISO-8601 or undefined if not yet paid
// status.approvalCode — present only on SUCCESS (use for cancel/refund)
// status.feeAmount — { value: number, currency: string } if provided by DOKU
// status.issuerId, status.issuerName, status.terminalId, status.customerName — from additionalInfocancel(input: CancelInput): Promise<CancelResponse>
Calls POST /snap-adapter/b2b/v1.0/qr/qr-expire to expire/cancel a pending QRIS QR.
const cancelResult = await qris.cancel({
partnerReferenceNo: 'ORD-001',
referenceNo: result.referenceNo,
reason: 'Customer requested cancellation', // optional, max 128 chars
})
// cancelResult.partnerReferenceNo — partner reference for the expired QR
// cancelResult.referenceNo — DOKU reference for the expired QR
// cancelResult.expiredDate — ISO-8601 timestamp when the QR was expiredrefund(input: RefundInput): Promise<RefundResponse>
const refundResult = await qris.refund({
originalPartnerReferenceNo: 'ORD-001',
originalReferenceNo: result.referenceNo, // optional
partnerRefundNo: 'RF-001', // unique per refund, max 64 chars
amount: 25000, // partial or full refund
reason: 'Customer requested refund', // max 256 chars
approvalCode: 'APPROVAL-CODE', // from query response or webhook
})
// refundResult.refundNo — DOKU-assigned refund reference
// refundResult.amount — { value: number, currency: string }
// refundResult.refundTime — ISO-8601 timestampStatus Mapping
DOKU uses different status formats depending on context — the API returns numeric codes, webhooks may return semantic strings. Both are normalised by DokuWebhookHandler and DokuQris.query():
| Source | DOKU Value | Normalised Status |
|--------|-----------|-------------------|
| API | 00 | SUCCESS |
| API / Webhook | 02 / PENDING | PENDING |
| API | 07 | EXPIRED |
| API | 12 | FAILED |
| API | 99 | FAILED |
| Webhook | SUCCESS | SUCCESS |
| Webhook | FAILURE | FAILED |
Unknown codes default to FAILED.
normalizeTransactionStatusCode() is exported for consumers who need normalisation outside the handler.
DokuWebhookHandler
new DokuWebhookHandler(
clientOrCredentials: DokuClient | DokuCredentials,
config?: { webhookPath?: string }, // defaults to '/api/webhooks/doku'
)Verifies webhook signatures and parses DOKU payment notifications. Accepts either a DokuClient or raw DokuCredentials.
Pattern 1 — Convenience Callbacks
const handler = new DokuWebhookHandler(credentials, {
webhookPath: '/api/webhooks/doku',
})
const { handled, event } = await handler.handle(req.headers, rawBody, {
onPaymentSuccess: async (event) => {
await db.orders.update({ where: { code: event.orderId }, data: { status: 'PAID' } })
},
onPaymentFailure: async (event) => {
await notifyAdmin(`Payment failed: ${event.orderId}`)
},
onPaymentPending: async (event) => {
await db.orders.update({ where: { code: event.orderId }, data: { status: 'PENDING' } })
},
})SUCCESS→onPaymentSuccessPENDING→onPaymentPendingFAILED/EXPIRED→onPaymentFailurehandledindicates whether a callback was triggered
Pattern 2 — Manual Parse
For complex flows (audit trails, multi-provider routing):
const handler = new DokuWebhookHandler(credentials)
// Throws DokuSignatureError on invalid signature, DokuValidationError on bad JSON
const event = await handler.parse(req.headers, rawBody)
await auditLog.save({ provider: 'doku', event })
if (event.status === 'SUCCESS') {
await settlement.forward(event.orderId, event.amount)
}WebhookEvent shape:
{
orderId: string // from order.invoice_number
status: 'SUCCESS' | 'PENDING' | 'FAILED' | 'EXPIRED'
rawStatus: string // raw DOKU value ("00", "SUCCESS", etc.)
amount: number // from order.amount
transactionId?: string // from transaction.original_request_id
transactionDate?: string // from transaction.date (ISO-8601)
additionalInfo?: Record<string, unknown>
}verifyWebhookSignature
verifyWebhookSignature(params: {
clientId: string
requestId: string
timestamp: string
targetPath: string // must match DOKU webhook config exactly
body: string // RAW unparsed body string
receivedSignature: string // from Signature header
secretKey: string
}): booleanHMAC-SHA256 verification with timing-safe comparison. Returns false on any error (never throws). Use this for manual webhook handling without DokuWebhookHandler.
Credential Resolvers
CredentialResolver (Interface)
interface CredentialResolver {
resolve(tenantId: string): Promise<DokuCredentials | null>
}Implement this to create your own resolver (e.g., database-backed):
import type { CredentialResolver, DokuCredentials } from '@solunesia-id/doku-payments'
class DbCredentialResolver implements CredentialResolver {
async resolve(tenantId: string): Promise<DokuCredentials | null> {
const row = await db.query('SELECT * FROM doku_credentials WHERE tenant_id = $1', [tenantId])
if (!row) return null
return {
clientId: row.client_id,
secretKey: row.secret_key,
privateKeyPem: row.private_key_pem,
apiUrl: row.api_url,
merchantId: row.merchant_id,
terminalId: row.terminal_id,
}
}
}Contract:
- Return
null— tenant not configured (caller treats as "not found") - Return
DokuCredentials— complete, valid credentials - Throw — tenant exists but data is malformed (configuration error)
FileCredentialResolver
new FileCredentialResolver(basePath: string)Reads credentials from a directory tree:
{basePath}/
{tenantId}/
config.json # FileConfig: { clientId, secretKey, apiUrl, merchantId, terminalId }
private.pem # RSA private key in PEM formatconfig.json example:
{
"clientId": "BRN-xxx",
"secretKey": "SK-xxx",
"apiUrl": "https://api-sandbox.doku.com",
"merchantId": "24800",
"terminalId": "POS001"
}- Missing directory /
config.json→ returnsnull(tenant not configured) config.jsonexists butprivate.pemmissing or empty → throwsDokuValidationError- Invalid JSON or missing required fields → throws
DokuValidationError - Tenant ID with path separators or
..→ throwsDokuValidationError
EnvCredentialResolver
new EnvCredentialResolver(env?: Record<string, string | undefined>) // defaults to process.envReads credentials from environment variables. Per-tenant variables take priority over global fallbacks.
| Field | Per-Tenant | Global Fallback |
|-------|-----------|-----------------|
| Client ID | DOKU_{TENANT}_CLIENT_ID | DOKU_CLIENT_ID |
| Secret Key | DOKU_{TENANT}_SECRET_KEY | DOKU_SECRET_KEY |
| Private Key Path | DOKU_{TENANT}_PRIVATE_KEY_PATH | DOKU_PRIVATE_KEY_PATH |
| API URL | DOKU_{TENANT}_API_URL | DOKU_API_URL |
| Merchant ID | DOKU_{TENANT}_MERCHANT_ID | DOKU_MERCHANT_ID |
| Terminal ID | DOKU_{TENANT}_TERMINAL_ID | DOKU_TERMINAL_ID |
{TENANT} is uppercased with non-alphanumeric characters replaced by _.
PRIVATE_KEY_PATH is a filesystem path — the PEM content is read at resolve time.
Semantics:
- No env vars found → returns
null - Partial config (some vars set, some missing) → throws
DokuValidationError - Unreadable or missing PEM file → throws
DokuValidationError
# Example: tenant "STG"
DOKU_STG_CLIENT_ID=BRN-xxx
DOKU_STG_SECRET_KEY=SK-xxx
DOKU_STG_PRIVATE_KEY_PATH=./keys/stg/private.pem
DOKU_STG_API_URL=https://api-sandbox.doku.com
DOKU_STG_MERCHANT_ID=24800
DOKU_STG_TERMINAL_ID=POS001Types
// Core
DokuCredentials { clientId, secretKey, privateKeyPem, apiUrl, merchantId, terminalId }
DokuEnvironment = 'sandbox' | 'production'
// QRIS
GenerateInput { partnerReferenceNo, amount, currency?, validityPeriod?, report?, merchantId?, terminalId?, postalCode?, feeType? }
QueryInput { originalPartnerReferenceNo, originalReferenceNo, serviceCode?, merchantId? }
CancelInput { partnerReferenceNo, referenceNo, merchantId?, reason? }
CancelResponse { partnerReferenceNo, referenceNo, expiredDate }
RefundInput { originalPartnerReferenceNo, originalReferenceNo?, merchantId?, partnerRefundNo, amount, currency?, reason, approvalCode }
QrisResponse { referenceNo, qrContent, validityPeriod, terminalId?, additionalInfo? }
QrisStatus { status, latestTransactionStatus, originalReferenceNo, originalPartnerReferenceNo, serviceCode, amount, currency, transactionDate?, transactionStatusDesc?, approvalCode?, feeAmount?, convenienceFee?, issuerId?, issuerName?, terminalId?, customerName? }
RefundResponse { originalReferenceNo, originalPartnerReferenceNo, refundNo, partnerRefundNo, amount, refundTime }
ReportField { key, value }
// Webhook
WebhookEvent { orderId, status, rawStatus, amount, transactionId?, transactionDate?, additionalInfo? }
WebhookResult { handled, event }
DokuTransactionStatus = 'SUCCESS' | 'PENDING' | 'FAILED' | 'EXPIRED'
// Credentials
CredentialResolver { resolve(tenantId: string): Promise<DokuCredentials | null> }
// Normalisation
normalizeTransactionStatusCode(raw: string): DokuTransactionStatus // maps "00"→SUCCESS, "02"→PENDING, "FAILURE"→FAILED, etc.Error Classes
| Class | Code | HTTP Status | When Thrown | Consumer Action |
|-------|------|-------------|-------------|-----------------|
| DokuError | varies | varies | Base class for all DOKU errors | Inspect code, statusCode, and raw for details |
| DokuSignatureError | SIGNATURE_ERROR | — | Webhook HMAC verification failed | Reject the webhook (return non-2xx) |
| DokuAuthError | AUTH_ERROR | 401 | Token request failed, invalid credentials, or HTTP 401/403 | Check clientId, secretKey, privateKeyPem |
| DokuRateLimitError | RATE_LIMITED | 429 | DOKU rate limit exceeded | Wait retryAfter seconds (default 60), retry |
| DokuValidationError | VALIDATION_ERROR | 400 | Invalid request body, missing fields, or malformed input | Fix request payload |
All error classes extend DokuError. Catch DokuError for any DOKU-specific failure, or use the specific subclass for targeted handling:
import { DokuError, DokuAuthError, DokuRateLimitError } from '@solunesia-id/doku-payments'
try {
await qris.generate({ partnerReferenceNo: 'ORD-001', amount: 50000 })
} catch (error) {
if (error instanceof DokuAuthError) {
// Re-authenticate or alert
} else if (error instanceof DokuRateLimitError) {
await sleep(error.retryAfter! * 1000)
} else if (error instanceof DokuError) {
console.error(`DOKU error ${error.code}: ${error.message}`)
console.error('Raw response:', error.raw)
}
}Webhook Handling
Endpoint Setup
DOKU sends webhook notifications to your configured URL. Your endpoint must:
- Capture the raw body — signature verification requires the unparsed string. Do NOT parse JSON before verification.
// Express example
app.post('/api/webhooks/doku', express.raw({ type: 'application/json' }), async (req, res) => {
const rawBody = req.body.toString('utf-8') // Buffer → string
const handler = new DokuWebhookHandler(credentials, {
webhookPath: '/api/webhooks/doku', // must match DOKU dashboard config exactly
})
try {
await handler.handle(req.headers, rawBody, {
onPaymentSuccess: async (event) => {
await db.orders.update({ where: { code: event.orderId }, data: { status: 'PAID' } })
},
})
res.status(200).json({ status: 'ok' })
} catch (error) {
res.status(400).json({ error: 'Invalid signature' })
}
})Important:
webhookPathmust exactly match the path configured in DOKU Dashboard (e.g.,/api/webhooks/doku). A mismatch causes signature verification to fail silently (returnsfalse).
DOKU Webhook Behavior
DOKU typically sends webhooks only on success. Pending and failure states are usually discovered via query polling (DokuQris.query()), not webhooks. DokuWebhookHandler still maps non-success statuses defensively.
The transaction.status field in webhook payloads may be a numeric code ("00") or a semantic string ("SUCCESS") depending on the DOKU payload version. Both are handled by DokuWebhookHandler.
Replay Protection (Consumer Responsibility)
Signature verification proves the webhook came from DOKU and the body was not tampered with — it does not prove the request is fresh or unique. Like Stripe/Adyen SDKs, replay protection is delegated to your endpoint:
- Freshness check: reject webhooks whose
Request-Timestampheader is older than a few minutes (compare against your server clock; allow for skew, e.g. ±5 minutes). - Idempotency: deduplicate on
Request-Id(ororder.invoice_number+ status) — DOKU may retry deliveries, and an attacker who intercepts a legitimate webhook can re-send it verbatim.
A typical flow: verify signature → check timestamp freshness → upsert by Request-Id before processing → process only if unseen.
Sandbox Testing
Base URLs
| Environment | Base URL |
|-------------|----------|
| Sandbox | https://api-sandbox.doku.com |
| Production | https://api.doku.com |
QRIS Simulator
Use the DOKU Sandbox QRIS Simulator to test payments end-to-end:
https://sandbox.doku.com/qris-simulator/
Keypair Generation
Generate an RSA keypair for sandbox testing:
# Generate private key
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
# Extract public key
openssl rsa -pubout -in private.pem -out public.pemUpload public.pem to DOKU Dashboard → Settings → API Keys → Merchant Public Key.
Place private.pem in your credentials directory (for FileCredentialResolver) or set DOKU_PRIVATE_KEY_PATH / DOKU_{TENANT}_PRIVATE_KEY_PATH (for EnvCredentialResolver).
Development
Scripts
| Command | Description |
|---------|-------------|
| npm run build | Build ESM + CJS + declaration files via tsup |
| npm test | Run tests via vitest |
| npm run typecheck | Type-check without emitting |
Running Tests
npm testTests use vitest with mock DOKU API responses. No network access required.
License
Private — Solunesia internal use only.
