How it works
- The provider calls one of the callback endpoints (see below).
- PGLink verifies the provider's token/signature.
- The matching payment (by
externalId) is updated, with idempotency protection. - PGLink forwards a normalized payload to your application's
webhookUrl.
Callback endpoints
| Provider | Endpoint | Verification |
|---|---|---|
| Xendit | POST /payments/xendit-callback | x-callback-token header equals XENDIT_WEBHOOK_TOKEN |
| Midtrans | POST /payments/midtrans-callback | SHA256(order_id + status_code + gross_amount + server_key) |
| Mayar.id | POST /payments/mayar-callback | ?token= query parameter (or x-mayar-token header) equals MAYAR_WEBHOOK_TOKEN |
| Xendit (subscriptions) | POST /subscriptions/xendit-callback | x-callback-token |
| Midtrans (subscriptions) | POST /subscriptions/midtrans-callback | SHA256 signature |
Mayar sends neither a signature nor a custom header, so the shared secret has to travel inside the webhook URL. Register the webhook in the Mayar dashboard ashttps://your-host/payments/mayar-callback?token=YOUR_TOKENwhenMAYAR_WEBHOOK_TOKENis configured. Leave the token out of the URL only whenMAYAR_WEBHOOK_TOKENis empty.
Payload forwarded to your app
{
"paymentId": "uuid",
"applicationOrderId": "ORDER-12345",
"externalId": "payment-ext-...",
"amount": 50000,
"currency": "IDR",
"status": "SUCCESS",
"provider": "MAYAR",
"paymentMethod": "QRIS",
"customerEmail": "budi@example.com",
"description": "Order #12345",
"metadata": {},
"processedAt": "2026-03-05T12:00:00.000Z"
}
Headers:
| Header | Description |
|---|---|
X-API-Key | Your application's API key |
X-Payment-Id | PGLink payment UUID |
Handling webhooks safely
- Verify the
X-API-Keyheader matches your application key. - Respond with HTTP
200quickly; do heavy work asynchronously. - Implement idempotency using
paymentId(PGLink already ignores duplicate callbacks for the same payment). - Log failures and alert on elevated error rates.
A webhook is sent with a 10 second timeout. Delivery failures are logged; the payment record itself is always persisted.
Status transitions
A payment only moves forward: a callback that reports a final status
(SUCCESS, FAILED, EXPIRED) is applied and
forwarded to your webhookUrl. Informational callbacks - Mayar's
payment.reminder for an unpaid invoice, a Midtrans
pending notification - never change the stored status and are not
forwarded. A payment that already succeeded is never downgraded, and a
repeated callback for the same final status is ignored.
Testing locally
# Mayar callback
curl -X POST http://localhost:3000/payments/mayar-callback \
-H "Content-Type: application/json" \
-d '{
"event": "payment.received",
"data": {
"transactionId": "trx-123",
"status": "SUCCESS",
"transactionStatus": "paid"
}
}'
# Mayar callback with the shared secret (when MAYAR_WEBHOOK_TOKEN is set)
curl -X POST "http://localhost:3000/payments/mayar-callback?token=your-webhook-token" \
-H "Content-Type: application/json" \
-d '{ "event": "payment.received", "data": { "transactionId": "trx-123", "transactionStatus": "paid" } }'
# Xendit callback
curl -X POST http://localhost:3000/payments/xendit-callback \
-H "Content-Type: application/json" \
-H "x-callback-token: your-webhook-token" \
-d '{ "external_id": "payment-ext-123", "status": "PAID" }'
Use ngrok to expose localhost when registering provider webhooks.