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
| Event | Donation action | Campaign action |
|---|---|---|
checkout_session.payment.paid | Status → completed; set paid_at | Increment raised_amount, available_amount, supporter_count |
payment.paid | Status → completed if resolvable, else logged and skipped | Increment campaign balances |
payment.failed | Status → failed | No 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:
tewhenPAYMONGO_LIVEMODE=false,liwhentrue. - 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 returns401.
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.
Payload amount verification parity. PayMongo webhook handlers (handleCheckoutSessionPaid and handlePaymentPaid) verify that the received payment amount in integer centavos matches (int) round($donation->total_amount * 100). Where available, the checkout session or payment intent is re-queried directly from PayMongo before completing. Any payload or gateway amount mismatch is logged and discarded, leaving the donation pending.
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
Test and live mode each need their own webhook endpoint configured in the PayMongo dashboard, each with its own signing secret (whsec_...).
Troubleshooting
| Symptom | Check |
|---|---|
| Webhook rejected before handler | Paymongo-Signature header missing/malformed, wrong signing secret for the active mode, or request older than 5 minutes |
| Donation not updating | Confirm reference_number (or metadata.donation_reference) matches the DB reference_number |
| Double-increment on campaign | Handler not idempotent — ensure status check before incrementing |
| Webhook not received at all | Server must be publicly reachable over HTTPS; check firewall rules |
| Donation updated twice | Should not happen — each handler acquires a row lock inside a DB transaction before checking status |