Payment Webhooks

PayMongo sends server-to-server webhook events to notify Batchmates when payment status changes. Webhooks are the authoritative signal — never rely solely on redirect URLs.


Endpoint

POST /api/v1/payments/paymongo/webhook

Security: protected by the paymongo.webhook middleware (VerifyPayMongoWebhook), which checks the Paymongo-Signature header before the handler runs.

Handler: PayMongoController::handleWebhook() → PayMongoTransactionService::handleWebhook()


Event Reference

EventDonation actionCampaign action
checkout_session.payment.paidStatus → completed; set paid_atIncrement raised_amount, available_amount, supporter_count
payment.paidStatus → completed if resolvable, else logged and skippedIncrement campaign balances
payment.failedStatus → failedNo change

checkout_session.payment.paid is the primary completion event for hosted checkout. A payment.paid that doesn't resolve to a donation is expected there and is logged, not treated as an error.

Campaign balances are incremented by the base donation amount (donation.amount), not the total_amount which includes fees.


Signature Verification

PayMongo signs each request with a Paymongo-Signature header. The header is a comma-separated set of parts and the signed payload includes the timestamp:

Paymongo-Signature: t=,te=,li=
// app/Http/Middleware/VerifyPayMongoWebhook.php

$signature = config('services.paymongo.livemode') ? $parts['li'] : $parts['te'];
$expected  = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $webhookSecret);

hash_equals($expected, $signature); // constant-time compare
  • The signed message is timestamp.rawBody (dot-separated), not the body alone.
  • Which signature is verified depends on mode: te when PAYMONGO_LIVEMODE=false, li when true.
  • The secret is the per-endpoint signing secret (whsec_...) from the PayMongo dashboard — not the API secret key.
  • Requests older than 5 minutes (MAX_AGE_SECONDS = 300) are rejected as replays. A missing or malformed header returns 401.

Event Payload

PayMongo nests the event type and resource under data.attributes:

Payload

{
  "data": {
    "id": "evt_abc123",
    "attributes": {
      "type": "checkout_session.payment.paid",
      "data": {
        "id": "cs_abc123",
        "attributes": {
          "reference_number": "550e8400-e29b-41d4-a716-446655440000",
          "metadata": { "donation_reference": "550e8400-e29b-41d4-a716-446655440000" },
          "payments": [{ "attributes": { "status": "paid", "source": { "type": "gcash" } } }]
        }
      }
    }
  }
}

The handler resolves the donation by reference_number (or metadata.donation_reference), falling back to the PayMongo resource id stored in transaction_id (cs_... or pi_...). Completion runs through the same locked, idempotent completeDonation() used by the redirect verify — so the webhook and the API-verified redirect can never double-count a donation.


Idempotency

All status-changing handlers are idempotent and race-safe. Each uses a database transaction with lockForUpdate() — if the same webhook fires twice concurrently (PayMongo retries on non-2xx), the second transaction finds the donation already in its final state and exits without making changes. The redirect handler's verifyAndComplete() runs through the same locked path, so the webhook and the redirect can never double-count a donation.

completeDonation() completes any donation that is not already completed — including one sitting at cancelled, failed, or expired. A gateway-confirmed payment on such a donation means our local status ran ahead of the gateway, so the money is still recorded (dropping it would lose a real charge) and a warning is logged with the reference and previous status:

PayMongo: completing a non-pending donation after gateway confirmation
{"reference":"...","previous_status":"cancelled","transaction_id":"cs_..."}

Treat that line as a signal worth investigating — it should be rare now that the cancel redirect verifies with the gateway before writing cancelled.


Subscription Events

Subscription lifecycle events (subscription.past_due, subscription.unpaid, subscription.updated) drive donation_subscriptions.status. The code also branches on subscription.cancelled and subscription.incomplete_cancelled, but these two event names are not in PayMongo's published webhook catalog and are unconfirmed as of 2026-09-15.


Testing Webhooks Locally

Use ngrok to expose your local server:

php artisan serve        # start backend on :8000
ngrok http 8000          # tunnel to public URL

# Configure the ngrok URL as a webhook endpoint in your PayMongo dashboard:
# https://abc123.ngrok.io/api/v1/payments/paymongo/webhook

Troubleshooting

SymptomCheck
Webhook rejected before handlerPaymongo-Signature header missing/malformed, wrong signing secret for the active mode, or request older than 5 minutes
Donation not updatingConfirm reference_number (or metadata.donation_reference) matches the DB reference_number
Double-increment on campaignHandler not idempotent — ensure status check before incrementing
Webhook not received at allServer must be publicly reachable over HTTPS; check firewall rules
Donation updated twiceShould not happen — each handler acquires a row lock inside a DB transaction before checking status

Was this page helpful?