false, 'error' => 'Too many attempts. Please wait a moment and try again.', 'retryAfter' => $rl['retryAfter']], 429, ['Retry-After' => (string)$rl['retryAfter']]); } $rlDay = RateLimit::hit(RateLimit::ipBucket('submit:1d', $ip), Config::int('RATE_LIMIT_SUBMIT_PER_DAY', 60), 86400); if (!$rlDay['allowed']) { Api::json(['ok' => false, 'error' => 'Too many attempts today. Text us and we will help directly.', 'retryAfter' => $rlDay['retryAfter']], 429, ['Retry-After' => (string)$rlDay['retryAfter']]); } $input = Api::readJsonBody(); ['data' => $d, 'errors' => $errors] = Validator::planVisit($input); // --- Bot checks. Respond with a fake success so bots learn nothing. if ($d['honeypot'] !== '' || ($d['elapsed_ms'] > 0 && $d['elapsed_ms'] < Config::int('MIN_FORM_SECONDS', 3) * 1000)) { Log::warn('Bot signal on submit', ['honeypot' => $d['honeypot'] !== '', 'elapsed_ms' => $d['elapsed_ms']]); Api::json(['ok' => true, 'submissionId' => $d['submission_id'], 'email' => ['status' => 'skipped'], 'sms' => ['status' => 'skipped']]); } if (!Turnstile::verify($d['turnstile_token'], $ip)) { Api::json(['ok' => false, 'error' => 'Security check failed. Please try again.'], 400); } if ($errors) { Api::json(['ok' => false, 'error' => 'Please check the highlighted fields.', 'fieldErrors' => $errors], 422); } // --- Persist first ['lead' => $lead, 'duplicate' => $duplicate] = LeadProcessor::persist($d, $ip, $ua); Log::info($duplicate ? 'Duplicate submission (idempotent replay)' : 'Lead stored', ['lead' => $lead['id'], 'campus' => $lead['campus_id'], 'email_consent' => (bool)$lead['email_consent'], 'sms_consent' => (bool)$lead['sms_consent']]); // --- Integrations (each guarded; failures are recorded for cron retry) $statuses = LeadProcessor::runIntegrations($lead); $public = static function (string $s): string { return match ($s) { 'sent' => 'sent', 'mock' => 'mock', 'skipped' => 'skipped', 'failed' => 'failed', default => 'queued', // pending / retry / sending }; }; Api::json([ 'ok' => true, 'submissionId' => $lead['submission_id'], 'duplicate' => $duplicate, 'email' => ['status' => $public($statuses['email'])], 'sms' => ['status' => $public($statuses['sms'])], ]);