Install the modern ESM package
leadpending-web is ESM-only, has no runtime dependencies, and targets modern bundlers and Node.js 18.13 or newer. Import only its public entry points: leadpending-web, leadpending-web/browser, and leadpending-web/server. The npm package also contains INTEGRATION.md as an offline handoff for coding agents.
npm install leadpending-webCheck the submission and collect the visit
Call trackPageView() on every navigation. It safely keeps up to 200 page steps, first referrer, landing URL, session start, and the standard UTM/gclid/fbclid keys in sessionStorage; collectVisitor() adds live browser context at submit time. checkSubmission() returns a verdict only: you decide whether to stop a filled honeypot, a non-negative elapsed time below 1,200 ms, an empty message, fewer than 3 Unicode code points, or one non-space character repeated at least 4 times.
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(),
}),
})Create the lead from server-only code
LeadPendingClient sends application/json to https://api.leadpending.com/api/v1/leads. Pass the browser visitor object through, then use connection for IP, user agent, and edge country observed by your server; non-empty connection values override those visitor fields without mutating the input. LeadPending is the only application store: do not mirror, cache, or fall back to a local lead row or submission event. Only return success after createLead resolves.
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 })
}
}The SDK is the recommended production path. This equivalent raw request is useful for checking the endpoint and headers independently; it still runs only from a trusted server, never from browser code.
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" } }
}'Keep one application store and erase by external ID
Do not insert or upsert an acquisition-form payload into a product database. A separate local account is fine: derive the same stable external_id from its ID without an application mapping. On erasure, call the site-scoped DELETE endpoint before deleting the account. It soft-deletes only the active lead belonging to this API key's exact site, returns 204 when already absent, and never accepts a browser credential.
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.Validate the real payload without creating a lead
validateLead uses the same authentication, strict decoding, lead validation, visitor normalization, and spam scoring as intake. It returns the resolved subject and locale, normalized visitor block, spam_score, and spam_reasons, but creates no lead, message, or notification. Run it with a representative production payload and a real site key before launch.
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
// }Lead fields and hard limits
| Field | Type | Rule | Description |
|---|---|---|---|
| string | Required | Trimmed, lowercased, and required to parse as one plain email address. | |
| message | string | Required | Trimmed; 1 to 20,000 Unicode code points. |
| external_id | string | Optional | Trimmed; at most 255 UTF-8 bytes. Unique per site and independently deduplicates a submission. |
| name | string | Optional | Trimmed; at most 200 Unicode code points. |
| phone | string | Optional | Trimmed; at most 100 Unicode code points. |
| company | string | Optional | Trimmed; at most 200 Unicode code points. |
| subject | string | Optional | Trimmed; at most 500 Unicode code points. Empty becomes “Inquiry from {site name}”. |
| page_url | string | Optional | At most 2,048 UTF-8 bytes; absolute http(s), with a host and without credentials or a fragment. |
| locale | en | ru | Optional | Trimmed and lowercased; empty uses the site's default locale. |
| metadata | object | Optional | Customer JSON, at most 20 KiB after encoding. null is normalized to an empty object. |
| visitor | object | Optional | Best-effort telemetry after strict JSON decoding; normalized as described below. |
Visitor context: let the SDK collect it
Use trackPageView() and collectVisitor() instead of maintaining a second collector. Every property is optional. JSON types and unknown fields are checked strictly when the body is decoded; after that, malformed or oversized telemetry values are dropped or truncated instead of rejecting an otherwise valid lead.
| Field | Type | Description |
|---|---|---|
| ip | string | Trimmed; kept only when it is a valid IPv4 or IPv6 address. |
| user_agent | string | Trimmed and truncated to 512 Unicode code points. |
| referrer | string | Trimmed and truncated to 2,048 Unicode code points; not URL-validated. |
| first_referrer | string | Trimmed and truncated to 2,048 Unicode code points. |
| landing_url | string | Trimmed and truncated to 2,048 Unicode code points. |
| country | string | Trimmed and uppercased; kept only as two ASCII letters. XX, ZZ, and T1 are dropped. |
| fingerprint | string | Site-computed identifier, trimmed and truncated to 256 Unicode code points. |
| session_started_at | string | Send RFC 3339; the server trims and truncates to 64 code points without parsing it. |
| utm | object | At most 24 string entries; keys over 64 UTF-8 bytes are dropped and values are truncated to 512 code points. |
| client | object | Browser/device JSON. The entire object is dropped when its encoding exceeds 8 KiB. |
| journey | array | Freshest 200 steps. url is required and capped at 2,048 code points; title at 512, entered_at at 64; a negative integer duration_ms becomes 0. |
| extra | object | Free-form JSON. The entire object is dropped when its encoding exceeds 16 KiB. |
The normalized visitor object is capped at 48 KiB. If it is larger, the server removes the oldest journey steps until it fits; if it still cannot fit, it drops the whole visitor object. An empty object is omitted. The raw request body still has a hard 64 KiB limit, and unknown JSON fields or wrong JSON types return 400 before this best-effort cleanup.
One identity for one submission
Pass an Idempotency-Key of at most 255 UTF-8 bytes and reuse it for every attempt of the same submission. The SDK generates one once per createLead call if omitted, but an explicit submission ID also protects a later application-level call. The server compares normalized lead fields, including metadata, but excludes visitor telemetry and connection metadata: the same key and same lead returns the original resource with 200; changing a lead field under that key returns 409. external_id is a separate site-scoped deduplication key: an existing value returns the original resource with 200 without comparing the new body.
By default the client makes up to 3 attempts: the first request plus 2 retries. It retries network failures, 429, and every 5xx with exponential delays of 500 ms, 1,000 ms, then longer if configured, always reusing the same Idempotency-Key. A numeric Retry-After value is interpreted as seconds. Other 4xx responses and AbortError are not retried. An already-aborted signal or an abort during backoff settles the public call immediately, preserves an explicit signal.reason (otherwise AbortError), and starts no next request.
Success responses
A new lead returns 201. A match by Idempotency-Key or external_id returns the existing lead with 200; LeadResult.replay is derived from that status. Both responses use { data: { id, status, created_at }, request_id }. Only a resolved, validated 2xx SDK result means accepted; malformed 2xx response data is reported as 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…"
}Typed failures and HTTP codes
A valid non-2xx API response throws LeadPendingError with status, code, and a safe synthetic message; server details and request IDs are not exposed by the SDK. Network failures remain their original errors, and cancellation remains AbortError or the signal's explicit reason. Log status and code only—never the key, email, message, or visitor payload.
| HTTP | Code | Meaning and action |
|---|---|---|
| 2xx | INVALID_RESPONSE | The response body is empty, malformed, lacks an object data envelope, or has the wrong success schema. Treat it as not accepted and investigate; the error keeps the actual HTTP status. |
| 400 | VALIDATION_FAILED | Invalid JSON, an unknown field, wrong type, invalid lead field, or Idempotency-Key over 255 bytes. Fix the request. |
| 401 | INVALID_API_KEY | Missing, malformed, invalid, revoked key, or a key whose site is inactive. Replace the key or activate the site. |
| 403 | ORGANIZATION_BLOCKED | The owning organization is blocked. Do not retry until its state changes. |
| 409 | IDEMPOTENCY_CONFLICT | The same Idempotency-Key was used with different normalized lead fields. Use the original payload or a new submission identity. |
| 413 | PAYLOAD_TOO_LARGE | The raw request body exceeds 64 KiB. Reduce it. |
| 415 | UNSUPPORTED_MEDIA_TYPE | Content-Type is not application/json. Fix the request. |
| 429 | RATE_LIMITED | The API key exceeded its configured intake rate (new keys start at 60 requests/minute). Retry with the same key after backoff. |
| 5xx | SERVER_DRAINING / INTERNAL_ERROR | Transient service failure. The SDK retries it with the same idempotency key. |
Spam scoring and email checks
Spam signals classify; they never reject a structurally valid lead. Every new real lead is stored even when its score is high, and the operator remains in control.
checkSubmission() is a separate preflight classifier for the browser. It returns only the first reason that fires: honeypot_filled, submitted_too_fast, message_empty, message_repeated_character, or message_very_short. The caller decides whether to stop the submission.
Intake trims the message, scores conservative content signals, and checks whether the email domain has MX or, as a fallback, A/AAAA records. DNS has a 2-second timeout and a 30-minute cache. Timeout or temporary resolver failure is fail-open: it adds no signal.
| Signal | When | Weight |
|---|---|---|
| recipient_domain_undeliverable | The email domain definitively has neither MX nor address records. | 0.9 |
| message_repeated_character | At least 4 non-space code points are all the same. | 0.7 |
| message_no_letters | The message contains no Unicode letter. | 0.5 |
| message_very_short | The trimmed message is under 15 Unicode code points. | 0.4 |
| message_single_token | The trimmed message has at most one whitespace-separated field. | 0.3 |
Scores combine as P = 1 − Π(1 − Pi) and are capped at 0.99. A score of 0.5 or more is shown as suspicious in the workspace. validateLead returns the same score and stable reason codes before launch.
Production checklist
- Use an ESM-capable build and Node.js 18.13+ for server code; import only public package entry points.
- Keep LEADPENDING_API_KEY in a server-only secret store and scan the browser bundle for lp_live_.
- Keep your own schema validation and bot challenge; use checkSubmission as an additional classifier.
- Call trackPageView on every navigation and pass collectVisitor output to your server.
- Generate one submission ID once and reuse it as both external_id and idempotencyKey across uncertain retries.
- Add IP, user agent, and country only from trusted server or edge headers through connection.
- Run validateLead with a representative production payload and inspect normalization and spam reasons.
- Treat only a resolved SDK result as accepted; return a generic failure to the browser and log only safe status/code fields.
- Test that aborts do not retry and that two calls with one idempotency key create only one lead.
- Disclose any visitor telemetry your site collects in its privacy notice.