Установите современный ESM-пакет
leadpending-web работает только как ESM, не имеет runtime-зависимостей и рассчитан на современные сборщики и Node.js 18.13+. Импортируйте только публичные точки входа: leadpending-web, leadpending-web/browser и leadpending-web/server. В npm-пакете также лежит INTEGRATION.md — автономное задание для coding agents.
npm install leadpending-webПроверьте форму и соберите визит
Вызывайте trackPageView() при каждой навигации. Он безопасно хранит в sessionStorage до 200 шагов, первый referrer, страницу входа, начало сессии и стандартные UTM/gclid/fbclid; collectVisitor() добавляет актуальный контекст браузера при отправке. checkSubmission() только возвращает вердикт: вы решаете, остановить ли заполненный honeypot, неотрицательное время меньше 1200 мс, пустое сообщение, текст короче 3 кодовых точек Unicode или минимум 4 одинаковых непробельных символа.
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(),
}),
})Создайте заявку из server-only кода
LeadPendingClient отправляет application/json на https://api.leadpending.com/api/v1/leads. Передайте объект visitor из браузера, а IP, user-agent и страну с edge, которые видел ваш сервер, добавьте через connection; непустые connection-поля перекрывают соответствующие поля visitor, не мутируя исходный объект. LeadPending — единственное хранилище заявки: не зеркальте её в локальную строку или событие и не используйте локальный fallback. Сообщайте браузеру об успехе только после завершившегося createLead.
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 и заголовки; он тоже выполняется только с доверенного сервера, никогда не из браузера.
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 и никогда не принимает браузерный ключ.
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 с настоящим ключом сайта.
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
// }Поля заявки и жёсткие лимиты
| Поле | Тип | Правило | Описание |
|---|---|---|---|
| string | Обязательно | Обрезается по краям, приводится к нижнему регистру и должен быть одним обычным email-адресом. | |
| message | string | Обязательно | Обрезается по краям; от 1 до 20 000 кодовых точек Unicode. |
| external_id | string | Опционально | Обрезается по краям; до 255 байт UTF-8. Уникален в пределах сайта и независимо дедуплицирует отправку. |
| name | string | Опционально | Обрезается по краям; до 200 кодовых точек Unicode. |
| phone | string | Опционально | Обрезается по краям; до 100 кодовых точек Unicode. |
| company | string | Опционально | Обрезается по краям; до 200 кодовых точек Unicode. |
| subject | string | Опционально | Обрезается по краям; до 500 кодовых точек Unicode. Пустое значение становится «Inquiry from {site name}». |
| page_url | string | Опционально | До 2048 байт UTF-8; абсолютный http(s)-адрес с host, без credentials и fragment. |
| locale | en | ru | Опционально | Обрезается по краям и приводится к нижнему регистру; пустое значение берётся из настроек сайта. |
| metadata | object | Опционально | Пользовательский JSON до 20 КиБ после кодирования. null превращается в пустой объект. |
| visitor | object | Опционально | Телеметрия по возможности после строгого JSON-декодирования; нормализация описана ниже. |
Контекст посетителя: доверьте сбор SDK
Используйте trackPageView() и collectVisitor(), а не поддерживайте второй ручной collector. Все свойства необязательны. Типы JSON и неизвестные поля строго проверяются при декодировании тела; после этого неверные по формату или слишком большие значения телеметрии удаляются или обрезаются и не отклоняют в остальном валидную заявку.
| Поле | Тип | Описание |
|---|---|---|
| ip | string | Обрезается по краям; сохраняется только валидный IPv4 или IPv6. |
| user_agent | string | Обрезается по краям и до 512 кодовых точек Unicode. |
| referrer | string | Обрезается по краям и до 2048 кодовых точек Unicode; как URL не проверяется. |
| first_referrer | string | Обрезается по краям и до 2048 кодовых точек Unicode. |
| landing_url | string | Обрезается по краям и до 2048 кодовых точек Unicode. |
| country | string | Обрезается, переводится в верхний регистр и сохраняется только как две ASCII-буквы. XX, ZZ и T1 удаляются. |
| fingerprint | string | Идентификатор, рассчитанный сайтом; обрезается до 256 кодовых точек Unicode. |
| session_started_at | string | Передавайте RFC 3339; сервер обрезает до 64 кодовых точек, но дату не парсит. |
| utm | object | До 24 строковых пар; ключи длиннее 64 байт UTF-8 удаляются, значения обрезаются до 512 кодовых точек. |
| client | object | JSON браузера и устройства. Объект целиком удаляется, если после кодирования он больше 8 КиБ. |
| journey | array | Последние 200 шагов. Обязательный url — до 2048 кодовых точек, title — 512, entered_at — 64; отрицательный целый duration_ms становится 0. |
| extra | object | Свободный 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.
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 | Код | Смысл и действие |
|---|---|---|
| 2xx | INVALID_RESPONSE | Тело ответа пустое, сломано, не содержит объект data или не соответствует успешной схеме. Не считайте заявку принятой и разберитесь; ошибка сохраняет фактический HTTP-статус. |
| 400 | VALIDATION_FAILED | Некорректный JSON, неизвестное поле, неверный тип, невалидное поле лида или Idempotency-Key длиннее 255 байт. Исправьте запрос. |
| 401 | INVALID_API_KEY | Ключ отсутствует, неверно оформлен, невалиден, отозван или его сайт неактивен. Замените ключ или активируйте сайт. |
| 403 | ORGANIZATION_BLOCKED | Организация-владелец заблокирована. Не повторяйте до изменения её состояния. |
| 409 | IDEMPOTENCY_CONFLICT | Тот же Idempotency-Key использован с другими нормализованными полями лида. Повторите исходное тело или создайте новый ID отправки. |
| 413 | PAYLOAD_TOO_LARGE | Сырое тело запроса больше 64 КиБ. Уменьшите его. |
| 415 | UNSUPPORTED_MEDIA_TYPE | Content-Type отличается от application/json. Исправьте запрос. |
| 429 | RATE_LIMITED | Ключ превысил настроенную частоту приёма (новый ключ получает 60 запросов в минуту). Повторите с тем же ключом после паузы. |
| 5xx | SERVER_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
- Используйте ESM-сборку и Node.js 18.13+ на сервере; импортируйте только публичные точки входа пакета.
- Храните LEADPENDING_API_KEY только на сервере и проверьте браузерный bundle на lp_live_.
- Сохраните собственную проверку схемы и антибот-защиту; используйте checkSubmission как дополнительный классификатор.
- Вызывайте trackPageView при каждой навигации и передавайте результат collectVisitor на свой сервер.
- Один раз создайте submission ID и повторяйте его как external_id и idempotencyKey при неопределённых сбоях.
- Добавляйте IP, user-agent и страну только из доверенных серверных или edge-заголовков через connection.
- Запустите validateLead с характерным production-payload и проверьте нормализацию и причины спама.
- Считайте успехом только завершившийся SDK-вызов; браузеру возвращайте общую ошибку, а в лог пишите только безопасные status/code.
- Проверьте, что отмена не ретраится, а два вызова с одним ключом идемпотентности создают один лид.
- Раскройте в политике конфиденциальности сайта ту телеметрию visitor, которую он собирает.