Skip to content

Webhooks

Two directions: outbound (this gateway → your backend, when a transaction is paid/expired) and inbound (the App Listener → this gateway, reporting a detected payment).

MethodPathPurpose
GET/v1/webhook-configView the configured webhook URL for the mode you authenticated with
PUT/v1/webhook-configSet or clear the webhook URL for the mode you authenticated with ({ "webhook_url": "https://..." | null })
GET/v1/outbound-webhook-logInspect outbound webhook delivery attempts for a transaction (?history_id= required)

Live and Sandbox each have their own webhook URL — which one you’re reading or writing is decided by which API key (live or sandbox) authenticated the request, not a body/query parameter. Configuring one never affects the other, so you can safely point Sandbox at a test endpoint while Live stays on your production one.

// GET/PUT /v1/webhook-config
{ "success": true, "data": { "webhook_url": "http://localhost:3211/hook" } }
// GET /v1/outbound-webhook-log?history_id=...
{
"success": true,
"data": [
{ "id": "17e69dea-7560-42c2-a452-afc05bfbba09", "event_type": "payment.success", "attempt_number": 1, "response_status": 200, "success": true, "next_retry_at": null, "attempted_at": "2026-07-08T09:34:35.967Z" }
]
}

Sent for payment.success and payment.expired. Every request carries X-Signature: HMAC-SHA256(secret, rawBody), using the mode’s secret key (secret_key_live or secret_key_sandbox) — verify this before trusting the payload.

{
"event": "payment.success",
"timestamp": "2026-07-08T09:34:35.954Z",
"data": {
"history_id": "1285922c-ddd2-4c3e-b8ee-533c520d08cd",
"qris_id": "37242f6d-90cc-4950-826e-3d92258bf416",
"mode": "sandbox",
"original_amount": "25000",
"final_amount": "25083",
"payment_status": "paid",
"paid_at": "2026-07-08T09:34:35.953Z",
"expires_at": "2026-07-08T09:49:35.942Z"
}
}

payment.expired has the same shape.

If your endpoint doesn’t respond 2xx, delivery is retried twice more (1 minute, then 5 minutes later). After 3 failed attempts, use POST /v1/qris-history/:id/resend-webhook to redeliver manually — your own polling via GET /v1/qris-history/:id is always the fallback source of truth.

POST /v1/webhook/payment-status — called by the App Listener, not by your backend. Not rate-limited (it authenticates by shared secret, not an API key).

Headers: X-User-ID plus one of:

  • X-Webhook-Secret — the tenant’s secret_key_live/secret_key_sandbox sent plaintext (whichever it matches also selects the mode).
  • X-Webhook-SignatureHMAC-SHA256(secret, rawBody) as lowercase hex, checked against both the live and sandbox secret (whichever matches selects the mode). For clients that can sign but shouldn’t transmit the secret itself.

Body:

{
"amount": 15000,
"paid_at": "2026-07-08T09:01:41+07:00",
"payment_source": "id.dana",
"payment_status": "paid",
"transaction_id": "b5b2b8b0-...-guid"
}

transaction_id is optional — a client-generated correlation value, stored verbatim in raw_body when sent, but it plays no part in matching (that’s by amount, see below) and isn’t returned in the response. Some App Listener clients can’t produce a real one and omit it entirely (see App Listener).

paid_at is also optional — if omitted or unparseable, the server falls back to its own receive-time timestamp (MacroDroid’s date variables aren’t guaranteed zero-padded ISO-8601).

Always responds 200 — a request that authenticates but matches no pending transaction (or isn’t payment_status: "paid") is recorded and ignored, not treated as an error. Only a missing/invalid X-User-ID/X-Webhook-Secret/X-Webhook-Signature returns 401 SIGNATURE_INVALID. Every response echoes the server’s own qg_inbound_webhook_log.id as log_id, letting you join a client-side log line to the exact row in inbound audit log below instead of correlating by timestamp/amount. When the notification matches a transaction, the response also carries that transaction’s own qg_qris_history.id as history_id.

{ "success": true, "message": "Matched: transaction marked as paid", "log_id": "75172331-5375-441f-99f1-e83fe7e0a461", "history_id": "3f9a1c2e-7b4d-4a2f-9e6c-1d8f5b0a2c31" }
{ "success": true, "message": "Ignored: payment_status is not 'paid'", "log_id": "..." }
{ "success": true, "message": "Ignored: no matching unpaid transaction for this amount", "log_id": "..." }

GET /v1/inbound-webhook-log — every authenticated call the App Listener made, matched or not; useful for answering “did the notification even arrive, and what did the gateway do with it?” when a transaction isn’t turning paid as expected. Unauthenticated calls (bad secret, unknown user) are not logged here — see Errors.

Filters (all optional): history_id, result (matched / ignored_not_paid / ignored_no_match), from/to (ISO-8601), limit/offset.

// GET /v1/inbound-webhook-log?result=ignored_no_match
{
"success": true,
"data": [
{
"id": "75172331-5375-441f-99f1-e83fe7e0a461",
"history_id": null,
"result": "ignored_no_match",
"raw_body": { "amount": 88888, "paid_at": null, "payment_source": "id.dana", "payment_status": "paid", "transaction_id": "t2" },
"received_at": "2026-07-08T10:47:12.277Z"
}
],
"pagination": { "total": 1, "limit": 20, "offset": 0 }
}

Continue to App Listener