Web SDK · API v1

Подключите сайт, не раскрывая его ключ.

Пакет закрывает весь путь: проверка и сбор данных в браузере, пересылка на ваш сервер и вызов LeadPendingClient с одним стабильным ключом идемпотентности. validateLead проверяет боевой payload, не создавая заявку.

Ключ lp_live_ хранится только на сервере

Держите LEADPENDING_API_KEY в серверном хранилище секретов. Не добавляйте его в публичные переменные окружения, HTML, клиентский bundle, логи или payload формы. Перед пересылкой сохраняйте собственную проверку схемы и антибот-защиту.

leadpending-web · ESM

Установите современный ESM-пакет

leadpending-web работает только как ESM, не имеет runtime-зависимостей и рассчитан на современные сборщики и Node.js 18.13+. Импортируйте только публичные точки входа: leadpending-web, leadpending-web/browser и leadpending-web/server. В npm-пакете также лежит INTEGRATION.md — автономное задание для coding agents.

Terminal
npm install leadpending-web

Проверьте форму и соберите визит

Вызывайте trackPageView() при каждой навигации. Он безопасно хранит в sessionStorage до 200 шагов, первый referrer, страницу входа, начало сессии и стандартные UTM/gclid/fbclid; collectVisitor() добавляет актуальный контекст браузера при отправке. checkSubmission() только возвращает вердикт: вы решаете, остановить ли заполненный honeypot, неотрицательное время меньше 1200 мс, пустое сообщение, текст короче 3 кодовых точек Unicode или минимум 4 одинаковых непробельных символа.

contact-form.ts
import { checkSubmission, newIdempotencyKey } from 'leadpending-web'
import { collectVisitor, trackPageView } from 'leadpending-web/browser'

// Run on every page navigation. The SDK keeps this tab's journey in sessionStorage.
trackPageView()

// Generate once for this user submission and keep it if your own POST is retried.
const submissionId = newIdempotencyKey('contact')
const verdict = checkSubmission({
  message: form.message.value,
  honeypot: form.company_url.value,
  elapsedMs: Date.now() - formShownAt,
})
if (!verdict.ok) return

await fetch('/api/contact', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    submissionId,
    name: form.name.value,
    email: form.email.value,
    message: form.message.value,
    pageUrl: location.href,
    visitor: collectVisitor(),
  }),
})
POST /api/v1/leads

Создайте заявку из server-only кода

LeadPendingClient отправляет application/json на https://api.leadpending.com/api/v1/leads. Передайте объект visitor из браузера, а IP, user-agent и страну с edge, которые видел ваш сервер, добавьте через connection; непустые connection-поля перекрывают соответствующие поля visitor, не мутируя исходный объект. LeadPending — единственное хранилище заявки: не зеркальте её в локальную строку или событие и не используйте локальный fallback. Сообщайте браузеру об успехе только после завершившегося createLead.

app/api/contact/route.ts
import { LeadPendingClient, LeadPendingError } from 'leadpending-web/server'

const leadpending = new LeadPendingClient({
  apiKey: process.env.LEADPENDING_API_KEY!,
})

export async function POST(request: Request) {
  const form = await request.json()

  try {
    const lead = await leadpending.createLead(
      {
        external_id: form.submissionId,
        name: form.name,
        email: form.email,
        message: form.message,
        page_url: form.pageUrl,
        visitor: form.visitor,
      },
      {
        idempotencyKey: form.submissionId,
        connection: {
          ip: request.headers.get('x-real-ip') ?? undefined,
          userAgent: request.headers.get('user-agent') ?? undefined,
          country: request.headers.get('cf-ipcountry') ?? undefined,
        },
        signal: request.signal,
      },
    )
    return Response.json({ accepted: true, replay: lead.replay })
  } catch (error) {
    if (error instanceof LeadPendingError) {
      console.error('LeadPending rejected lead', { status: error.status, code: error.code })
    }
    return Response.json({ accepted: false }, { status: 502 })
  }
}

Для production рекомендуется SDK. Этот эквивалентный raw-запрос нужен, чтобы независимо проверить endpoint и заголовки; он тоже выполняется только с доверенного сервера, никогда не из браузера.

Raw HTTP check
curl --request POST 'https://api.leadpending.com/api/v1/leads' \
  --header 'Authorization: Bearer lp_live_<server-secret>' \
  --header 'Content-Type: application/json' \
  --header 'Idempotency-Key: contact-018f47f2' \
  --data '{
    "external_id": "contact-018f47f2",
    "name": "Jane Doe",
    "email": "[email protected]",
    "message": "Can you help with three websites?",
    "page_url": "https://example.org/pricing",
    "locale": "en",
    "metadata": { "budget": "3000-5000" },
    "visitor": { "country": "DE", "utm": { "utm_source": "google" } }
  }'

Храните заявку в одном месте и удаляйте по external_id

Не записывайте payload acquisition-формы в базу продукта. Отдельный локальный аккаунт допустим: выводите стабильный external_id из его ID без таблицы соответствий заявок. При удалении сначала вызовите site-scoped DELETE, затем удаляйте аккаунт. Метод мягко удаляет только активный лид точного сайта этого API-ключа, повторно отвечает 204 для уже отсутствующего ID и никогда не принимает браузерный ключ.

Server-side erasure
curl --request DELETE \
  'https://api.leadpending.com/api/v1/leads/by-external-id?external_id=contact-018f47f2' \
  --header 'Authorization: Bearer lp_live_<server-secret>'

# 204 No Content. Repeating the same request is also 204.

Проверьте настоящий payload без создания заявки

validateLead использует ту же авторизацию, строгое декодирование, проверку полей, нормализацию visitor и оценку спама, что и боевой приём. Он возвращает вычисленные subject и locale, очищенный visitor, spam_score и spam_reasons, но не создаёт лид, сообщение или уведомление. До запуска прогоните характерный production-payload с настоящим ключом сайта.

integration.test.ts
const preview = await leadpending.validateLead(
  {
    email: '[email protected]',
    message: 'Can you help with three websites?',
    visitor: { country: 'XX', ip: '203.0.113.77' },
  },
  { connection: { userAgent: 'integration-test' } },
)

// No lead or notification was created:
// {
//   would_accept: true,
//   subject: 'Inquiry from Example',
//   locale: 'en',
//   visitor: { ip: '203.0.113.77', user_agent: 'integration-test' },
//   spam_score: 0
// }

Поля заявки и жёсткие лимиты

ПолеТипПравилоОписание
emailstringОбязательноОбрезается по краям, приводится к нижнему регистру и должен быть одним обычным email-адресом.
messagestringОбязательноОбрезается по краям; от 1 до 20 000 кодовых точек Unicode.
external_idstringОпциональноОбрезается по краям; до 255 байт UTF-8. Уникален в пределах сайта и независимо дедуплицирует отправку.
namestringОпциональноОбрезается по краям; до 200 кодовых точек Unicode.
phonestringОпциональноОбрезается по краям; до 100 кодовых точек Unicode.
companystringОпциональноОбрезается по краям; до 200 кодовых точек Unicode.
subjectstringОпциональноОбрезается по краям; до 500 кодовых точек Unicode. Пустое значение становится «Inquiry from {site name}».
page_urlstringОпциональноДо 2048 байт UTF-8; абсолютный http(s)-адрес с host, без credentials и fragment.
localeen | ruОпциональноОбрезается по краям и приводится к нижнему регистру; пустое значение берётся из настроек сайта.
metadataobjectОпциональноПользовательский JSON до 20 КиБ после кодирования. null превращается в пустой объект.
visitorobjectОпциональноТелеметрия по возможности после строгого JSON-декодирования; нормализация описана ниже.

Контекст посетителя: доверьте сбор SDK

Используйте trackPageView() и collectVisitor(), а не поддерживайте второй ручной collector. Все свойства необязательны. Типы JSON и неизвестные поля строго проверяются при декодировании тела; после этого неверные по формату или слишком большие значения телеметрии удаляются или обрезаются и не отклоняют в остальном валидную заявку.

ПолеТипОписание
ipstringОбрезается по краям; сохраняется только валидный IPv4 или IPv6.
user_agentstringОбрезается по краям и до 512 кодовых точек Unicode.
referrerstringОбрезается по краям и до 2048 кодовых точек Unicode; как URL не проверяется.
first_referrerstringОбрезается по краям и до 2048 кодовых точек Unicode.
landing_urlstringОбрезается по краям и до 2048 кодовых точек Unicode.
countrystringОбрезается, переводится в верхний регистр и сохраняется только как две ASCII-буквы. XX, ZZ и T1 удаляются.
fingerprintstringИдентификатор, рассчитанный сайтом; обрезается до 256 кодовых точек Unicode.
session_started_atstringПередавайте RFC 3339; сервер обрезает до 64 кодовых точек, но дату не парсит.
utmobjectДо 24 строковых пар; ключи длиннее 64 байт UTF-8 удаляются, значения обрезаются до 512 кодовых точек.
clientobjectJSON браузера и устройства. Объект целиком удаляется, если после кодирования он больше 8 КиБ.
journeyarrayПоследние 200 шагов. Обязательный url — до 2048 кодовых точек, title — 512, entered_at — 64; отрицательный целый duration_ms становится 0.
extraobjectСвободный JSON. Объект целиком удаляется, если после кодирования он больше 16 КиБ.

Нормализованный visitor ограничен 48 КиБ. Если он больше, сервер удаляет самые старые шаги journey, пока объект не поместится; если этого недостаточно, удаляет visitor целиком. Пустой объект не возвращается. Для сырого тела запроса действует жёсткий лимит 64 КиБ, а неизвестные JSON-поля и неверные JSON-типы вернут 400 до этой best-effort очистки.

Один идентификатор на одну отправку

Передавайте Idempotency-Key длиной до 255 байт UTF-8 и повторяйте его при каждой попытке той же отправки. Если ключ не указан, SDK создаёт его один раз на вызов createLead, но явный submission ID защищает и повторный вызов на уровне приложения. Сервер сравнивает нормализованные поля лида, включая metadata, но исключает visitor и метаданные соединения: тот же ключ и тот же лид вернут исходный ресурс с 200, а изменённое поле — 409. external_id — отдельный ключ дедупликации внутри сайта: существующее значение вернёт исходный ресурс с 200 без сравнения нового тела.

По умолчанию клиент делает до 3 попыток: исходный запрос и 2 ретрая. Он повторяет сетевые сбои, 429 и любые 5xx с экспоненциальными задержками 500 мс, 1000 мс и далее при иной настройке, всегда с тем же Idempotency-Key. Числовой Retry-After трактуется как секунды. Остальные 4xx и AbortError не повторяются. Уже отменённый signal или отмена во время backoff немедленно завершает публичный вызов, сохраняет явную signal.reason (иначе AbortError) и не запускает следующий запрос.

Успешные ответы

Новая заявка возвращает 201. Совпадение по Idempotency-Key или external_id возвращает существующую заявку с 200; LeadResult.replay вычисляется из статуса. Оба ответа имеют вид { data: { id, status, created_at }, request_id }. Принятой считается только заявка, для которой SDK успешно разобрал 2xx; некорректные данные в 2xx возвращаются как INVALID_RESPONSE.

201 Created
HTTP/1.1 201 Created
Content-Type: application/json

{
  "data": {
    "id": "0198…",
    "status": "pending",
    "created_at": "2026-08-02T12:00:00Z"
  },
  "request_id": "0198…"
}

Типизированные сбои и HTTP-коды

Корректный non-2xx ответ API выбрасывает LeadPendingError со status, code и безопасным синтетическим message; details и request_id сервера SDK наружу не отдаёт. Сетевой сбой остаётся исходной ошибкой, отмена — AbortError или явная signal.reason. Логируйте только status и code, никогда не ключ, email, message или visitor.

HTTPКодСмысл и действие
2xxINVALID_RESPONSEТело ответа пустое, сломано, не содержит объект data или не соответствует успешной схеме. Не считайте заявку принятой и разберитесь; ошибка сохраняет фактический HTTP-статус.
400VALIDATION_FAILEDНекорректный JSON, неизвестное поле, неверный тип, невалидное поле лида или Idempotency-Key длиннее 255 байт. Исправьте запрос.
401INVALID_API_KEYКлюч отсутствует, неверно оформлен, невалиден, отозван или его сайт неактивен. Замените ключ или активируйте сайт.
403ORGANIZATION_BLOCKEDОрганизация-владелец заблокирована. Не повторяйте до изменения её состояния.
409IDEMPOTENCY_CONFLICTТот же Idempotency-Key использован с другими нормализованными полями лида. Повторите исходное тело или создайте новый ID отправки.
413PAYLOAD_TOO_LARGEСырое тело запроса больше 64 КиБ. Уменьшите его.
415UNSUPPORTED_MEDIA_TYPEContent-Type отличается от application/json. Исправьте запрос.
429RATE_LIMITEDКлюч превысил настроенную частоту приёма (новый ключ получает 60 запросов в минуту). Повторите с тем же ключом после паузы.
5xxSERVER_DRAINING / INTERNAL_ERRORВременный сбой сервиса. SDK повторяет запрос с тем же ключом идемпотентности.

Оценка спама и проверка e-mail

Спам-сигналы только классифицируют и никогда не отклоняют структурно валидную заявку. Новый реальный лид сохраняется даже с высоким скором, а решение остаётся за оператором.

checkSubmission() — отдельный браузерный preflight-классификатор. Он возвращает только первую сработавшую причину: honeypot_filled, submitted_too_fast, message_empty, message_repeated_character или message_very_short. Останавливать отправку или нет, решает вызывающий код.

На приёме сервер обрезает message, считает осторожные текстовые сигналы и проверяет, есть ли у домена e-mail MX либо, как fallback, A/AAAA. DNS ограничен 2 секундами, результат кэшируется на 30 минут. Таймаут или временный сбой резолвера обрабатывается fail-open и не добавляет сигнал.

СигналКогдаВес
recipient_domain_undeliverableУ домена e-mail точно нет ни MX, ни адресной записи.0.9
message_repeated_characterМинимум 4 непробельные кодовые точки одинаковы.0.7
message_no_lettersВ сообщении нет ни одной Unicode-буквы.0.5
message_very_shortОбрезанное сообщение короче 15 кодовых точек Unicode.0.4
message_single_tokenВ обрезанном сообщении не больше одного разделённого пробелами поля.0.3

Скор считается как P = 1 − Π(1 − Pi) и ограничен сверху 0.99. Значение 0.5 и выше показывается в кабинете как подозрительное. validateLead возвращает тот же скор и стабильные коды причин до запуска.

Чек-лист перед production

  1. Используйте ESM-сборку и Node.js 18.13+ на сервере; импортируйте только публичные точки входа пакета.
  2. Храните LEADPENDING_API_KEY только на сервере и проверьте браузерный bundle на lp_live_.
  3. Сохраните собственную проверку схемы и антибот-защиту; используйте checkSubmission как дополнительный классификатор.
  4. Вызывайте trackPageView при каждой навигации и передавайте результат collectVisitor на свой сервер.
  5. Один раз создайте submission ID и повторяйте его как external_id и idempotencyKey при неопределённых сбоях.
  6. Добавляйте IP, user-agent и страну только из доверенных серверных или edge-заголовков через connection.
  7. Запустите validateLead с характерным production-payload и проверьте нормализацию и причины спама.
  8. Считайте успехом только завершившийся SDK-вызов; браузеру возвращайте общую ошибку, а в лог пишите только безопасные status/code.
  9. Проверьте, что отмена не ретраится, а два вызова с одним ключом идемпотентности создают один лид.
  10. Раскройте в политике конфиденциальности сайта ту телеметрию visitor, которую он собирает.