'Method not allowed']); } $rawBody = file_get_contents('php://input'); if ($rawBody === false || $rawBody === '') { respond(400, ['error' => 'Empty request body']); } $signatureHeader = $_SERVER['HTTP_PADDLE_SIGNATURE'] ?? ''; if ($signatureHeader === '') { respond(400, ['error' => 'Missing Paddle-Signature header']); } $signatureParts = []; foreach (explode(';', $signatureHeader) as $part) { $pair = explode('=', $part, 2); if (count($pair) === 2) { $signatureParts[trim($pair[0])] = trim($pair[1]); } } $timestamp = $signatureParts['ts'] ?? ''; $providedHash = $signatureParts['h1'] ?? ''; if ($timestamp === '' || $providedHash === '' || !ctype_digit($timestamp)) { respond(400, ['error' => 'Malformed Paddle-Signature header']); } $webhookSecret = getenv('HOOK_SECRET'); if ($webhookSecret === false || $webhookSecret === '') { error_log('receive-purchase.php: HOOK_SECRET is not configured'); respond(500, ['error' => 'Server misconfiguration']); } $signedPayload = $timestamp . ':' . $rawBody; $expectedHash = hash_hmac('sha256', $signedPayload, $webhookSecret); if (!hash_equals($expectedHash, $providedHash)) { respond(401, ['error' => 'Invalid signature']); } $maxTimestampAgeSeconds = 300; if (abs(time() - (int) $timestamp) > $maxTimestampAgeSeconds) { respond(401, ['error' => 'Signature timestamp outside allowed window']); } $event = json_decode($rawBody, true); if (!is_array($event)) { respond(400, ['error' => 'Invalid JSON payload']); } $eventId = $event['event_id'] ?? null; if (!is_string($eventId) || $eventId === '') { respond(400, ['error' => 'Missing event_id']); } $pdo = getDbConnection(); $pdo->exec('CREATE TABLE IF NOT EXISTS processed_webhook_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )'); $pdo->exec('CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id), credits_added INTEGER NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP )'); $pdo->exec('CREATE INDEX IF NOT EXISTS idx_transactions_user_id ON transactions (user_id)'); $creditsPerPrice = [ 'pri_01m1v28mknf5pjm5c99cvxa5k4' => 20, 'pri_01m1v2aegbrdvwhw1jf7b0s6va' => 50, ]; $pdo->beginTransaction(); try { // Claim the event ID first, inside the transaction: SQLite's unique constraint // makes this the atomic compare-and-set that decides whether this delivery is // the one that gets to apply credits. A losing concurrent delivery fails here // before touching the user's balance. $claimStmt = $pdo->prepare('INSERT INTO processed_webhook_events (event_id) VALUES (:event_id)'); $claimStmt->execute(['event_id' => $eventId]); } catch (PDOException $e) { $pdo->rollBack(); if (str_contains($e->getMessage(), 'UNIQUE constraint failed')) { respond(200, ['status' => 'already_processed']); } throw $e; } if (($event['event_type'] ?? '') === 'transaction.completed') { $data = $event['data'] ?? []; $ticket = $data['custom_data']['user_id'] ?? null; $userId = is_string($ticket) ? decodeTicket($ticket, 'purchase') : null; $items = is_array($data['items'] ?? null) ? $data['items'] : []; $creditsToAdd = 0; foreach ($items as $item) { $priceId = $item['price']['id'] ?? null; $quantity = $item['quantity'] ?? 1; if (!is_int($quantity) || $quantity < 1 || $quantity > 100) { continue; } if (is_string($priceId) && isset($creditsPerPrice[$priceId])) { $creditsToAdd += $creditsPerPrice[$priceId] * $quantity; } } if ($creditsToAdd > 0 && $userId !== null) { $userExistsStmt = $pdo->prepare('SELECT 1 FROM users WHERE id = :id'); $userExistsStmt->execute(['id' => $userId]); if ($userExistsStmt->fetch()) { $creditStmt = $pdo->prepare('UPDATE users SET credits = credits + :credits WHERE id = :id'); $creditStmt->execute(['credits' => $creditsToAdd, 'id' => $userId]); $transactionStmt = $pdo->prepare('INSERT INTO transactions (user_id, credits_added) VALUES (:user_id, :credits_added)'); $transactionStmt->execute(['user_id' => $userId, 'credits_added' => $creditsToAdd]); } } } $pdo->commit(); respond(200, ['status' => 'ok']);