LeadScout API

Build on LeadScout

Sync prospects with your CRM, push in leads from other tools, and react to what your team does in the field.

Overview

The LeadScout API is a JSON REST API over HTTPS. It reads and writes prospects, notes, appointments and statuses in one organization, and sends webhooks when they change.

EnvironmentBase URL
Productionhttps://app.leadscoutapp.com/api/v1
Sandboxhttps://app.dev.leadscoutapp.com/api/v1
  • Requires an organization on an active Pro plan or higher.
  • camelCase keys, string ids, RFC 3339 UTC timestamps. null means known empty.
  • One resource comes back as { "data": { … } }. Lists add a meta object for paging.
  • Unknown request fields are rejected, not ignored. Typos fail loudly.
  • Every response carries an X-Request-Id. Include it when you contact support.

Quickstart

  1. In LeadScout, open Settings → Integrations → LeadScout API and create an API key. Pick the scopes it needs. The key is shown once.
  2. Check the key:
Request
curl https://app.leadscoutapp.com/api/v1/account \
  -H "Authorization: Bearer $LEADSCOUT_API_KEY"
Response
{
  "data": {
    "organization": {
      "id": "3f6c1a9e-2b7d-4c1e-9a55-8e0d4b2f7c61",
      "name": "Lakeshore Roofing",
      "slug": "lakeshore-roofing"
    },
    "credential": { "type": "api_key", "name": "HubSpot sync" },
    "scopes": ["prospects:read", "prospects:write"],
    "actor": { "id": "1204", "name": "HubSpot sync (API)" }
  }
}
  1. Create your first prospect:
Request
curl https://app.leadscoutapp.com/api/v1/prospects \
  -H "Authorization: Bearer $LEADSCOUT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Jordan Rivera",
    "phoneNumber": "+1 616-555-0142",
    "location": { "lat": 42.9634, "lng": -85.6681 },
    "address": {
      "line1": "120 Fulton St W",
      "city": "Grand Rapids",
      "state": "MI",
      "zip": "49503"
    },
    "source": "hubspot",
    "sourceId": "90210"
  }'

That's it. The pin shows up on your team's map, credited to <key name> (API). Every endpoint is in the API reference, where you can run requests with your key.

Authentication

Send a token on every request as Authorization: Bearer <token>. Every token belongs to exactly one organization, so there is no organization header. There are two kinds.

API keyOAuth 2.0
Use it forYour own server-to-server integrationAn app that other organizations connect
Acts asThe key itself, shown as "<key name> (API)"The person who approved the app
Tokenlsk_…lsa_…
LifetimeUntil revoked1 hour, refreshable
Created inSettings → Integrations → LeadScout APISame page, under OAuth apps

API keys

  • Owners and admins create keys. Each key has a name and a set of scopes.
  • A key is its own actor. Prospects, notes and status changes it makes show in LeadScout as <key name> (API). Renaming the key renames the actor.
  • Each active key uses a seat on your plan, like a team member.
  • The full key is shown once. Store it in a secret manager. Revoke it from the same page.

OAuth 2.0

Use OAuth when your app acts for someone else's organization. It's the authorization code flow with PKCE, and S256 is required.

Register your app in Settings → Integrations → LeadScout API. You get a client ID (lsapp_…) and a client secret (lss_…). The secret is shown once. Add every redirect URI your app uses; they must match exactly.

1. Send the user to LeadScout

Node.js
import crypto from 'node:crypto'

// Keep verifier and state in the user's session until the callback.
const verifier = crypto.randomBytes(32).toString('base64url')
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
const state = crypto.randomBytes(16).toString('base64url')

const url = new URL('https://app.leadscoutapp.com/oauth/authorize')
url.search = new URLSearchParams({
  response_type: 'code',
  client_id: process.env.LEADSCOUT_CLIENT_ID,
  redirect_uri: 'https://yourapp.com/leadscout/callback',
  scope: 'prospects:read prospects:write',
  state,
  code_challenge: challenge,
  code_challenge_method: 'S256',
}).toString()

// Redirect the user to url.

The user signs in, picks an organization where they are an owner or admin, and approves your scopes.

2. Handle the callback

LeadScout redirects back with a code. Check that state matches what you stored. The code works once and expires after 10 minutes. If the user declines, you get error=access_denied instead.

Callback
https://yourapp.com/leadscout/callback?code=lsc_...&state=...

3. Exchange the code for tokens

The token endpoint takes form-encoded bodies. Authenticate with HTTP Basic, or send client_id and client_secret in the body.

Request
curl https://app.leadscoutapp.com/api/oauth/token \
  -u "$LEADSCOUT_CLIENT_ID:$LEADSCOUT_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="https://yourapp.com/leadscout/callback" \
  -d code_verifier="$VERIFIER"
Response
{
  "access_token": "lsa_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "lsr_...",
  "scope": "prospects:read prospects:write"
}

4. Call the API

Send the access token as a bearer token. It lasts 1 hour and works only while the user is still an owner or admin of that organization.

5. Refresh

Request
curl https://app.leadscoutapp.com/api/oauth/token \
  -u "$LEADSCOUT_CLIENT_ID:$LEADSCOUT_CLIENT_SECRET" \
  -d grant_type=refresh_token \
  -d refresh_token="$REFRESH_TOKEN"

Refresh tokens last 90 days and rotate on every use. Save the new refresh token each time. Reusing an old one revokes the whole authorization, and the user has to connect again.

6. Disconnect

Request
curl https://app.leadscoutapp.com/api/oauth/revoke \
  -u "$LEADSCOUT_CLIENT_ID:$LEADSCOUT_CLIENT_SECRET" \
  -d token="$REFRESH_TOKEN"

Revoking either token ends the whole authorization and deletes the webhooks it created. The OAuth endpoints return standard OAuth errors, { "error", "error_description" }, and allow 60 requests per minute per app.

Testing

Use the sandbox at app.dev.leadscoutapp.com for the same flow: /oauth/authorize, /api/oauth/token and /api/oauth/revoke.

Pins and geocoding

A prospect needs a location or an address when you create it.

The API never geocodes

Send location (lat, lng) to put a pin on the map. An address without a location is stored, but it has no pin until you add coordinates. Geocode on your side if your source only has addresses.

API writes also don't pull property data, roof or solar insights, or send SMS review requests. Those run only from the LeadScout apps.

Status changes go through POST /prospects/{id}/status so they land in the status history and run your Zapier and CompanyCam automations. PATCH rejects statusId with field_not_mutable. Use source and sourceId to keep your own ids on the record.

Pagination and sync

Lists use cursors. Pass limit (1 to 100, default 50) and follow meta.nextCursor until it is null.

Response
{
  "data": [ { "id": "10234", "updatedAt": "2026-09-28T14:03:11.482Z", ... } ],
  "meta": { "hasMore": true, "nextCursor": "MjAyNi0wOS0yOFQxNDowMzoxMS40ODJafDEwMjM0" }
}

Lists are ordered by updatedAt, then id. That makes incremental sync simple:

  • updated_afterreturns rows changed at or after a time. It's inclusive, so you may see a row twice. Upsert by id.
  • Timestamps need an offset: 2026-09-01T00:00:00Z works, 2026-09-01T00:00:00 does not.
  • Deletes are soft. Add include_deleted=true and deleted rows come back with deletedAt set.
  • Keep the other query parameters the same while you follow a cursor.
  • Statuses and tags are small, so they always come back in one page. Webhooks too: at most 10 per credential.
Node.js
const BASE = 'https://app.leadscoutapp.com/api/v1'

// since: the checkpoint you saved last run (null the first time).
async function syncProspects(since) {
  let cursor = null
  let checkpoint = since
  do {
    const url = new URL(`${BASE}/prospects`)
    url.searchParams.set('limit', '100')
    url.searchParams.set('include_deleted', 'true')
    if (since) url.searchParams.set('updated_after', since)
    if (cursor) url.searchParams.set('cursor', cursor)

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.LEADSCOUT_API_KEY}` },
    })
    const body = await res.json()
    if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`)

    for (const prospect of body.data) {
      if (prospect.deletedAt) await deleteLocal(prospect.id)
      else await upsertLocal(prospect) // keyed by id, so replays are harmless
      checkpoint = prospect.updatedAt
    }
    cursor = body.meta.nextCursor
  } while (cursor)

  return checkpoint // save it, pass it back next run
}

Errors

Errors use standard HTTP status codes and one body shape. Match on code, not on message.

Response
{
  "error": {
    "code": "validation_failed",
    "message": "The request body is invalid",
    "requestId": "req_4f1d9c2b7a6e48d3b0c5e1f2a3b4c5d6",
    "details": [
      { "field": "location.lat", "reason": "Number must be less than or equal to 90" }
    ]
  }
}
StatusCodeMeaning
400invalid_jsonThe body is not valid JSON.
400invalid_parameterA query parameter is malformed.
400invalid_cursorThe cursor is invalid.
401unauthorizedNo bearer token was sent.
401invalid_tokenThe token is invalid, expired or revoked.
403insufficient_scopeThe token lacks the scope this endpoint needs.
403plan_requiredThe organization is not on an active Pro plan.
404not_foundNo such resource in this organization.
409conflictThe request conflicts with the current state.
409limit_reachedAn organization limit was hit.
422validation_failedThe body is invalid, or an id in it is unknown. See details.
422location_requiredCreate needs a location or an address.
422field_not_mutableThat field cannot be changed here.
429rate_limitedToo many requests. See Retry-After.
500internal_errorOur fault. Retry reads; check state before retrying writes.
502calendar_errorGoogle Calendar rejected an appointment change. Nothing was saved.

An id from another organization returns 404, never 403. So does a deleted prospect or a canceled appointment.

Rate limits

Each credential gets 120 requests per minute. Every authenticated response reports where you stand:

HeaderValue
X-RateLimit-LimitRequests allowed per minute.
X-RateLimit-RemainingRequests left in this window.
X-RateLimit-ResetUnix time in seconds when the window resets.

Over the limit, you get 429 rate_limited with a Retry-After header in seconds. Wait that long, then continue. For backfills, run requests one at a time rather than in parallel.

Webhooks

Webhooks tell you when something changes, so you don't have to poll as often. Create one with a token that has webhooks:manage and prospects:read (events contain prospect data). The response includes the signing secret once.

Request
curl https://app.leadscoutapp.com/api/v1/webhooks \
  -H "Authorization: Bearer $LEADSCOUT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/leadscout/webhooks",
    "events": ["prospect.created", "prospect.updated", "prospect.status_changed"]
  }'
EventSent whendata
prospect.createdA prospect is created.Prospect
prospect.updatedFields, tags or assignment change.Prospect
prospect.deletedA prospect is deleted.Prospect
prospect.status_changedThe status changes.Prospect
note.createdA note is added.Note
appointment.createdAn appointment is scheduled.Appointment
appointment.deletedAn appointment is canceled or replaced.Appointment

Each delivery is a POST with a JSON envelope. data has the same shape the REST API returns.

Delivery
POST /leadscout/webhooks
Content-Type: application/json
X-LeadScout-Event-Id: evt_7d1c2f0a9b8e4c55a1f3e2d4c6b8a0e1
X-LeadScout-Timestamp: 1790000000
X-LeadScout-Signature: v1=5f2b7c3e9a1d...

{
  "id": "evt_7d1c2f0a9b8e4c55a1f3e2d4c6b8a0e1",
  "type": "prospect.status_changed",
  "occurredAt": "2026-09-28T14:03:11.482Z",
  "organizationId": "3f6c1a9e-2b7d-4c1e-9a55-8e0d4b2f7c61",
  "origin": "app",
  "credential": null,
  "data": { "id": "10234", "status": { "id": "12", "name": "Interested", "category": "engaged" }, ... }
}

Verify the signature

X-LeadScout-Signature is v1= plus the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your secret. Verify it on the raw bytes, reject timestamps older than 5 minutes, and dedupe on X-LeadScout-Event-Id. Each retry is signed again with a fresh timestamp.

Node.js
import crypto from 'node:crypto'
import express from 'express'

const app = express()
const SECRET = process.env.LEADSCOUT_WEBHOOK_SECRET

// express.raw keeps the exact bytes. Parsing first breaks the signature.
app.post('/leadscout/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-LeadScout-Timestamp')
  const signature = req.get('X-LeadScout-Signature') ?? ''
  const eventId = req.get('X-LeadScout-Event-Id')

  // 1. Reject stale deliveries (older than 5 minutes).
  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!timestamp || !(age <= 300)) return res.sendStatus(400)

  // 2. Recompute the signature over "<timestamp>.<raw body>".
  const expected = Buffer.from(
    'v1=' +
      crypto.createHmac('sha256', SECRET).update(`${timestamp}.`).update(req.body).digest('hex'),
  )
  // The header can hold several comma-separated signatures. Any match passes.
  const valid = signature.split(',').some((part) => {
    const candidate = Buffer.from(part.trim())
    return candidate.length === expected.length && crypto.timingSafeEqual(candidate, expected)
  })
  if (!valid) return res.sendStatus(401)

  // 3. Acknowledge fast, then process. Dedupe on the event id.
  res.sendStatus(200)
  const event = JSON.parse(req.body.toString('utf8'))
  enqueue(eventId, event)
})

Delivery

  • Best effort. An event is tried up to 3 times within a few seconds, then dropped.
  • The same event can arrive twice, so dedupe on its id.
  • Return any 2xx within 5 seconds. Do the work after you respond.
  • Timeouts, 5xx, 408 and 429 are retried, up to 3 attempts a few seconds apart. Other 4xx responses are not retried, and redirects are not followed.
  • After 20 events in a row fail, or on a 410 Gone, the webhook is disabled. Fix the endpoint, then PATCH it with { "active": true }.
  • Events fire for changes from the apps and from the API. Use origin and credential to skip echoes of your own writes.
  • A webhook belongs to the credential that created it, up to 10 per credential. Revoking the key or the OAuth authorization deletes its webhooks.

Webhooks speed up sync. Polling is the source of truth.

Some bulk actions in the app, like CSV import, don't send events. Run an updated_after sync on a schedule to catch everything.

Scopes

Ask for the fewest scopes you need. Every endpoint lists its scope in the reference.

ScopeGrants
prospects:readRead prospects, notes, appointments, status history, statuses and tags.
prospects:writeCreate, update and delete prospects, notes, appointments and status changes.
users:readRead the organization's team members.
webhooks:manageCreate and manage webhook subscriptions.

GET /accountworks with any scope. Write scopes don't include read, so a sync that writes back needs both prospects:read and prospects:write.

Ready to build?

Every endpoint, with requests you can run.

Open the API reference