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

EventDonation actionCampaign action
checkout.session.completedStatus → completed; set paid_atIncrement raised_amount, available_amount, supporter_count
checkout.session.expiredStatus → expiredNo change
charge.succeededStatus → completed; set paid_atIncrement raised_amount, available_amount, supporter_count
charge.failedStatus → failedNo 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
  }
}

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

SymptomCheck
401 on every webhookWebhook secret in .env doesn't match Magpie dashboard
Donation not updatingConfirm reference_number in metadata matches the DB record
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?