Secure Payment Webhook & Transaction Ingestion Engine
Designed and built a secure REST webhook endpoint for an amusement park, receiving real-time payment notifications from a third-party payment platform whenever visitors pay at self-service kiosks (food, drinks, souvenirs).
Implemented HMAC signature verification, an immediate acknowledgment response to prevent provider-side timeouts, and idempotent transaction handling via a unique-constraint-backed atomic insert-or-reclaim mechanism — enabling safe retries of failed events without ever reprocessing a successful one.
Handled two distinct transaction types (subscription discounts and gift card e-wallet top-ups) across both payment and cancellation flows, with full production logging for monitoring and debugging.
- Webhooks
- HMAC
- Immediate ACK
- Idempotency
- Business Logic
Code Snippet
<?php
/** * Amusement park payment webhook — in-park kiosk payments. * Receives real-time payment notifications from a third-party payment * provider whenever a customer pays at one of the park's self-service * kiosks (food, drinks, souvenirs). Anonymized/condensed for portfolio * purposes. * * Note: verifies the request's authenticity via HMAC signature, then * acknowledges receipt immediately to avoid provider-side timeouts * before processing the transaction — updating subscription discounts * or gift card balances depending on the payload's transaction type. */
use AppDB;
const WEBHOOK_STATUS = [ 'failed' => 0, 'success' => 1, 'pending' => 2];
// ============================================================================// 1. HMAC-SHA256 SIGNATURE VERIFICATION// ============================================================================
$headers = getallheaders();$providedSignature = $headers['X-Provider-Signature'] ?? $headers['x-provider-signature'] ?? '';$secretKey = getenv('WEBHOOK_SECRET') ?: 'secret_key_placeholder';$rawInput = file_get_contents('php://input');
$computedSignature = hash_hmac('sha256', $rawInput, $secretKey);
if (!hash_equals($providedSignature, $computedSignature)) { http_response_code(401); echo 'Invalid HMAC signature'; exit();}
// ============================================================================// 2. PAYLOAD DECODING & VALIDATION// ============================================================================
$data = json_decode($rawInput, true);
if (!$data || !isset($data['payload'], $data['event_type'])) { http_response_code(400); echo 'Invalid payload structure'; exit();}
$payload = $data['payload'];$orderId = $payload['orderId'] ?? null;$notificationId = $payload['notificationId'] ?? null;
if (!$orderId || !$notificationId) { http_response_code(400); echo 'Missing data'; exit();}
// ============================================================================// 3. IDEMPOTENCE CHECK// ============================================================================
if (isEventAlreadyProcessed($notificationId)) { http_response_code(200); exit();}
// ============================================================================// 4. FAST ACKNOWLEDGMENT (IMMEDIATE RESPONSE TO AVOID PROVIDER-SIDE TIMEOUTS)// ============================================================================
http_response_code(200);header('Content-Length: 0');header('Connection: close');
if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request();}
// ============================================================================// 5. ASYNCHRONOUS TRANSACTION PROCESSING// ============================================================================
try { $badgeNumber = $payload['customer']['badgeNumber'] ?? null; $status = $payload['status'] ?? null;
if (!$badgeNumber || !$status) { markEventStatus($notificationId, WEBHOOK_STATUS['failed']); exit(); }
// Route based on card type (Subscription vs. Gift Card) if (isSubscriptionBadge($badgeNumber)) { $success = handleSubscriptionTransaction($payload, $orderId, $badgeNumber, $status); } elseif (isGiftCardBadge($badgeNumber)) { $success = handleGiftCardTransaction($payload, $orderId, $badgeNumber, $status); } else { logWebhookError("Unknown badge type - Badge: {$badgeNumber}"); $success = false; }
markEventStatus($notificationId, $success ? WEBHOOK_STATUS['success'] : WEBHOOK_STATUS['failed']);
} catch (Exception $e) { logWebhookError('Critical exception: ' . $e->getMessage()); markEventStatus($notificationId, WEBHOOK_STATUS['failed']);}
// ============================================================================// BUSINESS LOGIC HANDLERS// ============================================================================
/** * Handles idempotency. */function isEventAlreadyProcessed(string $notificationId): bool {
try { $sql = 'INSERT INTO webhook_events (unique_id, status) VALUES (:uniqueId, :status)';
DB::exec($sql, [':uniqueId' => $notificationId, ':status' => WEBHOOK_STATUS['pending']]);
return false;
} catch (PDOException $e) { if ((int) $e->errorInfo[1] === 1062) { return !reclaimFailedEvent($notificationId); }
logWebhookError('Idempotency error: ' . $e->getMessage()); throw $e; }}
/** * In case of a retry */function reclaimFailedEvent(string $notificationId): bool{ $sql = "UPDATE webhook_events SET status = :newStatus WHERE unique_id = :uniqueId AND status = :oldStatus";
$affected = DB::exec($sql, [':uniqueId' => $notificationId, ':newStatus' => WEBHOOK_STATUS['pending'], ':oldStatus' => WEBHOOK_STATUS['failed']]);
return (int) $affected === 1;}
/** * Registers event status after processing. */function markEventStatus(string $notificationId, int $status): void{ $sql = 'UPDATE webhook_events SET status = :status WHERE unique_id = :uniqueId'; DB::exec($sql, [':status' => $status, ':uniqueId' => $notificationId]);}
/** * Handles the subscription lifecycle (discount recording and cancellation). */function handleSubscriptionTransaction(array $payload, string $orderId, string $badgeNumber, string $status): bool{ $success = true;
if ($status === 'PAID') { $ticketCode = findTicketCodeByBadge($badgeNumber); $discountedPrice = $payload['totalPriceDiscountedWithTaxExcluded'] ?? null;
if (!$ticketCode || $discountedPrice === null) { return false; }
$kioskId = KIOSK_MAPPING[$payload['deviceId']] ?? null;
$totalHT = $payload['totalPriceWithTaxExcluded']; $totalTTC = $payload['totalPriceWithTaxIncluded']; $totalDiscountedHT = $payload['totalPriceDiscountedWithTaxExcluded']; $totalDiscountedTTC = $payload['totalPriceDiscountedWithTaxIncluded'];
$discountHT = centsToEuros($totalHT - $totalDiscountedHT); $discountTTC = centsToEuros($totalTTC - $totalDiscountedTTC); $discountVat = $discountTTC - $discountHT;
$discount = new Discount(); $discount->setKioskId($kioskId); $discount->setOrderId($orderId); $discount->setTicketCode($ticketCode); $discount->setDiscountHT($discountHT); $discount->setDiscountVat($discountVat); $discount->setTotalHT(centsToEuros($totalDiscountedHT));
if ($discount->save()) { logWebhookInfo("Discount created successfully - OrderID: {$orderId}"); } else { logWebhookError("Failed to create discount - OrderID: {$orderId}, KioskID: {$kioskId}"); $success = false; } }
if ($status === 'CANCELLED') { $discount = new Discount(); $discount->setOrderId($orderId);
if ($discount->loadByOrderId()) { if ($discount->delete()) { logWebhookInfo("Discount deleted after cancellation - OrderID: {$orderId}"); } else { logWebhookError("Failed to delete discount - OrderID: {$orderId}"); $success = false; } } else { logWebhookError("Discount not found for cancellation - OrderID: {$orderId}"); $success = false; } }
return $success;}
/** * Handles the gift card lifecycle (e-wallet debit and cancellations). */function handleGiftCardTransaction(array $payload, string $orderId, string $badgeNumber, string $status): bool{ $kioskId = KIOSK_MAPPING[$payload['deviceId']] ?? null; $giftCardTx = new GiftCardTransaction(); $success = true;
if ($status === 'PAID') { $payments = $payload['payments'] ?? []; foreach ($payments as $payment) { if (($payment['type'] ?? '') === 'EWallet') { $amount = centsToEuros($payment['value']); $result = $giftCardTx->addTransaction($badgeNumber, $kioskId, $amount, 1, $orderId);
if ($result['success'] ?? false) { logWebhookInfo("Gift card transaction created - Code: {$badgeNumber}, Amount: {$amount}€"); } else { logWebhookError("Gift card error - Code: {$badgeNumber}, Message: " . ($result['message'] ?? 'Unknown')); $success = false; } } } }
if ($status === 'CANCELLED') { if ($giftCardTx->archiveTransaction($orderId)) { logWebhookInfo("Gift card transaction archived for cancellation - OrderID: {$orderId}"); } else { logWebhookError("Failed to archive gift card transaction - OrderID: {$orderId}"); $success = false; } } return $success;}
// ============================================================================// HELPERS// ============================================================================
function isSubscriptionBadge(string $badge): bool{ return str_starts_with($badge, 'S');}
function isGiftCardBadge(string $badge): bool{ return isset($badge[1]) && strtoupper($badge[1]) === 'G';}
function findTicketCodeByBadge(string $badgeNumber): ?string{ $ticketMeta = new TicketMeta(); $ticketMeta->setMetaValue($badgeNumber);
if ($ticketMeta->loadByCard() && $ticketMeta->getTicketId()) { $ticket = new Ticket(); $ticket->setId($ticketMeta->getTicketId()); if ($ticket->load()) { return $ticket->getCode(); } }
return null;}