AI Skill Report Card

Integrating QZPAY API

A-85·Aug 25, 2026·Source: Web
15 / 15

Every QZPAY request needs 3 headers: X-QZPAY-KEY, X-QZPAY-TIMESTAMP, X-QZPAY-SIGNATURE. Signature is HMAC-SHA256 of METHOD:PATH:BODY_HASH:TIMESTAMP, keyed with the API key.

JavaScript
const crypto = require('crypto'); function sign(method, path, body, timestamp, apiKey) { const bodyHash = crypto.createHash('sha256') .update(body ? JSON.stringify(body) : '') .digest('hex').toLowerCase(); const stringToSign = `${method}:${path}:${bodyHash}:${timestamp}`; return crypto.createHmac('sha256', apiKey).update(stringToSign).digest('hex'); } const timestamp = new Date().toISOString(); const body = { merchantId: 'mch_abc123', amount: 150000, channel: 'QRIS', feeBearer: 'MERCHANT' }; const res = await fetch('https://api.qzpayment.com/v2/payments', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-QZPAY-KEY': process.env.QZPAY_API_KEY, 'X-QZPAY-TIMESTAMP': timestamp, 'X-QZPAY-SIGNATURE': sign('POST', '/v2/payments', body, timestamp, process.env.QZPAY_API_KEY), 'Idempotency-Key': crypto.randomUUID(), }, body: JSON.stringify(body), });

Empty-body requests (GET/DELETE) use the SHA256 hash of an empty string: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.

Recommendation
Add a brief error-code reference table (common codes and meanings) rather than a single example.
14 / 15

Progress:

  • Step 1: Create API Key in Dashboard → Developer → API Keys, select minimal required scopes
  • Step 2: Store API Key server-side only (env var/secret manager) — it's shown once
  • Step 3: Implement the sign() function matching the target language (see below)
  • Step 4: Build request with required headers, add Idempotency-Key for all POST requests
  • Step 5: Handle response — check success, branch on code for errors
  • Step 6: Register a webhook endpoint and verify X-QZPAY-SIGNATURE on every incoming payload
  • Step 7: Respond HTTP 200 immediately, process business logic async
  • Step 8: Test with dashboard's "Kirim Test" or a local tunnel (ngrok/webhook.site)
Recommendation
Clarify what merchantId scoping looks like across sandbox vs production environments.

Python:

Python
import hashlib, hmac, json def sign(method, path, body, ts, key): body_str = json.dumps(body, sort_keys=True) if body else '' body_hash = hashlib.sha256(body_str.encode()).hexdigest() sts = f"{method}:{path}:{body_hash}:{ts}" return hmac.new(key.encode(), sts.encode(), hashlib.sha256).hexdigest()

PHP:

PHP
function sign($method, $path, $body, $ts, $key) { $bodyStr = $body ? json_encode($body) : ''; $bodyHash = strtolower(hash('sha256', $bodyStr)); $sts = "$method:$path:$bodyHash:$ts"; return hash_hmac('sha256', $sts, $key); }

Go:

Go
func sign(method, path string, body any, ts, key string) string { var b []byte if body != nil { b, _ = json.Marshal(body) } h := sha256.Sum256(b) sts := method + ":" + path + ":" + hex.EncodeToString(h[:]) + ":" + ts mac := hmac.New(sha256.New, []byte(key)) mac.Write([]byte(sts)) return hex.EncodeToString(mac.Sum(nil)) }

Bash (for scripts/testing):

Bash
BODY_HASH=$(echo -n "$BODY" | sha256sum | cut -d" " -f1) STRING_TO_SIGN="POST:/v2/payments:${BODY_HASH}:${TIMESTAMP}" SIGNATURE=$(echo -n "$STRING_TO_SIGN" | openssl dgst -sha256 -hmac "$API_KEY" | awk '{print $NF}')

Always use constant-time comparison to prevent timing attacks.

JavaScript
function verifyWebhook(payload, signature, secret) { const expected = crypto.createHmac('sha256', secret) .update(JSON.stringify(payload)).digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); }
Python
def verify_webhook(payload: dict, signature: str, secret: str) -> bool: expected = hmac.new(secret.encode(), json.dumps(payload, separators=(',', ':')).encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature)

Handle idempotently: dedupe on data.id or data.referenceId since the same event may be delivered multiple times (retries at 0s, 10s, 30s, 60s, 5min if you don't return HTTP 200 within 5s).

17 / 20

Example 1: Create QRIS Payment Input: POST /v2/payments with {"merchantId":"mch_abc123","amount":150000,"channel":"QRIS","feeBearer":"MERCHANT"} Output:

JSON
{ "success": true, "data": { "id": "pay_abc123", "referenceId": "QZ-MQ6YNQLN-HVBP", "status": "PENDING", "qrImageUrl": "https://api.qzpayment.com/v2/qr/pay_abc123", "expiresAt": "2026-06-21T10:15:00Z" } }

Example 2: Withdrawal with OTP confirmation Input: POST /v2/merchants/mch_abc123/withdrawals with bank details Output: status: "AWAITING_OTP" → follow up with POST /v2/merchants/:id/withdrawals/:id/confirm with {"otp":"123456"}

Example 3: Signature mismatch error Input: Request sent with stale timestamp (>5 min old) Output: HTTP 401 {"success": false, "code": "UNAUTHORIZED", "message": "..."}

Recommendation
Include a note on rate-limit header names for programmatic backoff handling.
  • Sort JSON keys deterministically before hashing the body — mismatched key order between client/server causes signature failures.
  • Always send Idempotency-Key (UUID) on POST requests to avoid double-charging on retries.
  • Request only the scopes needed per key (payment:create, balance:read, etc.) — principle of least privilege.
  • Use webhooks instead of polling; reserve GET status checks for reconciliation/fallback only.
  • On HTTP 429, apply exponential backoff — don't hammer retries.
  • Respond to webhooks with HTTP 200 immediately; do heavy processing asynchronously.
  • Log webhook events for debugging, but never log the signature or API key value.
  • Never put the API Key in frontend/client-side JS or commit it to GitHub — it's shown only once at creation.
  • Don't use ===/== for webhook signature comparison — use timingSafeEqual/compare_digest to avoid timing attacks.
  • Don't forget the empty-body SHA256 hash constant for GET/DELETE requests — signing with an empty string literal instead breaks verification.
  • Don't assume timestamp is lenient — window is ±5 minutes UTC; clock drift causes silent 401s.
  • Don't skip idempotency handling on webhook receivers — duplicate deliveries are expected, not a bug.
  • Don't poll payment status in a tight loop — this burns rate limit (300 req/min default, 10 req/sec burst) and webhooks exist specifically to avoid this.
0
Grade A-AI Skill Framework
Scorecard
Criteria Breakdown
Quick Start
15/15
Workflow
14/15
Examples
17/20
Completeness
19/20
Format
15/15
Conciseness
13/15