Webhooks

Receive payment events

When a provider reports a status change, PGLink updates the record and forwards a normalized webhook to your application.

How it works

  1. The provider calls one of the callback endpoints (see below).
  2. PGLink verifies the provider's token/signature.
  3. The matching payment (by externalId) is updated, with idempotency protection.
  4. PGLink forwards a normalized payload to your application's webhookUrl.

Callback endpoints

ProviderEndpointVerification
XenditPOST /payments/xendit-callbackx-callback-token header equals XENDIT_WEBHOOK_TOKEN
MidtransPOST /payments/midtrans-callbackSHA256(order_id + status_code + gross_amount + server_key)
Mayar.idPOST /payments/mayar-callback?token= query parameter (or x-mayar-token header) equals MAYAR_WEBHOOK_TOKEN
Xendit (subscriptions)POST /subscriptions/xendit-callbackx-callback-token
Midtrans (subscriptions)POST /subscriptions/midtrans-callbackSHA256 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 as https://your-host/payments/mayar-callback?token=YOUR_TOKEN when MAYAR_WEBHOOK_TOKEN is configured. Leave the token out of the URL only when MAYAR_WEBHOOK_TOKEN is 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:

HeaderDescription
X-API-KeyYour application's API key
X-Payment-IdPGLink payment UUID

Handling webhooks safely

  1. Verify the X-API-Key header matches your application key.
  2. Respond with HTTP 200 quickly; do heavy work asynchronously.
  3. Implement idempotency using paymentId (PGLink already ignores duplicate callbacks for the same payment).
  4. 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.