The ListMatchGenie REST API lets you run every stage of the pipeline from your own code: upload files, kick off match jobs, read the results and the computed report, and pull formatted PDF / XLSX / PPTX exports. Everything the app does, the API can do.
API access is a Business-plan feature. Generate a key under Account → API keys inside the app.
Concepts you need before your first call
Spend two minutes here. The rest of this page assumes you understand these five ideas.
1. Source file vs. master file
Every match job compares two files:
- Source file — the list you're checking. New leads, a newsletter signup export, today's form submissions, a prospecting list. This is usually the smaller, fresher file.
- Master file — the list you're checking against. Your CRM, your customer database, your existing contact list. The system of record.
Concrete example. You export 5,000 new leads from a landing page. You want to know which of them are already in your 500,000-row Salesforce export so you don't re-email existing customers.
- Source =
new_leads_may.csv(5,000 rows) - Master =
salesforce_export.csv(500,000 rows)
The match produces, for each source row, either a pointer to a master row ("this lead is already your customer") or nothing ("genuinely new lead"). Your output CSV has every original source column plus the matched master columns and the match metadata — no VLOOKUPs required.
purpose in the API is either "source" or "master" and it's
sticky per file: you decide when you upload. A single file can be
used as either role across different jobs, but most users keep one
stable master file and upload a new source file each run.
2. The lifecycle of a file
POST /files → upload your bytes to S3 → file.status = "uploaded"
↓ (worker auto-prepares)
GET /files/{id} → parses headers, profiles → file.status = "preparing"
columns, writes cleanse report
↓
→ ready to use in a match → file.status = "ready"
Files are ready when status === "ready". Don't start a match against
a file still in preparing — the job will wait, but you burn time.
Preparation takes seconds for small files, up to a minute for files
in the millions of rows.
3. The lifecycle of a match job
POST /jobs → job.status = "pending" (queued)
↓
→ job.status = "processing" (worker picked it up)
↓
→ job.status = "completed" (result CSV available)
or "failed" (errorMessage populated)
Polling cadence: every 2-5 seconds is plenty. Most jobs of under 100K source rows finish in under a minute.
4. Reports vs. raw results
Every completed match produces two output surfaces:
- Raw result CSV — every source row + matched master columns +
_lmg_match_status,_lmg_match_score, etc. Fetched viajob.downloadUrlonGET /jobs/{jobId}. - Report — computed rollups (match rate, score distribution,
data-quality stats, pivots by column) plus AI-written narrative
sections. Fetched via
GET /reports/{reportId}. One report per job.
If you just want the matched rows to import into your CRM: use the CSV. If you want dashboard-grade analytics or an exec-ready PDF: use the report.
5. Match profiles
A profile tells the matcher how to compare rows: which columns
map to which (e.g. "their first_name column is our fname"),
which fields are primary keys vs. supporting evidence, whether to
treat this as people-matching or product-matching. Profiles are saved
configurations — create them once in the app wizard, reference them
by ID in API calls.
Today, profile creation is UI-only (the wizard walks through column mapping with previews). The API reads profile IDs but doesn't create them. If you need programmatic profile creation, email us.
Base URL
https://app.listmatchgenie.com/api/v1
All endpoints are under that prefix. Replace /api/v1 with nothing
and you get the app's internal routes, which are session-auth only
and not supported for integrations.
Authentication
Every request needs an Authorization header:
Authorization: Bearer lmg_live_YOUR_KEY_HERE
Keys are created from Account → API keys. The raw key is shown exactly once at creation — copy it into your secrets manager immediately. Lose it and you'll need to regenerate (which revokes the old key atomically).
Keys start with lmg_live_ followed by 32 url-safe base64 characters.
If you see lmg_test_ anywhere, that's a mistake — we don't run
separate test keys today. All keys hit the same production data.
Managing keys
| Action | UI path | Effect |
|---|---|---|
| Create | Account → API keys → New key | Issues a new key with a label. Shown once. |
| Regenerate | Account → API keys → Regenerate | Atomic rotate. Old key dies immediately, new key shown once, same label & scope. |
| Revoke | Account → API keys → Revoke | Soft delete. Requests with the key get 401 Invalid or revoked API key. |
| Usage | Account → API keys → Chart icon | 30-day request + error counts per key. |
Rate limits
20 requests per 10 seconds per key.
Every response — success or error — includes three headers so your client can pace itself without sampling 429s:
X-RateLimit-Limit: 20
X-RateLimit-Remaining: 17
X-RateLimit-Reset: 1714237890
X-RateLimit-Limit— the cap for the window.X-RateLimit-Remaining— calls still available. Drops to 0 when you've hit the cap.X-RateLimit-Reset— Unix timestamp (seconds) when the window resets andRemainingclimbs back toLimit.
On a 429 Too Many Requests you also get Retry-After as an integer
number of seconds. Respect it — keep retrying immediately and you'll
just keep hitting 429.
Need higher limits (e.g. bulk import)? Email support and we'll raise on a per-key basis.
OpenAPI spec
Machine-readable spec at:
https://app.listmatchgenie.com/api/v1/openapi.json
Point Postman, Insomnia, Stoplight, or any SDK generator at that URL.
Quickstart: your first API call
Verify your key works and see which tier you're on:
curl https://app.listmatchgenie.com/api/v1/whoami \
-H "Authorization: Bearer $LMG_API_KEY"Expected response:
{
"user": {
"id": "0ff8a06d-9e87-4abc-bd22-0e6a71084fa1",
"email": "you@example.com",
"name": "You"
},
"tier": "business"
}If you get 401 Missing Authorization header — you forgot the
-H. If you get 401 Invalid API key format — your key is
malformed (probably copy-paste munged the lmg_live_ prefix). If you
get 401 Invalid or revoked API key — the key was revoked or never
existed.
Full end-to-end walkthrough
This is the complete path: upload a source file, upload a master
file, pick a profile, run a match, pull the result, fetch the
report, export a PDF. Every step is a real curl call you can copy.
export API="https://app.listmatchgenie.com/api/v1"
export AUTH="Authorization: Bearer $LMG_API_KEY"Step 1 — Upload the source file
Two-step upload: request a presigned S3 URL, then PUT your bytes directly to S3. Bytes never pass through our servers.
# Request the presigned URL
curl -X POST "$API/files" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{
"fileName": "new_leads_may.csv",
"fileSize": 524288,
"contentType": "text/csv",
"purpose": "source"
}'Response:
{
"fileId": "b1c9f3a0-...",
"uploadUrl": "https://s3.us-east-1.amazonaws.com/...presigned...",
"s3Key": "users/.../raw.csv"
}Now PUT the actual bytes:
curl -X PUT "$UPLOAD_URL_FROM_ABOVE" \
-H "Content-Type: text/csv" \
--data-binary @new_leads_may.csvAfter the PUT completes, the file is automatically queued for
preparation (header parsing, column profiling, cleanse report).
Wait for status === "ready":
curl "$API/files/b1c9f3a0-..." -H "$AUTH"
# → { "file": { "status": "preparing", ... } }
# ...a few seconds later...
# → { "file": { "status": "ready", "rowCount": 5000, ... } }Step 2 — Upload the master file
Same two-step flow, with "purpose": "master". In practice you
upload your master file once and reuse it across many source
files — there's no need to re-upload your CRM every run.
curl -X POST "$API/files" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{
"fileName": "salesforce_master.csv",
"fileSize": 104857600,
"purpose": "master"
}'
# → presigned URL, PUT the bytes, poll /files/{id} for "ready"Step 3 — Pick a match profile
Profiles are created through the app wizard (it walks you through column mapping with live previews). Once saved, list them via API:
curl "$API/profiles" -H "$AUTH"Response:
{
"profiles": [
{
"id": "a27b8f01-...",
"name": "leads → crm",
"profileType": "person",
"isTemplate": false,
"createdAt": "2026-04-20T12:00:00Z"
}
]
}Grab the id of the profile that fits your use case.
Step 4 — Create the match job
curl -X POST "$API/jobs" \
-H "$AUTH" -H "Content-Type: application/json" \
-d '{
"sourceFileId": "b1c9f3a0-...",
"masterFileId": "f4d29b5c-...",
"profileId": "a27b8f01-...",
"matchMode": "best_match"
}'Response (201 Created):
{
"job": {
"id": "c93a1f00-...",
"status": "pending",
"matchMode": "best_match",
"createdAt": "2026-04-22T14:32:00Z"
}
}matchMode:
best_match(default) — each source row pairs with at most one master row (the highest-scoring candidate above threshold).all_candidates— each source row can match multiple master rows; every candidate above threshold is returned.
Use best_match for "dedupe my leads against my CRM" (you want one
verdict per lead). Use all_candidates when investigating potential
duplicate customers and you want to see every near-miss.
Step 5 — Poll until complete
curl "$API/jobs/c93a1f00-..." -H "$AUTH"Early response:
{
"job": {
"id": "c93a1f00-...",
"status": "processing",
"progress": 42,
"matchMode": "best_match",
"resultSummary": null,
"downloadUrl": null,
"createdAt": "2026-04-22T14:32:00Z",
"startedAt": "2026-04-22T14:32:03Z",
"completedAt": null
}
}Final response:
{
"job": {
"id": "c93a1f00-...",
"status": "completed",
"progress": 100,
"matchMode": "best_match",
"resultSummary": {
"total_source_rows": 5000,
"matched": 3812,
"review": 244,
"unmatched": 944
},
"downloadUrl": "https://s3...presigned...",
"createdAt": "2026-04-22T14:32:00Z",
"startedAt": "2026-04-22T14:32:03Z",
"completedAt": "2026-04-22T14:33:41Z"
}
}Match verdicts (each source row gets exactly one):
- matched — high confidence (≥ profile threshold, typically 85%).
- review — mid confidence (20 pts below threshold up to threshold). Human should eyeball these before trusting.
- unmatched — no candidate above the review floor.
Step 6 — Download the result CSV
The downloadUrl is a presigned S3 URL. It expires in about an
hour; if it does, just call GET /jobs/{id} again for a fresh one.
# $RESULT_URL = the downloadUrl from above
curl "$RESULT_URL" -o results.csvThe CSV has three sections of columns, in order:
- All original source columns, unchanged.
- All master columns from the matched master row (empty when
_lmg_match_status == "unmatched"). - LMG metadata prefixed
_lmg_:_lmg_row_id— stable source row identifier_lmg_match_status— matched / review / unmatched_lmg_match_score— 0-100 confidence score_lmg_match_reason— human-readable reason (e.g. "email + zip")_lmg_master_row_id— id of the matched master row, if any
You should never need to VLOOKUP your own data — the matched row is already stitched in.
Step 7 — Fetch the report
The match job produces a report in parallel: computed rollups,
AI narrative, pivots. Find it by jobId:
curl "$API/reports?jobId=c93a1f00-..." -H "$AUTH"Response:
{
"reports": [
{
"id": "d04b2e91-...",
"jobId": "c93a1f00-...",
"status": "completed",
"progress": 100,
"createdAt": "2026-04-22T14:33:44Z",
"completedAt": "2026-04-22T14:34:02Z"
}
],
"pagination": { "limit": 50, "offset": 0, "returned": 1 }
}Now pull the full detail:
curl "$API/reports/d04b2e91-..." -H "$AUTH"Trimmed response:
{
"report": {
"id": "d04b2e91-...",
"status": "completed",
"rollups": {
"totals": {
"sourceRows": 5000,
"matched": 3812,
"review": 244,
"unmatched": 944,
"matchRatePct": 76.24,
"avgScore": 87.1,
"medianScore": 91.0
},
"scoreDistribution": [
{ "bucket": "90-100", "count": 3120 },
{ "bucket": "80-89", "count": 692 },
{ "bucket": "70-79", "count": 244 }
],
"passBreakdown": [
{ "pass": "email_exact", "count": 2410, "pct": 48.2 },
{ "pass": "name_zip", "count": 980, "pct": 19.6 },
{ "pass": "phone_normalized", "count": 422, "pct": 8.4 }
],
"dataQuality": {
"totalSource": 5000,
"missingEmail": 140,
"missingPhone": 823,
"invalidEmail": 17,
"duplicateRows": 62
}
},
"narrative": {
"executive": "76% of your source file matched your CRM...",
"dataQuality": "140 rows were missing email...",
"keyFindings": "The majority of matches came through email..."
},
"exports": {
"pdfReady": false,
"pptxReady": false,
"xlsxReady": false
}
},
"job": {
"sourceFileName": "new_leads_may.csv",
"masterFileName": "salesforce_master.csv",
"resultSummary": { "matched": 3812, "review": 244, "unmatched": 944 }
}
}Everything is JSON-native — pipe it into a dashboard, Slack digest, weekly report, or your own analytics warehouse.
Step 8 — Export a PDF / XLSX / PPTX
The report object tells you which formats are already generated
(exports.pdfReady, etc.). First request triggers generation:
curl "$API/reports/d04b2e91-.../export?format=pdf" -H "$AUTH"First response (not yet generated):
{
"status": "generating",
"message": "Export is being prepared. Poll this endpoint every 10-30 seconds until status='ready'."
}Poll until:
{
"status": "ready",
"downloadUrl": "https://s3...presigned...pdf"
}Then curl that URL to the file of your choice. Generation takes
10-30 seconds typically.
Tier gates on exports:
| Format | Required tier |
|---|---|
| xlsx | Starter+ |
| Pro+ | |
| pptx | Business only |
A format above your tier returns 403 with requiredTier in the
body so you can surface the upgrade prompt.
Endpoint reference
Every endpoint requires Authorization: Bearer <key>. Every response
carries X-RateLimit-* headers.
GET /whoami
Returns the authenticated user and their subscription tier. Use this as a sanity check during integration work.
Request:
curl https://app.listmatchgenie.com/api/v1/whoami \
-H "Authorization: Bearer $LMG_API_KEY"Response 200:
{
"user": { "id": "...", "email": "you@example.com", "name": "You" },
"tier": "business"
}GET /files
List the authenticated user's non-deleted files.
Query parameters:
| Param | Type | Notes |
|---|---|---|
purpose | string | source or master (optional) |
limit | int | 1-200, default 50 |
offset | int | default 0 |
Request:
curl "$API/files?purpose=master&limit=10" -H "$AUTH"Response 200:
{
"files": [
{
"id": "f4d29b5c-...",
"name": "salesforce_master.csv",
"purpose": "master",
"rowCount": 500000,
"columnCount": 22,
"fileSizeBytes": 104857600,
"status": "ready",
"createdAt": "2026-04-15T09:00:00Z",
"lastUsedAt": "2026-04-22T14:33:41Z"
}
],
"pagination": { "limit": 10, "offset": 0, "returned": 1 }
}POST /files
Two-step upload. First you POST metadata; the response gives you a presigned S3 URL. Then you PUT your raw bytes to that URL. Bytes never pass through our servers.
Request body:
{
"fileName": "leads.csv",
"fileSize": 1048576,
"contentType": "text/csv",
"purpose": "source"
}Response 200:
{
"fileId": "b1c9f3a0-...",
"uploadUrl": "https://s3...presigned...",
"s3Key": "users/.../raw.csv"
}Then upload bytes (client-side, direct to S3):
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: text/csv" \
--data-binary @leads.csvAfter the PUT, the file auto-enters preparation. Poll
GET /files/{fileId} and wait for status: "ready" before matching.
413 File too large — your plan caps per-file size. See
docs/data/file-size-and-row-limits for current caps by tier.
GET /files/{fileId}
Single-file metadata including detected columns and cleanse report.
Response 200:
{
"file": {
"id": "b1c9f3a0-...",
"name": "leads.csv",
"purpose": "source",
"status": "ready",
"rowCount": 5000,
"columnCount": 14,
"columns": [
{ "name": "email", "type": "email", "nullCount": 140 },
{ "name": "first_name", "type": "person", "nullCount": 0 },
{ "name": "zip", "type": "us_zip", "nullCount": 87 }
],
"cleanseReport": {
"invalidEmails": 17,
"normalizedPhones": 4210
}
}
}GET /profiles
List saved match profiles. Profiles are created through the app wizard today. The API surfaces them read-only so you can reference one by ID when creating a job.
Response 200:
{
"profiles": [
{
"id": "a27b8f01-...",
"name": "leads → crm",
"profileType": "person",
"isTemplate": false,
"createdAt": "2026-04-20T12:00:00Z"
}
]
}GET /jobs
List match jobs.
Query parameters:
| Param | Type | Notes |
|---|---|---|
status | string | pending, processing, completed, failed (optional) |
limit | int | 1-200, default 50 |
offset | int | default 0 |
POST /jobs
Create and enqueue a match job. Returns 201 with a pending job;
poll GET /jobs/{jobId} for status.
Request body:
{
"sourceFileId": "b1c9f3a0-...",
"masterFileId": "f4d29b5c-...",
"profileId": "a27b8f01-...",
"matchMode": "best_match"
}matchMode is best_match (default) or all_candidates — see the
walkthrough above.
Response 201:
{
"job": {
"id": "c93a1f00-...",
"status": "pending",
"matchMode": "best_match",
"createdAt": "2026-04-22T14:32:00Z"
}
}Errors:
400— missingsourceFileId,masterFileId, orprofileId.404— one of the three IDs doesn't belong to you or was deleted.
GET /jobs/{jobId}
Status, progress, and (once complete) the download URL for the result CSV.
Response 200:
{
"job": {
"id": "c93a1f00-...",
"status": "completed",
"progress": 100,
"matchMode": "best_match",
"resultSummary": {
"total_source_rows": 50000,
"matched": 42100,
"review": 3200,
"unmatched": 4700
},
"downloadUrl": "https://s3...presigned...",
"createdAt": "...",
"startedAt": "...",
"completedAt": "..."
}
}downloadUrl is only populated when status === "completed". The
URL expires in about an hour — call this endpoint again for a fresh
URL if you need it later.
GET /reports
List reports. One report exists per completed match job. Filter by
jobId to find the report for a specific match.
Query parameters:
| Param | Type | Notes |
|---|---|---|
jobId | uuid | Filter to a specific match |
limit | int | 1-200, default 50 |
offset | int | default 0 |
Response 200:
{
"reports": [
{
"id": "d04b2e91-...",
"jobId": "c93a1f00-...",
"title": "Leads → CRM — May",
"status": "completed",
"progress": 100,
"jobLabel": "leads → crm",
"createdAt": "2026-04-22T14:33:44Z",
"completedAt": "2026-04-22T14:34:02Z"
}
],
"pagination": { "limit": 50, "offset": 0, "returned": 1 }
}GET /reports/{reportId}
Full report detail: rollups, narrative sections, export readiness, and joined job context.
Response 200 (abridged — see the walkthrough for the full
shape):
{
"report": {
"id": "d04b2e91-...",
"status": "completed",
"rollups": {
"totals": {
"sourceRows": 5000,
"matched": 3812,
"review": 244,
"unmatched": 944,
"matchRatePct": 76.24
},
"scoreDistribution": [ ... ],
"passBreakdown": [ ... ],
"dataQuality": { ... }
},
"narrative": {
"executive": "...",
"dataQuality": "...",
"keyFindings": "..."
},
"exports": { "pdfReady": true, "pptxReady": false, "xlsxReady": true }
},
"job": {
"sourceFileName": "new_leads_may.csv",
"masterFileName": "salesforce_master.csv",
"resultSummary": { "matched": 3812, "review": 244, "unmatched": 944 }
}
}All fields in rollups are optional — handle missing keys
defensively.
GET /reports/{reportId}/export?format=pdf|xlsx|pptx
Fetch a formatted export. Two-phase response:
If already generated:
{
"status": "ready",
"downloadUrl": "https://s3...presigned...pdf"
}If not yet generated (first request enqueues; subsequent polls
return the same generating until it's done):
{
"status": "generating",
"message": "Export is being prepared. Poll this endpoint every 10-30 seconds until status='ready'."
}Poll cadence: every 10-30 seconds. Generation is usually done within 30 seconds, faster for small reports.
Tier gates:
| Format | Tier |
|---|---|
| xlsx | Starter+ |
| Pro+ | |
| pptx | Business |
A format above your tier returns 403 with requiredTier in the
body.
Other errors:
404— report doesn't exist or isn't yours.409— report is notcompletedyet (still running).
GET /schedules
List recurring match schedules. Read-only today; create / edit goes through the app UI.
Response 200:
{
"schedules": [
{
"id": "s01abc...",
"name": "Daily lead dedupe",
"profileId": "a27b8f01-...",
"sourceFileId": "b1c9f3a0-...",
"masterFileId": "f4d29b5c-...",
"intervalType": "daily",
"hourLocal": 6,
"timezone": "America/New_York",
"nextRunAt": "2026-04-23T10:00:00Z",
"lastRunAt": "2026-04-22T10:00:00Z",
"lastJobId": "c93a1f00-...",
"status": "active",
"createdAt": "2026-04-15T09:00:00Z"
}
]
}Patterns & recipes
Polling a job efficiently
Back off slightly on longer jobs so you're not burning rate-limit budget polling every second:
import time, requests
def wait_for_job(job_id, api, headers, timeout_sec=900):
deadline = time.time() + timeout_sec
delay = 2
while time.time() < deadline:
r = requests.get(f"{api}/jobs/{job_id}", headers=headers)
r.raise_for_status()
job = r.json()["job"]
if job["status"] in ("completed", "failed"):
return job
time.sleep(delay)
delay = min(delay * 1.5, 15) # cap at 15s
raise TimeoutError(f"Job {job_id} didn't complete in {timeout_sec}s")Respecting rate limits
Every response carries the state. Backoff when X-RateLimit-Remaining
is low, rather than catching 429s:
import time
def with_rate_limit_backoff(resp):
remaining = int(resp.headers.get("X-RateLimit-Remaining", "20"))
reset_ts = int(resp.headers.get("X-RateLimit-Reset", "0"))
if remaining <= 2:
sleep_for = max(0, reset_ts - int(time.time()))
if sleep_for > 0:
time.sleep(sleep_for)On a hard 429 you get Retry-After — sleep exactly that many
seconds and retry.
Reusing the master file
Don't re-upload your master file every run. Upload it once, keep the
fileId, and reuse it across many source files:
MASTER_FILE_ID = "f4d29b5c-..." # set once, reuse across runs
def run_daily_dedupe(new_leads_path):
source_id = upload_file(new_leads_path, purpose="source")
wait_for_file_ready(source_id)
job = create_job(source_id, MASTER_FILE_ID, PROFILE_ID)
return wait_for_job(job["id"])Fetching both CSV and report in one pass
job = wait_for_job(job_id)
# Download the raw matches
csv_bytes = requests.get(job["downloadUrl"]).content
with open("results.csv", "wb") as f:
f.write(csv_bytes)
# Find the report for this job
report_list = requests.get(f"{API}/reports?jobId={job_id}", headers=headers).json()
report_id = report_list["reports"][0]["id"]
report = requests.get(f"{API}/reports/{report_id}", headers=headers).json()["report"]
# Kick off a PDF export (will poll for ready)
export_url = wait_for_export(report_id, format="pdf")
pdf_bytes = requests.get(export_url).contentCode samples
Python — full pipeline
import os, time, requests
API = "https://app.listmatchgenie.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['LMG_API_KEY']}"}
# 1. Upload source file
meta = requests.post(f"{API}/files", headers=HEADERS, json={
"fileName": "leads.csv",
"fileSize": os.path.getsize("leads.csv"),
"contentType": "text/csv",
"purpose": "source",
}).json()
with open("leads.csv", "rb") as f:
requests.put(meta["uploadUrl"],
data=f,
headers={"Content-Type": "text/csv"}).raise_for_status()
source_id = meta["fileId"]
# 2. Wait for file to be ready
while True:
f = requests.get(f"{API}/files/{source_id}", headers=HEADERS).json()["file"]
if f["status"] == "ready": break
if f["status"] == "failed": raise RuntimeError(f["prepareError"])
time.sleep(2)
# 3. Fetch master + profile (assume you know these)
MASTER_ID = os.environ["LMG_MASTER_ID"]
PROFILE_ID = os.environ["LMG_PROFILE_ID"]
# 4. Create + poll the match job
job = requests.post(f"{API}/jobs", headers=HEADERS, json={
"sourceFileId": source_id,
"masterFileId": MASTER_ID,
"profileId": PROFILE_ID,
}).json()["job"]
while job["status"] not in ("completed", "failed"):
time.sleep(3)
job = requests.get(f"{API}/jobs/{job['id']}", headers=HEADERS).json()["job"]
if job["status"] == "failed":
raise RuntimeError("Match failed")
print(f"Matched: {job['resultSummary']['matched']}")
print(f"Result CSV: {job['downloadUrl']}")
# 5. Fetch the report
report_list = requests.get(
f"{API}/reports?jobId={job['id']}", headers=HEADERS,
).json()["reports"]
report = requests.get(
f"{API}/reports/{report_list[0]['id']}", headers=HEADERS,
).json()["report"]
print("Match rate:", report["rollups"]["totals"]["matchRatePct"], "%")
print("Narrative:", report["narrative"]["executive"][:200])
# 6. Export a PDF (poll until ready)
while True:
exp = requests.get(
f"{API}/reports/{report['id']}/export?format=pdf",
headers=HEADERS,
).json()
if exp.get("status") == "ready":
print("PDF:", exp["downloadUrl"])
break
time.sleep(15)JavaScript (Node.js + fetch)
const API = "https://app.listmatchgenie.com/api/v1";
const headers = { Authorization: `Bearer ${process.env.LMG_API_KEY}` };
// Upload source
const meta = await fetch(`${API}/files`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
fileName: "leads.csv",
fileSize: fs.statSync("leads.csv").size,
contentType: "text/csv",
purpose: "source",
}),
}).then((r) => r.json());
await fetch(meta.uploadUrl, {
method: "PUT",
body: fs.readFileSync("leads.csv"),
headers: { "Content-Type": "text/csv" },
});
// Wait for ready
let file;
do {
await new Promise((r) => setTimeout(r, 2000));
file = await fetch(`${API}/files/${meta.fileId}`, { headers })
.then((r) => r.json()).then((d) => d.file);
} while (file.status !== "ready" && file.status !== "failed");
// Create + poll job
const { job: pending } = await fetch(`${API}/jobs`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
sourceFileId: meta.fileId,
masterFileId: process.env.LMG_MASTER_ID,
profileId: process.env.LMG_PROFILE_ID,
}),
}).then((r) => r.json());
let job = pending;
while (job.status !== "completed" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 3000));
job = await fetch(`${API}/jobs/${pending.id}`, { headers })
.then((r) => r.json()).then((d) => d.job);
}
console.log("Result CSV:", job.downloadUrl);
// Fetch report
const { reports } = await fetch(
`${API}/reports?jobId=${job.id}`, { headers },
).then((r) => r.json());
const { report } = await fetch(
`${API}/reports/${reports[0].id}`, { headers },
).then((r) => r.json());
console.log("Match rate:", report.rollups.totals.matchRatePct);Errors
All errors return a JSON body with an error field:
{ "error": "Invalid or revoked API key" }| Code | When |
|---|---|
400 | Missing / malformed request body |
401 | Missing, malformed, or revoked API key |
402 | Feature requires a higher plan (API is Business only) |
403 | Tier limit hit (e.g. export format above your plan) |
404 | Resource not found (wrong ID, deleted, or not owned by you) |
409 | Precondition failed (e.g. report not yet completed) |
413 | File too large for your plan |
429 | Rate limit — retry after Retry-After seconds |
500 | Server error — retry with backoff |
Stability
The v1 API is committed. Endpoint paths, request shapes, and
response shapes won't break. We may add new endpoints and new fields
on existing responses — write your clients defensively so extra
fields don't trip them up. Breaking changes would ship as v2
alongside v1 with a deprecation window.
Support
API questions go to support@listmatchgenie.com. Or reach us through the contact form inside the app.
