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.
| Environment | Base URL |
|---|---|
| Production | https://app.leadscoutapp.com/api/v1 |
| Sandbox | https://app.dev.leadscoutapp.com/api/v1 |
- Requires an organization on an active Pro plan or higher.
- camelCase keys, string ids, RFC 3339 UTC timestamps.
nullmeans known empty. - One resource comes back as
{ "data": { … } }. Lists add ametaobject 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
- In LeadScout, open Settings → Integrations → LeadScout API and create an API key. Pick the scopes it needs. The key is shown once.
- Check the key:
curl https://app.leadscoutapp.com/api/v1/account \
-H "Authorization: Bearer $LEADSCOUT_API_KEY"{
"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)" }
}
}- Create your first prospect:
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 key | OAuth 2.0 | |
|---|---|---|
| Use it for | Your own server-to-server integration | An app that other organizations connect |
| Acts as | The key itself, shown as "<key name> (API)" | The person who approved the app |
| Token | lsk_… | lsa_… |
| Lifetime | Until revoked | 1 hour, refreshable |
| Created in | Settings → Integrations → LeadScout API | Same 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
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.
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.
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"{
"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
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
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
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
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.
{
"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:00Zworks,2026-09-01T00:00:00does not. - Deletes are soft. Add
include_deleted=trueand deleted rows come back withdeletedAtset. - 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.
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.
{
"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" }
]
}
}| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | The body is not valid JSON. |
| 400 | invalid_parameter | A query parameter is malformed. |
| 400 | invalid_cursor | The cursor is invalid. |
| 401 | unauthorized | No bearer token was sent. |
| 401 | invalid_token | The token is invalid, expired or revoked. |
| 403 | insufficient_scope | The token lacks the scope this endpoint needs. |
| 403 | plan_required | The organization is not on an active Pro plan. |
| 404 | not_found | No such resource in this organization. |
| 409 | conflict | The request conflicts with the current state. |
| 409 | limit_reached | An organization limit was hit. |
| 422 | validation_failed | The body is invalid, or an id in it is unknown. See details. |
| 422 | location_required | Create needs a location or an address. |
| 422 | field_not_mutable | That field cannot be changed here. |
| 429 | rate_limited | Too many requests. See Retry-After. |
| 500 | internal_error | Our fault. Retry reads; check state before retrying writes. |
| 502 | calendar_error | Google 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:
| Header | Value |
|---|---|
X-RateLimit-Limit | Requests allowed per minute. |
X-RateLimit-Remaining | Requests left in this window. |
X-RateLimit-Reset | Unix 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.
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"]
}'| Event | Sent when | data |
|---|---|---|
prospect.created | A prospect is created. | Prospect |
prospect.updated | Fields, tags or assignment change. | Prospect |
prospect.deleted | A prospect is deleted. | Prospect |
prospect.status_changed | The status changes. | Prospect |
note.created | A note is added. | Note |
appointment.created | An appointment is scheduled. | Appointment |
appointment.deleted | An appointment is canceled or replaced. | Appointment |
Each delivery is a POST with a JSON envelope. data has the same shape the REST API returns.
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.
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, thenPATCHit with{ "active": true }. - Events fire for changes from the apps and from the API. Use
originandcredentialto 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.
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.
| Scope | Grants |
|---|---|
prospects:read | Read prospects, notes, appointments, status history, statuses and tags. |
prospects:write | Create, update and delete prospects, notes, appointments and status changes. |
users:read | Read the organization's team members. |
webhooks:manage | Create 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.