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
<?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;
}