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).
Outbound: configuration
Section titled “Outbound: configuration”| Method | Path | Purpose |
|---|---|---|
GET | /v1/webhook-config | View the configured webhook URL for the mode you authenticated with |
PUT | /v1/webhook-config | Set or clear the webhook URL for the mode you authenticated with ({ "webhook_url": "https://..." | null }) |
GET | /v1/outbound-webhook-log | Inspect 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" } ]}Outbound: delivery, signature, retries
Section titled “Outbound: delivery, signature, retries”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.
Inbound: the App Listener’s contract
Section titled “Inbound: the App Listener’s contract”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’ssecret_key_live/secret_key_sandboxsent plaintext (whichever it matches also selects the mode).X-Webhook-Signature—HMAC-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": "..." }Inbound: audit log
Section titled “Inbound: audit log”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 →