Payment Webhooks
Payment gateways send server-to-server webhook events to notify Batchmates when payment status changes. Webhooks are the authoritative signal — never rely solely on redirect URLs. This page covers Maya webhooks; the PayMongo section below covers PayMongo.
Endpoint
POST /api/v1/payments/maya/webhook
Security: IP allowlist — only Maya's published webhook source IPs are accepted (sandbox: 13.229.160.234, 3.1.199.75; production: 18.138.50.235, 3.1.207.200). Requests from any other source are rejected before the handler runs.
Handler: MayaController::handleWebhook() → MayaTransactionService::updateStatus()
Event Reference
| Event | Donation action | Campaign action |
|---|---|---|
PAYMENT_SUCCESS | Status → completed; set paid_at | Increment raised_amount, available_amount, supporter_count |
PAYMENT_FAILED | Status → failed | No change |
PAYMENT_EXPIRED | Status → expired | No change |
PAYMENT_CANCELLED | Status → cancelled | No change |
Campaign balances are incremented by the base donation amount (donation.amount), not the total_amount which includes fees.
Payload Structure
PAYMENT_SUCCESS
Payload
{
"id": "maya_ch_abc123xyz",
"status": "PAYMENT_SUCCESS",
"requestReferenceNumber": "550e8400-e29b-41d4-a716-446655440000",
"amount": {
"value": 1055.00,
"currency": "PHP"
},
"paymentMethod": "card",
"customer": {
"email": "donor@example.com"
},
"createdAt": "2025-03-01T08:00:00Z",
"completedAt": "2025-03-01T08:00:15Z"
}
Maya amounts are decimals in PHP (amount.value), unlike the cents-based amounts used by some other gateways.
PAYMENT_FAILED
Payload
{
"id": "maya_ch_def456abc",
"status": "PAYMENT_FAILED",
"requestReferenceNumber": "650e8400-e29b-41d4-a716-446655440001",
"amount": {
"value": 1055.00,
"currency": "PHP"
},
"failureReason": "Insufficient funds",
"createdAt": "2025-03-01T08:05:00Z"
}
Handler Logic
The handler resolves the donation by requestReferenceNumber (which is the donation's reference_number) and dispatches on status:
// app/Services/MayaTransactionService.php
public function updateStatus(array $eventData): void
{
$status = $eventData['status'] ?? null;
match ($status) {
'PAYMENT_SUCCESS' => $this->handlePaymentSuccess($eventData),
'PAYMENT_FAILED' => $this->handlePaymentFailed($eventData),
'PAYMENT_EXPIRED' => $this->handlePaymentExpired($eventData),
'PAYMENT_CANCELLED' => $this->handlePaymentCancelled($eventData),
default => Log::warning('Unhandled Maya webhook event', ['status' => $status]),
};
}
All status-changing handlers are idempotent and race-safe. Each uses a database transaction with lockForUpdate() — if the same webhook fires twice concurrently (Maya 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.
PayMongo
PayMongo fires events to a separate endpoint, protected by the paymongo.webhook middleware (VerifyPayMongoWebhook).
POST /api/v1/payments/paymongo/webhook
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.
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.
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 in your Maya dashboard:
# https://abc123.ngrok.io/api/v1/payments/maya/webhook
The Maya webhook is IP-allowlisted. When testing locally, temporarily allow your tunnel's egress IP (or use the sandbox source IPs) or the request will be rejected before reaching the handler.
Troubleshooting
| Symptom | Check |
|---|---|
| Webhook rejected before handler | Source IP not in Maya allowlist — confirm sandbox/production IPs |
| Donation not updating | Confirm requestReferenceNumber 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 |