Payment Webhooks
Magpie 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/magpie/webhook
Middleware: magpie.webhook — verifies the HMAC-SHA256 signature before the handler runs.
Handler: MagpieController::handleWebhook() → MagpieTransactionService::updateStatus()
Signature Verification
Every incoming request includes a Magpie-Signature header. The middleware verifies it using a constant-time comparison to prevent timing attacks.
// app/Services/MagpieService.php
public function verifyWebhookSignature(string $payload, string $signature): bool
{
$expected = hash_hmac('sha256', $payload, config('services.magpie.webhook_secret'));
return hash_equals($expected, $signature); // constant-time compare
}
Requests with an invalid signature are rejected with 401 Unauthorized before reaching the controller.
Event Reference
| Event | Donation action | Campaign action |
|---|---|---|
checkout.session.completed | Status → completed; set paid_at | Increment raised_amount, available_amount, supporter_count |
checkout.session.expired | Status → expired | No change |
charge.succeeded | Status → completed; set paid_at | Increment raised_amount, available_amount, supporter_count |
charge.failed | Status → failed | No change |
Campaign balances are incremented by the base donation amount (donation.amount), not the total_amount which includes fees.
Payload Structure
checkout.session.completed
Payload
{
"type": "checkout.session.completed",
"data": {
"id": "sess_abc123xyz",
"amount": 103000,
"currency": "php",
"status": "paid",
"payment_method_types": ["card"],
"customer_email": "donor@example.com",
"metadata": {
"reference_number": "550e8400-e29b-41d4-a716-446655440000",
"donation_id": 45,
"campaign_id": 5
},
"created_at": 1740787200,
"completed_at": 1740787215
}
}
Magpie amounts are in cents. Divide by 100 to get the PHP amount (103000 cents = ₱1,030.00).
charge.succeeded
Payload
{
"type": "charge.succeeded",
"data": {
"id": "ch_def456abc",
"amount": 51500,
"currency": "php",
"status": "succeeded",
"customer": "cus_abc123",
"source": "src_xyz789",
"metadata": {
"reference_number": "650e8400-e29b-41d4-a716-446655440001",
"donation_id": 91,
"campaign_id": 5
},
"created_at": 1740787500
}
}
charge.failed
Payload
{
"type": "charge.failed",
"data": {
"id": "ch_ghi789jkl",
"amount": 51500,
"currency": "php",
"status": "failed",
"failure_code": "card_declined",
"failure_message": "Card was declined",
"metadata": {
"reference_number": "750e8400-e29b-41d4-a716-446655440002",
"donation_id": 92
},
"created_at": 1740787600
}
}
Handler Logic
// app/Services/MagpieTransactionService.php
public function updateStatus(array $eventData): void
{
$eventType = $eventData['type'] ?? $eventData['event'] ?? null;
match ($eventType) {
'checkout.session.completed' => $this->handleCheckoutCompleted($eventData),
'checkout.session.expired' => $this->handleCheckoutExpired($eventData),
'charge.succeeded' => $this->handleChargeSucceeded($eventData),
'charge.failed' => $this->handleChargeFailed($eventData),
default => Log::warning('Unhandled Magpie webhook event', ['event_type' => $eventType]),
};
}
All status-changing handlers are idempotent and race-safe. Each uses a database transaction with lockForUpdate() — if the same webhook fires twice concurrently (Magpie retries on non-2xx), the second transaction finds the donation already in its final state and exits without making changes.
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 Magpie dashboard:
# https://abc123.ngrok.io/api/v1/payments/magpie/webhook
To send a test event manually:
curl -X POST https://your-dev-url/api/v1/payments/magpie/webhook \
-H "Content-Type: application/json" \
-H "Magpie-Signature: {computed_signature}" \
-d '{
"type": "checkout.session.completed",
"data": {
"id": "sess_test_123",
"amount": 103000,
"status": "paid",
"metadata": {
"reference_number": "550e8400-e29b-41d4-a716-446655440000",
"donation_id": 45,
"campaign_id": 5
}
}
}'
Troubleshooting
| Symptom | Check |
|---|---|
401 on every webhook | Webhook secret in .env doesn't match Magpie dashboard |
| Donation not updating | Confirm reference_number in metadata matches the DB record |
| 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 |