$v) { if (strcasecmp($k, $name) === 0) return $v; } } $serverKey = 'HTTP_' . strtoupper(str_replace('-', '_', $name)); return $_SERVER[$serverKey] ?? ''; } // ── Resolve the notification URL exactly as registered (signature depends on it). function sqNotificationUrl(array $config): string { $u = trim($config['webhook_url'] ?? ''); if ($u !== '') return $u; // Fallback: reconstruct from the request (works when not behind a rewriting proxy). $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? 'https'); $host = $_SERVER['HTTP_HOST'] ?? ''; $uri = $_SERVER['REQUEST_URI'] ?? ''; return $scheme . '://' . $host . $uri; } // ── Guard: signature key must be configured ────────────────────────────────── $signatureKey = $config['webhook_signature_key'] ?? ''; if ($signatureKey === '') { sqLog('square-webhook: webhook_signature_key not set — refusing to process unverified events'); http_response_code(500); echo json_encode(['status' => 'signature_key_not_configured']); exit(); } // ── Verify HMAC-SHA256 signature ───────────────────────────────────────────── // signature = base64( HMAC_SHA256( key, notificationUrl + rawBody ) ) $providedSig = sqWebhookHeader('x-square-hmacsha256-signature'); if ($providedSig === '') { sqLog('square-webhook: missing x-square-hmacsha256-signature header — ignored'); http_response_code(403); echo json_encode(['status' => 'missing_signature']); exit(); } $notificationUrl = sqNotificationUrl($config); $expectedSig = base64_encode(hash_hmac('sha256', $notificationUrl . $rawBody, $signatureKey, true)); if (!hash_equals($expectedSig, $providedSig)) { sqLog('square-webhook: signature MISMATCH (notification_url=' . $notificationUrl . ') — rejected'); http_response_code(403); echo json_encode(['status' => 'signature_verification_failed']); exit(); } // ── Parse the verified event ───────────────────────────────────────────────── $event = json_decode($rawBody, true) ?: []; $eventType = $event['type'] ?? ''; $merchantId = $event['merchant_id'] ?? ($event['data']['object']['merchant_id'] ?? ''); sqLog('square-webhook: verified type=' . $eventType . ' event_id=' . ($event['event_id'] ?? '?') . ' merchant_id=' . ($merchantId ?: '?')); http_response_code(200); try { $store = new MerchantStore($config); // ════════════════════════════════════════════════════════════════════════ // EVENT: oauth.authorization.revoked // The seller removed our application from THEIR Square account. The tokens // are already dead at Square — clear ours and flag the school so payments // stop and staff know a reconnect is required. // ════════════════════════════════════════════════════════════════════════ if ($eventType === 'oauth.authorization.revoked') { if ($merchantId === '') { sqLog('square-webhook: revoked event without merchant_id — nothing to do'); echo json_encode(['status' => 'ok', 'note' => 'no merchant_id']); exit(); } $school = $store->markAuthorizationRevoked($merchantId); sqLog('square-webhook: authorization revoked for merchant_id=' . $merchantId . ($school ? (' (school=' . $school . ')') : ' (no matching school)')); echo json_encode(['status' => 'ok', 'school' => $school]); exit(); } // ── Unhandled (but verified) event — 200 so Square stops retrying ───────── sqLog('square-webhook: unhandled event type=' . $eventType); echo json_encode(['status' => 'ok', 'note' => 'event type not handled']); exit(); } catch (Throwable $e) { // Respond 200 so Square doesn't hammer us; the failure is logged for review. sqLog('square-webhook: handler error — ' . $e->getMessage()); echo json_encode(['status' => 'ok', 'note' => 'handler error logged']); exit(); }