SNAP QRIS Notification

When a customer completes a QRIS payment (static or dynamic), Durianpay sends a POST callback to your configured webhook URL with the payment result.

  • Callbacks are delivered to the URL registered in your dashboard.
  • Your endpoint must return 200 OK within 5 seconds.
  • If a 200 OK is not received in time, Durianpay retries at 2, 5, 10, 90, and 210 minutes after the original attempt.
  • Retries replay an identical payload, so your handler must be idempotent (see below).

For static QRIS the callback is the only signal the merchant receives, there is no preceding API call to correlate against, so dedup/reconciliation relies on the fields in the callback itself (originalReferenceNo, additionalInfo.rrn).


1. Setting Up the Webhook

  1. In the dashboard, go to Settings → Webhooks → Create New.
  2. Subscribe to the QRIS payment event (payment.qr.mpm.notify).
  3. Give the webhook a name and a target URL.
  4. On save, Durianpay sends a sample webhook to your URL. It must return 200 OK for the configuration to be saved.

2. Events After QRIS Payment

Event NameTriggerApplies to
payment.qr.mpm.notifyFires when a QRIS payment completes against a static or dynamic QR.Static + Dynamic

3. Handling the Callback

Read the outcome from the top-level latestTransactionStatus (mirrored in transactionStatusDesc):

Status CodeStatus TextMeaningWhat to do
00completedThe customer paid the QR. Funds settle to your merchant account.Credit your customer or fulfill the order.
08expiredThe dynamic QR passed its expiry time without being paid. No funds moved, so paidTime is empty and there is no rrn or issuerName.Close the order. To collect payment, generate a new QR.
09rejectedThe customer paid, but the payment was rejected. The funds are refunded to the payor automatically; refundReference identifies the refund.Do not credit your customer's balance.

Top-level fields

FieldDescription
originalReferenceNoDurianpay's payment ID (e.g. pay_f7RdgKc0ly4311). Primary key for dedup and reconciliation.
latestTransactionStatusOutcome code (00 = completed).
transactionStatusDescHuman-readable status (e.g. completed).
amount.value / amount.currencyPaid amount and currency (e.g. "1022.00", "IDR").

additionalInfo fields

FieldDescription
terminalIDTerminal ID (e.g. A01).
externalStoreIdNMID (National Merchant ID) — comes from your QRIS credentials.
rrnRetrieval Reference Number from the issuer; natural reconciliation key on the issuer side.
issuerNamePaying customer's bank/issuer (e.g. BCA).
isLivetrue for production, false for sandbox.
createdTimeWhen the QR/payment record was created.
paidTimeWhen the customer paid.
updatedTimeLast status update time.
failureReasonEmpty {} on success; populated object on failure.
payerNamePayor name as sent by the paying bank or e-wallet. Included only when the acquirer passes it.
payerPhoneNumberPayor phone or account number as sent by the paying bank or e-wallet. Included only when the acquirer passes it.
rejectionReasonRejected only. Why the payment was rejected (e.g. Payor Information Doesn't Match).
refundReferenceRejected only. ID of the automatic refund to the payor (e.g. rfn_ulAz6fI9bI5343).
customerInfocustomer details — see below.

customerInfo fields

FieldDescription
customer_idDurianpay customer ID (e.g. cus_AObLIp7MSL3730).
customer_ref_idMerchant's customer reference, if provided (may be empty).
emailPayor email (may be a system default for static QRIS, e.g. [email protected]).
given_namePayor name, if available (may be empty).
mobilePayor mobile number, if available.
❗️

Ensuring Callback Idempotency

Durianpay retries deliver the same payload. Deduplicate on originalReferenceNo (and, as a cross-check, additionalInfo.rrn):

  1. On first receipt, persist originalReferenceNo and process your business logic once.
  2. If the same originalReferenceNo arrives again, return 200 OK without reprocessing.

This is especially important for static QRIS, where the callback is the sole record of the transaction.


4. Digital Signature Verification

Every callback carries X-SIGNATURE and X-TIMESTAMP headers. Verify the signature before trusting the body.

Steps:

  1. Read X-SIGNATURE and X-TIMESTAMP from the request headers.

  2. Compose the string-to-sign in this format:

    POST:<RELATIVE_PATH>:<LOWERCASE_HEX_SHA256_OF_MINIFIED_BODY>:<X-TIMESTAMP>
    • <RELATIVE_PATH> excludes the domain, e.g. /callback/v1.0/qr/qr-mpm-notify.
    • Minify the JSON body (no insignificant whitespace), SHA-256 hash it, then hex-encode and lowercase.
    • <X-TIMESTAMP> is taken verbatim from the header.
  3. Verify with SHA-256 + RSA-2048 (PKCS#8) against Durianpay's public key.

  4. Return 200 OK only if verification succeeds; otherwise reject (401).

Example string-to-sign:

POST:/callback/v1.0/qr/qr-mpm-notify:5d2c90ddfdd406117ced5c2b502c05b601d435c7e5440f82e58733fdd5f15b7d:2026-06-22T11:36:12+00:00

Sandbox and Live use different public keys. Use additionalInfo.isLive and your environment config to load the matching key.

Verification (Go)

package main

import (
	"crypto"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/hex"
	"encoding/pem"
	"errors"
	"fmt"
)

// VerifyQRISCallback verifies the X-SIGNATURE of a Durianpay QRIS callback.
func VerifyQRISCallback(relativePath, timestamp string, body []byte, xSignature, publicKeyPEM string) error {
	// 1. SHA-256 of the minified request body, hex-encoded lowercase.
	bodyHash := sha256.Sum256(body)
	bodyHashHex := hex.EncodeToString(bodyHash[:]) // hex.EncodeToString already lowercases

	// 2. Compose the string-to-sign.
	stringToSign := fmt.Sprintf("POST:%s:%s:%s", relativePath, bodyHashHex, timestamp)

	// 3. Parse Durianpay's public key (PKIX / PEM).
	block, _ := pem.Decode([]byte(publicKeyPEM))
	if block == nil {
		return errors.New("invalid public key PEM")
	}
	pub, err := x509.ParsePKIXPublicKey(block.Bytes)
	if err != nil {
		return err
	}
	rsaPub, ok := pub.(*rsa.PublicKey)
	if !ok {
		return errors.New("public key is not RSA")
	}

	// 4. Decode the base64 signature and verify (SHA-256, PKCS#1 v1.5).
	sig, err := base64.StdEncoding.DecodeString(xSignature)
	if err != nil {
		return err
	}
	digest := sha256.Sum256([]byte(stringToSign))
	return rsa.VerifyPKCS1v15(rsaPub, crypto.SHA256, digest[:], sig)
}

5. Callback Payload Example

Completed payment (00)

{
  "additionalInfo": {
    "terminalID": "A01", // newly added value
    "externalStoreId": "202604234324234", // newly added value
    "createdTime": "2026-06-22T11:36:11+00:00",
    "customerInfo": {
      "customer_id": "cus_AggggP7MSL3730",
      "customer_ref_id": "",
      "email": "[email protected]",
      "given_name": "",
      "mobile": "0812345678"
    },
    "failureReason": {},
    "isLive": true,
    "issuerName": "BCA",
    "paidTime": "2026-06-22T11:36:12+00:00",
    "rrn": "000360170200",
    "updatedTime": "2026-06-22T11:36:12+00:00"
  },
  "amount": {
    "currency": "IDR",
    "value": "1022.00"
  },
  "latestTransactionStatus": "00",
  "originalReferenceNo": "pay_ab7HdgKc0ly4322",
  "transactionStatusDesc": "completed"
}

The payload shape is identical for both dynamic & static QRIS; the difference is only in origin (a dynamic QR was generated via API first, so you can also correlate by your own stored originalReferenceNo).


Rejected payment (09)

{
  "amount": {
    "value": "21.00",
    "currency": "IDR"
  },
  "additionalInfo": {
    "rrn": "02dc94f46d76",
    "isLive": true,
    "paidTime": "",
    "payerName": "DENDY NANDA PRASETYA",
    "issuerName": "OVO",
    "createdTime": "2026-10-05T10:29:10+00:00",
    "updatedTime": "2026-10-05T10:29:42+00:00",
    "customerInfo": {
      "email": "",
      "mobile": "6285719360000",
      "given_name": "tuwagapattt",
      "customer_id": "cus_gCw13utAqw5890",
      "customer_ref_id": ""
    },
    "failureReason": {
      "code": 20010,
      "message": "Payor Information Doesn't Match"
    },
    "refundReference": "rfn_ulAz6fI9bI5343",
    "rejectionReason": "Payor Information Doesn't Match",
    "payerPhoneNumber": "9360091285719369696"
  },
  "originalReferenceNo": "pay_F0TiGHqHWo8104",
  "transactionStatusDesc": "rejected",
  "latestTransactionStatus": "09"
}

Expired payment (08)

{
  "amount": {
    "value": "50000.00",
    "currency": "IDR"
  },
  "additionalInfo": {
    "isLive": true,
    "paidTime": "",
    "createdTime": "2026-10-20T00:00:14+00:00",
    "updatedTime": "2026-10-20T00:15:17+00:00",
    "customerInfo": {
      "email": "[email protected]",
      "mobile": "081234567890",
      "given_name": "Jane Doe",
      "customer_id": "cus_IqqyeEWaee4167",
      "customer_ref_id": "cust_001"
    },
    "failureReason": {}
  },
  "originalReferenceNo": "pay_oKDoX6uZOZ3193",
  "transactionStatusDesc": "expired",
  "latestTransactionStatus": "08"
}

Because nobody paid, an expired callback carries no payment details: paidTime is empty, failureReason is {}, and rrn, issuerName, terminalID, externalStoreId and the payer fields are absent. Use originalReferenceNo to find and close the order you created when you generated the QR.


6. Response Format

Acknowledge every callback with a minimal SNAP-shaped response. Only responseCode and responseMessage are expected — do not echo request data or add custom fields.

{
  "responseCode": "2005200",
  "responseMessage": "Successful"
}
responseCodeMeaningWhen to return
2005200SuccessfulCallback received and (idempotently) processed.
4015200Unauthorized — invalid signatureSignature verification failed.
5005200Internal server errorYour handler errored; Durianpay will retry.

📘

Changelog 26/10/2026

Adding 2 new conditional parameters on the callback: payerName & payerPhoneNumber where these 2 parameters will be included if the issuer passed the information


Did this page help you?