Web SDK · API v1

Integrate a website without exposing its key.

Use the package for the complete path: check and collect in the browser, forward to your own server, then call LeadPendingClient with one stable idempotency key. validateLead proves the production payload without creating a lead.

The lp_live_ key is server-only

Keep LEADPENDING_API_KEY in a server secret store. Never put it in browser-prefixed environment variables, rendered HTML, client bundles, logs, or a form payload. Keep your own schema validation and bot challenge before forwarding a lead.

leadpending-web · ESM

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.

Terminal
npm install leadpending-web

Check 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.

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

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.

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 })
  }
}

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.

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" } }
  }'

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.

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.

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.

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
// }

Lead fields and hard limits

FieldTypeRuleDescription
emailstringRequiredTrimmed, lowercased, and required to parse as one plain email address.
messagestringRequiredTrimmed; 1 to 20,000 Unicode code points.
external_idstringOptionalTrimmed; at most 255 UTF-8 bytes. Unique per site and independently deduplicates a submission.
namestringOptionalTrimmed; at most 200 Unicode code points.
phonestringOptionalTrimmed; at most 100 Unicode code points.
companystringOptionalTrimmed; at most 200 Unicode code points.
subjectstringOptionalTrimmed; at most 500 Unicode code points. Empty becomes “Inquiry from {site name}”.
page_urlstringOptionalAt most 2,048 UTF-8 bytes; absolute http(s), with a host and without credentials or a fragment.
localeen | ruOptionalTrimmed and lowercased; empty uses the site's default locale.
metadataobjectOptionalCustomer JSON, at most 20 KiB after encoding. null is normalized to an empty object.
visitorobjectOptionalBest-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.

FieldTypeDescription
ipstringTrimmed; kept only when it is a valid IPv4 or IPv6 address.
user_agentstringTrimmed and truncated to 512 Unicode code points.
referrerstringTrimmed and truncated to 2,048 Unicode code points; not URL-validated.
first_referrerstringTrimmed and truncated to 2,048 Unicode code points.
landing_urlstringTrimmed and truncated to 2,048 Unicode code points.
countrystringTrimmed and uppercased; kept only as two ASCII letters. XX, ZZ, and T1 are dropped.
fingerprintstringSite-computed identifier, trimmed and truncated to 256 Unicode code points.
session_started_atstringSend RFC 3339; the server trims and truncates to 64 code points without parsing it.
utmobjectAt most 24 string entries; keys over 64 UTF-8 bytes are dropped and values are truncated to 512 code points.
clientobjectBrowser/device JSON. The entire object is dropped when its encoding exceeds 8 KiB.
journeyarrayFreshest 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.
extraobjectFree-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.

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…"
}

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.

HTTPCodeMeaning and action
2xxINVALID_RESPONSEThe 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.
400VALIDATION_FAILEDInvalid JSON, an unknown field, wrong type, invalid lead field, or Idempotency-Key over 255 bytes. Fix the request.
401INVALID_API_KEYMissing, malformed, invalid, revoked key, or a key whose site is inactive. Replace the key or activate the site.
403ORGANIZATION_BLOCKEDThe owning organization is blocked. Do not retry until its state changes.
409IDEMPOTENCY_CONFLICTThe same Idempotency-Key was used with different normalized lead fields. Use the original payload or a new submission identity.
413PAYLOAD_TOO_LARGEThe raw request body exceeds 64 KiB. Reduce it.
415UNSUPPORTED_MEDIA_TYPEContent-Type is not application/json. Fix the request.
429RATE_LIMITEDThe API key exceeded its configured intake rate (new keys start at 60 requests/minute). Retry with the same key after backoff.
5xxSERVER_DRAINING / INTERNAL_ERRORTransient 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.

SignalWhenWeight
recipient_domain_undeliverableThe email domain definitively has neither MX nor address records.0.9
message_repeated_characterAt least 4 non-space code points are all the same.0.7
message_no_lettersThe message contains no Unicode letter.0.5
message_very_shortThe trimmed message is under 15 Unicode code points.0.4
message_single_tokenThe 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

  1. Use an ESM-capable build and Node.js 18.13+ for server code; import only public package entry points.
  2. Keep LEADPENDING_API_KEY in a server-only secret store and scan the browser bundle for lp_live_.
  3. Keep your own schema validation and bot challenge; use checkSubmission as an additional classifier.
  4. Call trackPageView on every navigation and pass collectVisitor output to your server.
  5. Generate one submission ID once and reuse it as both external_id and idempotencyKey across uncertain retries.
  6. Add IP, user agent, and country only from trusted server or edge headers through connection.
  7. Run validateLead with a representative production payload and inspect normalization and spam reasons.
  8. Treat only a resolved SDK result as accepted; return a generic failure to the browser and log only safe status/code fields.
  9. Test that aborts do not retry and that two calls with one idempotency key create only one lead.
  10. Disclose any visitor telemetry your site collects in its privacy notice.