Integrating QZPAY API
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.
JavaScriptconst 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.
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-Keyfor all POST requests - Step 5: Handle response — check
success, branch oncodefor errors - Step 6: Register a webhook endpoint and verify
X-QZPAY-SIGNATUREon 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)
Python:
Pythonimport 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:
PHPfunction 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:
Gofunc 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):
BashBODY_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.
JavaScriptfunction 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)); }
Pythondef 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).
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": "..."}
- 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 — usetimingSafeEqual/compare_digestto 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.