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

EventDonation actionCampaign action
PAYMENT_SUCCESSStatus → completed; set paid_atIncrement raised_amount, available_amount, supporter_count
PAYMENT_FAILEDStatus → failedNo change
PAYMENT_EXPIREDStatus → expiredNo change
PAYMENT_CANCELLEDStatus → cancelledNo 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"
}

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

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.

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.


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

Troubleshooting

SymptomCheck
Webhook rejected before handlerSource IP not in Maya allowlist — confirm sandbox/production IPs
Donation not updatingConfirm requestReferenceNumber 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?