LeadHound REST API · v1

Every lead, call and conversion — over HTTP

Read the leads and calls LeadHound captured, push leads in from your own systems, and pull the attribution and ROI numbers behind them. JSON in, JSON out, authenticated with an API key you can create in a few seconds.

Base URL

https://getleadhound.io/api/public/v1
Your first request
curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/me"

Introduction

The API is organised around the objects the product is: leads, the calls some of them came from, the websites and tracking numbers that produced them, and the reports that join them to ad spend. Every path lives under one versioned base URL, and every response is JSON.

Requests are scoped to your account automatically. There is no account id in any path — the key you authenticate with decides what you can see, and a record belonging to someone else is a 404, never a partial result.

Single objects are returned unwrapped. Collections come back under a key named for what they hold — leads, calls, profiles — alongside their pagination counters. There is no generic data envelope to unwrap.

Authentication

The API uses HTTP Basic authentication. The key’s token is the username and its secret is the password — every HTTP client has a first-class way to send that, so there is nothing to hand-roll.

Create a key under Settings → Integrations → API Key. The secret is shown once, at creation. There are two kinds:

  • ltk_…An account key, bound to one account. 10,000 requests a day.
  • ltm_…A master key, held by an agency, valid for every client account in its organization. 50,000 requests a day.

Keep keys server-side

HTTP Basic credentials cannot be used from a browser without exposing them. Call the API from your backend. If you need to capture leads from a page, use the tracking snippet or the public capture endpoint instead — neither needs a secret.

Account key
curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/me"
Master key, one client
curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  -H "X-Account-Id: 43" \
  "https://getleadhound.io/api/public/v1/leads"

Accounts & websites

Data sits at three levels: an organization (an agency) owns accounts, and each account owns one or more websites. A website is called a profile, and its id is what every profile_id filter in this API expects.

An account key is pinned to its own account and needs nothing extra. A master key must say which account each request is for, with the X-Account-Id header — without it the request is a 400. Call GET /accounts for the ids you may use.

Omitting profile_id means “every website on this account”. That is the intended account-wide view, not a missing filter.

Choosing a client account
curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  -H "X-Account-Id: 43" \
  "https://getleadhound.io/api/public/v1/leads"

Pagination

Collections are paged with page_number and a per-page size named for the collection — leads_per_page on leads, calls_per_page on calls. Both are 1-indexed.

Every paged response reports total_pages and the total record count, so you can size a job before you run it. Keep pages modest and let the counters drive the loop rather than asking for the maximum every time; the ceiling exists for backfills, not for polling.

Paging a moving collection

New leads arrive while you page, which shifts everything down by one. For a full export, pin the window with `start_date` and `end_date` and page inside it — the set is then fixed no matter how long the export takes.

{
  "page_number": 2,
  "leads_per_page": 50,
  "total_pages": 3,
  "total_leads": 118,
  "leads": [ /* … */ ]
}

Errors

Errors use conventional HTTP status codes and always return the same body: a human-readable message, an error object naming the machine-readable type, and — for a validation failure — an errors map keyed by field.

Branch on error.type rather than on the message text. Messages are written for whoever is reading the log and may be reworded; the types below are stable.

StatusTypeWhen you see it
400invalid_request_errorThe request could not be understood. A master key sent without an X-Account-Id header lands here.
401authentication_errorNo credentials, or a token/secret pair that does not match a key.
403permission_errorAuthenticated, but this key may not perform that action.
404invalid_request_errorNo such record on this account. Another account’s record is a 404, never a 403 — the API does not confirm that it exists.
422validation_errorThe payload or a filter failed validation. `errors` names each field.
429rate_limit_errorThe key’s daily quota is spent. `Retry-After` says how many seconds until it resets.
5xxapi_errorSomething failed on our side. Retry with backoff, and quote the X-Request-Id if it persists.
{
  "message": "The request payload failed validation.",
  "error": {
    "type": "validation_error",
    "status": 422
  },
  "errors": {
    "email": ["Provide at least an email or a phone number."]
  }
}

Rate limits

Each key has a daily request quota — 10,000 for an account key, 50,000 for a master key — which resets at midnight UTC. Every response, successful or not, reports where you stand.

  • X-RateLimit-Limit — the key’s daily ceiling.
  • X-RateLimit-Remaining — requests left today.
  • X-RateLimit-Reset — Unix timestamp of the reset.
  • X-Request-Id — this request’s id. Send your own and we will echo it back, so your logs and ours line up.

Once the quota is spent every request is a 429 carrying Retry-After in seconds. Watch X-RateLimit-Remaining and slow down before you get there rather than after.

HTTP/1.1 200 OK
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9871
X-RateLimit-Reset: 1788393600
X-Request-Id: 6f1c9b2e-8f4a-4f6e-9d17-2b0a5c8e77aa

Idempotency

Creating a lead is idempotent on external_id — your own id for the record. Send it and a retried request returns the lead that already exists with a 200 instead of creating a second one.

This is what makes a webhook or a queued job safe to retry. Without an external_id there is nothing to match on, and a repeated POST /leads will create a duplicate.

Backfilling history

When importing old leads, send `occurred_at` with the original timestamp and `sync_conversions: false`. Otherwise a year of history is uploaded to Google Ads and Meta as conversions that all happened today, and their bidding models will believe it.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "email": "jane@example.com", "external_id": "crm-8841" }' \
  "https://getleadhound.io/api/public/v1/leads"

# 201 the first time, 200 and the same lead every time after.

Account

Who these credentials are, which account they act on, and how much of today’s quota is left. Start here when wiring up a new integration.

Verify credentials

GET/api/public/v1/me

Returns the account the credentials resolve to, the key doing the resolving, the remaining daily quota, and every website on the account.

This is the cheapest way to confirm a key works, and the only way to see which account a master key is currently pointed at. The secret half of the pair is never returned.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/me"
Response200
{
  "api_version": "v1",
  "account": {
    "id": 42,
    "name": "Acme Plumbing",
    "slug": "acme-plumbing",
    "plan": "pro",
    "organization_id": 7
  },
  "api_key": {
    "name": "Zapier",
    "token": "ltk_9f2c…",
    "scope": "account"
  },
  "rate_limit": {
    "limit": 10000,
    "remaining": 9871,
    "resets_at": "2026-09-03T00:00:00+00:00"
  },
  "profiles": [
    {
      "id": 11,
      "name": "acme-plumbing.com",
      "domain": "acme-plumbing.com",
      "tracking_id": "a7Kd92Lm",
      "is_active": true
    }
  ]
}

List accounts

GET/api/public/v1/accounts

Every account these credentials can act on, and which one the current request is bound to.

An account key returns exactly one account — its own. A master key returns every account in its organization, and those ids are what you pass in X-Account-Id to switch between them.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/accounts"
Response200
{
  "scope": "master",
  "active_account_id": 42,
  "accounts": [
    {
      "id": 42,
      "name": "Acme Plumbing",
      "slug": "acme-plumbing",
      "plan": "pro",
      "profiles_count": 2,
      "active": true
    },
    {
      "id": 43,
      "name": "Zenith Roofing",
      "slug": "zenith-roofing",
      "plan": "starter",
      "profiles_count": 1,
      "active": false
    }
  ]
}

Websites

A website (a “profile”) is the unit almost everything else is filtered by. Its tracking id is the public identifier baked into the snippet.

List websites

GET/api/public/v1/profiles

Every website on the account, alphabetically.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/profiles"
Response200
{
  "total_profiles": 2,
  "profiles": [
    {
      "id": 11,
      "tenant_id": 42,
      "name": "acme-plumbing.com",
      "domain": "acme-plumbing.com",
      "timezone": "America/Chicago",
      "tracking_id": "a7Kd92Lm",
      "settings": null,
      "is_active": true,
      "created_at": "2026-02-11T09:04:22+00:00",
      "updated_at": "2026-08-30T18:15:03+00:00"
    }
  ]
}

Get a website

GET/api/public/v1/profiles/{id}

One website by id.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/profiles/11"
Response200
{
  "id": 11,
  "tenant_id": 42,
  "name": "acme-plumbing.com",
  "domain": "acme-plumbing.com",
  "timezone": "America/Chicago",
  "tracking_id": "a7Kd92Lm",
  "settings": null,
  "is_active": true,
  "created_at": "2026-02-11T09:04:22+00:00",
  "updated_at": "2026-08-30T18:15:03+00:00"
}

Leads

Everything a visitor turned into: a form submission, a tracked call, or a lead your own systems pushed in. Reading, creating, correcting and deleting them.

List leads

GET/api/public/v1/leads

A paginated, filterable list of leads, newest first.

Query parameters

leads_per_pageintegerdefault 25

Results per page, 1–2500.

page_numberintegerdefault 1

Which page to return.

order_bystringdefault created_at

Which column to sort on.

created_atupdated_atvalue
orderstringdefault desc

Sort direction.

ascdesc
start_datestring

Only include records created on or after this moment. ISO-8601 date or datetime, interpreted in UTC.

end_datestring

Only include records created on or before this moment. Must not precede start_date.

lead_typestring

How the lead arrived.

formphoneapi
lead_statusstring

Where the lead sits in your pipeline.

newqualifiedconvertedspam
profile_idinteger

Restrict to a single website. Omit for every website on the account. An id belonging to another account is rejected, not silently ignored.

sourcestring

Exact match on the captured source, e.g. google.

campaignstring

Exact match on the captured campaign name.

external_idstring

Find the lead you created under your own id. The fast way to reconcile a sync.

searchstring

Partial match across name, email and phone.

includestring

Comma-separated relations to embed in each lead. Omitted by default because they cost a query each.

attributioncall
curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/leads?leads_per_page=50&lead_status=converted"
Response200
{
  "page_number": 1,
  "leads_per_page": 50,
  "total_pages": 3,
  "total_leads": 118,
  "leads": [
    {
      "id": 1042,
      "tenant_id": 42,
      "profile_id": 11,
      "type": "phone",
      "status": "converted",
      "is_repeat": false,
      "name": "Jane Doe",
      "email": "jane@example.com",
      "phone": "+15125550147",
      "value": "1250.00",
      "currency": "USD",
      "source": "google",
      "medium": "cpc",
      "campaign": "emergency-plumbing",
      "channel_label": "Google Ads",
      "keyword": "emergency plumber austin",
      "gclid": "Cj0KCQjw…",
      "ad_group": "987654321",
      "ad_id": "555000111",
      "external_id": "crm-8841",
      "lead_data": { "message": "Burst pipe under the sink" },
      "metadata": { "session_id": "s_8f21c9" },
      "converted_at": "2026-08-31T16:20:11+00:00",
      "created_at": "2026-08-30T14:02:55+00:00",
      "updated_at": "2026-08-31T16:20:11+00:00",
      "profile": { "id": 11, "name": "acme-plumbing.com", "domain": "acme-plumbing.com" }
    }
  ]
}

Get a lead

GET/api/public/v1/leads/{id}

One lead, always with its resolved attribution and — for a phone lead — the call it came from, including the transcript and any AI analysis.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/leads/1042"
Response200
{
  "id": 1042,
  "type": "phone",
  "status": "converted",
  "name": "Jane Doe",
  "value": "1250.00",
  "channel_label": "Google Ads",
  "attribution": {
    "source": "google",
    "medium": "cpc",
    "campaign": "emergency-plumbing",
    "keyword": "emergency plumber austin",
    "adgroup": "Emergency — Exact",
    "ad_id": "555000111"
  },
  "call_log": {
    "id": 806,
    "status": "completed",
    "duration_seconds": 214,
    "tracking_number": "+15125550100",
    "caller_number": "+15125550147",
    "recording_url": "https://recordings.leadhound.io/806.mp3",
    "transcription": "Hi, I have water coming through the ceiling…",
    "transcription_redacted": true,
    "speaker_source": "diarized",
    "analysis": { "summary": "Emergency call-out booked", "sentiment": "positive" }
  }
}

Create a lead

POST/api/public/v1/leads

Push a lead in from your own site, CRM or call centre. At least an email or a phone number is required.

Send external_id and the call becomes idempotent: a repeat of the same id returns the existing lead with 200 instead of creating a second one, so a retried webhook cannot duplicate a lead.

Send gclid, fbclid or a session_id from the snippet and the lead attributes exactly like one we captured ourselves — including the campaign, ad group and keyword we look up from the Google Ads API.

Importing history? Set occurred_at to the lead’s original timestamp and sync_conversions to false, or a year of old leads is restated to the ad platforms as conversions that happened today.

Body parameters

namestring

The person’s name.

emailstringrequired

Email address. Required unless phone is given.

phonestringrequired

Phone number. Required unless email is given.

valuenumber

What the lead is worth. Drives ROAS reporting.

currencystringdefault USD

Three-letter currency code.

lead_typestringdefault api

How it arrived.

formphoneapi
profile_idinteger

Which website the lead belongs to.

external_idstring

Your own id for this lead. Makes the call idempotent.

sourcestring

Traffic source, e.g. google.

mediumstring

Traffic medium, e.g. cpc.

campaignstring

Campaign name.

keywordstring

Search term.

ad_groupstring

Google Ads ad group id.

ad_idstring

Google Ads creative id.

gclidstring

Google click id captured on the landing page.

fbclidstring

Meta click id captured on the landing page.

session_idstring

The snippet’s session id, linking this lead to the visit that produced it.

fieldsobject

Any extra key/value data to keep with the lead.

occurred_atstring

The lead’s original timestamp, for backfills. Never in the future.

sync_conversionsbooleandefault true

Set false to skip the ad-platform upload and the new-lead alert.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{ "name": "Jane Doe", "email": "jane@example.com", "phone": "+15125550147", "value": 1250, "external_id": "crm-8841", "gclid": "Cj0KCQjw...", "profile_id": 11 }' \
  "https://getleadhound.io/api/public/v1/leads"
Response201
{
  "id": 1042,
  "type": "api",
  "status": "new",
  "name": "Jane Doe",
  "email": "jane@example.com",
  "phone": "+15125550147",
  "value": "1250.00",
  "currency": "USD",
  "external_id": "crm-8841",
  "gclid": "Cj0KCQjw…",
  "created_at": "2026-09-02T11:41:07+00:00"
}

Update a lead

POST/api/public/v1/leads/{id}

Correct a lead or move it through your pipeline. Only the fields you send are changed.

Setting status to converted stamps the conversion time and restates the conversion at the ad platforms; changing value restates the amount. This is how a deal closed three weeks after the click gets credited to the campaign that produced it.

Body parameters

statusstring

Pipeline status.

newqualifiedconvertedspam
valuenumber

Revised lead value.

currencystring

Three-letter currency code.

namestring

Corrected name.

emailstring

Corrected email.

phonestring

Corrected phone number.

sourcestring

Corrected source.

mediumstring

Corrected medium.

campaignstring

Corrected campaign.

keywordstring

Corrected keyword.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{ "status": "converted", "value": 1250 }' \
  "https://getleadhound.io/api/public/v1/leads/1042"
Response200
{
  "id": 1042,
  "status": "converted",
  "value": "1250.00",
  "converted_at": "2026-09-02T11:44:19+00:00",
  "updated_at": "2026-09-02T11:44:19+00:00"
}

Delete a lead

DELETE/api/public/v1/leads/{id}

Remove a lead permanently.

Deleting does not retract a conversion already uploaded to Google Ads or Meta. If you are removing junk, set the status to spam instead — that is what keeps the platforms’ bidding honest.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  -X DELETE \
  "https://getleadhound.io/api/public/v1/leads/1042"
Response200
{
  "deleted": true,
  "id": 1042
}

Get a lead’s journey

GET/api/public/v1/leads/{id}/journey

The visit behind the lead: first touch, the pages viewed, the device and location, and every conversion we uploaded for it.

The conversions array is the audit trail for the ad platforms — what we sent, when, and the error if a send failed. It is the first thing to check when a conversion has not appeared in Google Ads.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/leads/1042/journey"
Response200
{
  "session": {
    "first_touch_at": "2026-08-29T19:44:02+00:00",
    "landing_page": "https://acme-plumbing.com/emergency",
    "referrer": "https://www.google.com/",
    "last_landing_page": "https://acme-plumbing.com/contact",
    "gclid": "Cj0KCQjw…"
  },
  "visitor": {
    "ip": "203.0.113.24",
    "geo": { "city": "Austin", "region": "TX", "country": "US" },
    "browser": "Chrome",
    "os": "iOS",
    "device": "mobile",
    "device_make": "Apple"
  },
  "page_views": [
    { "url": "https://acme-plumbing.com/emergency", "at": "2026-08-29T19:44:02Z" },
    { "url": "https://acme-plumbing.com/contact", "at": "2026-08-30T14:01:30Z" }
  ],
  "conversions": [
    {
      "platform": "google_ads",
      "event_type": "lead",
      "kind": "click",
      "status": "sent",
      "value": "1250.00",
      "currency": "USD",
      "error": null,
      "sent_at": "2026-08-31T16:20:44Z"
    }
  ]
}

Calls

Tracked calls in their own right. A missed call or a voicemail never becomes a lead — and those are usually the ones worth chasing.

List calls

GET/api/public/v1/calls

A paginated, filterable list of calls, newest first.

Query parameters

calls_per_pageintegerdefault 25

Results per page, 1–1000.

page_numberintegerdefault 1

Which page to return.

orderstringdefault desc

Sort direction on call time.

ascdesc
start_datestring

Only include records created on or after this moment. ISO-8601 date or datetime, interpreted in UTC.

end_datestring

Only include records created on or before this moment. Must not precede start_date.

statusstring

Call outcome.

ringingansweredmissedvoicemailcompleted
profile_idinteger

Restrict to a single website. Omit for every website on the account. An id belonging to another account is rejected, not silently ignored.

tracking_numberstring

The number the call came in on, in E.164.

caller_numberstring

The number that called, in E.164.

min_durationinteger

Seconds. Filters out hang-ups and wrong numbers.

has_recordingboolean

Only calls that have a recording.

answered_onlyboolean

Only calls a person picked up. A voicemail is a missed call with a message, so it is excluded.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/calls?answered_only=1&min_duration=60"
Response200
{
  "page_number": 1,
  "calls_per_page": 25,
  "total_pages": 2,
  "total_calls": 34,
  "calls": [
    {
      "id": 806,
      "profile_id": 11,
      "lead_id": 1042,
      "status": "completed",
      "tracking_number": "+15125550100",
      "caller_number": "+15125550147",
      "destination_number": "•••••••1400",
      "duration_seconds": 214,
      "started_at": "2026-08-30T14:02:11+00:00",
      "recording_url": "https://recordings.leadhound.io/806.mp3",
      "transcription": "Hi, I have water coming through the ceiling…",
      "transcription_redacted": true,
      "speaker_source": "diarized",
      "speakers_observed": true,
      "analysis": { "summary": "Emergency call-out booked", "sentiment": "positive" },
      "created_at": "2026-08-30T14:02:11+00:00",
      "lead": { "id": 1042, "name": "Jane Doe", "status": "converted", "value": "1250.00" }
    }
  ]
}

Get a call

GET/api/public/v1/calls/{id}

One call, with its recording, transcript, speaker turns and AI analysis where the number pool has them switched on.

speaker_source says how much to trust the speaker labels: diarized and corrected are evidence, labelled and inferred are a best guess. speakers_observed is the same distinction as a boolean.

When PCI redaction is on for the pool, transcription is the redacted text and transcription_redacted is true — a card number read aloud never leaves the platform.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/calls/806"
Response200
{
  "id": 806,
  "profile_id": 11,
  "lead_id": 1042,
  "status": "completed",
  "tracking_number": "+15125550100",
  "caller_number": "+15125550147",
  "destination_number": "•••••••1400",
  "duration_seconds": 214,
  "started_at": "2026-08-30T14:02:11+00:00",
  "recording_url": "https://recordings.leadhound.io/806.mp3",
  "transcription": "Hi, I have water coming through the ceiling…",
  "transcription_redacted": true,
  "transcript_segments": [
    { "speaker": "caller", "text": "Hi, I have water coming through the ceiling.", "start": 1.2 },
    { "speaker": "agent", "text": "I can get someone to you within the hour.", "start": 6.8 }
  ],
  "speaker_source": "diarized",
  "speakers_observed": true,
  "analysis": {
    "summary": "Emergency call-out booked",
    "sentiment": "positive",
    "outcome": "booked"
  },
  "created_at": "2026-08-30T14:02:11+00:00"
}

Tracking numbers

The numbers the snippet swaps onto your pages, and the pool each one serves. Use these to resolve a call’s `tracking_number` to a website and a traffic source.

List tracking numbers

GET/api/public/v1/phone-numbers

Every tracking number on the account.

Forwarding destinations are masked to their last four digits. That is the customer’s real line, not tracking data, so the API never returns it in full.

Query parameters

profile_idinteger

Restrict to a single website. Omit for every website on the account. An id belonging to another account is rejected, not silently ignored.

active_onlyboolean

Exclude numbers that have been retired.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/phone-numbers"
Response200
{
  "total_phone_numbers": 1,
  "phone_numbers": [
    {
      "id": 60,
      "name": "Emergency landing page",
      "tracking_number": "+15125550100",
      "formatted": "+1 (512) 555-0100",
      "destination_number": "•••••••1400",
      "is_active": true,
      "provider": "plivo",
      "number_pool_id": 4,
      "created_at": "2026-03-02T10:00:00+00:00",
      "profile": { "id": 11, "name": "acme-plumbing.com" },
      "pool": { "id": 4, "name": "Paid search", "traffic_source": "search" }
    }
  ]
}

Reports

Pre-aggregated numbers, so you are not paging the whole leads collection to do the sums yourself.

Lead summary

GET/api/public/v1/reports/leads

Headline counts for a date window, plus one breakdown — by day, source, campaign, keyword, type, status or website.

Query parameters

start_datestringdefault 29 days ago

First day of the window, YYYY-MM-DD.

end_datestringdefault today

Last day of the window, YYYY-MM-DD.

group_bystringdefault day

The dimension the breakdown is cut by.

daysourcemediumcampaignkeywordtypestatusprofile
profile_idinteger

Restrict to a single website. Omit for every website on the account. An id belonging to another account is rejected, not silently ignored.

lead_typestring

Restrict the whole report to one kind of lead.

formphoneapi
curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/reports/leads?group_by=source&start_date=2026-08-01&end_date=2026-08-31"
Response200
{
  "start_date": "2026-08-01",
  "end_date": "2026-08-31",
  "group_by": "source",
  "totals": {
    "total": 118,
    "by_type": { "form": 61, "phone": 49, "api": 8 },
    "by_status": { "new": 44, "qualified": 39, "converted": 28, "spam": 7 },
    "conversion_rate": 23.7,
    "total_value": 41250
  },
  "breakdown": [
    { "key": "google", "leads": 74, "converted": 21, "value": 32100, "conversion_rate": 28.4 },
    { "key": "facebook", "leads": 29, "converted": 5, "value": 6400, "conversion_rate": 17.2 },
    { "key": "direct", "leads": 15, "converted": 2, "value": 2750, "conversion_rate": 13.3 }
  ]
}

Cost per lead and ROAS

GET/api/public/v1/reports/roi

Imported ad spend joined to captured leads, per source and per campaign.

Only platforms whose spend has been imported appear here — connect Google Ads or Meta first, or the cost columns read zero.

Free traffic is excluded from a platform’s figures: a site’s organic Google visits share the google source with its Ads clicks, and counting them would flatter cost-per-lead and inflate ROAS.

Query parameters

start_datestringdefault 29 days ago

First day of the window, YYYY-MM-DD.

end_datestringdefault today

Last day of the window, YYYY-MM-DD.

profile_idinteger

Restrict to a single website. Omit for every website on the account. An id belonging to another account is rejected, not silently ignored.

curl -u "$LEADHOUND_TOKEN:$LEADHOUND_SECRET" \
  "https://getleadhound.io/api/public/v1/reports/roi?start_date=2026-08-01&end_date=2026-08-31"
Response200
{
  "from": "2026-08-01",
  "to": "2026-08-31",
  "totals": {
    "cost": 4820,
    "leads": 103,
    "revenue": 38500,
    "cost_per_lead": 46.8,
    "roas": 7.99
  },
  "by_source": [
    {
      "source": "google",
      "clicks": 1904,
      "cost": 3600,
      "leads": 74,
      "revenue": 32100,
      "cost_per_lead": 48.65,
      "roas": 8.92
    }
  ],
  "campaigns": [
    {
      "source": "google",
      "campaign": "emergency-plumbing",
      "clicks": 902,
      "cost": 2100,
      "leads": 48,
      "revenue": 24800,
      "cost_per_lead": 43.75,
      "roas": 11.81
    }
  ]
}

Something missing, or an endpoint behaving differently from this page? Tell us — quote the X-Request-Id from the response and we can find the exact call.