npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@solunesia-id/doku-payments

v0.2.0

Published

Reusable DOKU payment API client for Solunesia projects — QRIS, webhook handling, and credential management

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-payments

Requirements

  • Node.js >= 18 (uses native fetch and node: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 retryAfter seconds (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 DOKU

query(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 additionalInfo

cancel(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 expired

refund(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 timestamp

Status 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 → onPaymentSuccess
  • PENDING → onPaymentPending
  • FAILED / EXPIRED → onPaymentFailure
  • handled indicates 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
}): boolean

HMAC-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 format

config.json example:

{
  "clientId": "BRN-xxx",
  "secretKey": "SK-xxx",
  "apiUrl": "https://api-sandbox.doku.com",
  "merchantId": "24800",
  "terminalId": "POS001"
}
  • Missing directory / config.json → returns null (tenant not configured)
  • config.json exists but private.pem missing or empty → throws DokuValidationError
  • Invalid JSON or missing required fields → throws DokuValidationError
  • Tenant ID with path separators or .. → throws DokuValidationError

EnvCredentialResolver

new EnvCredentialResolver(env?: Record<string, string | undefined>)  // defaults to process.env

Reads 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=POS001

Types

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

  1. 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: webhookPath must exactly match the path configured in DOKU Dashboard (e.g., /api/webhooks/doku). A mismatch causes signature verification to fail silently (returns false).

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-Timestamp header is older than a few minutes (compare against your server clock; allow for skew, e.g. ±5 minutes).
  • Idempotency: deduplicate on Request-Id (or order.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.pem

Upload 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 test

Tests use vitest with mock DOKU API responses. No network access required.


License

Private — Solunesia internal use only.