REST API for programmatic people search. Query by name and optional age, city, and country — get a full profile back as JSON.
The PersonPages API returns the same structured profile you'd get from a normal search on the site. Results are cached, so a repeated query for the same person is served instantly and doesn't cost extra credits.
https://personpages.comAuthorization headerEvery request must include your API key as a bearer token. Keys start with pp_live_ (or pp_test_ for sandbox keys) and are shown once at signup — treat them like a password.
Authorization: Bearer pp_live_xxxxxxxxxxxxxxxxxxxxxxxx
Lost your key? You can rotate it from your developer dashboard.
Look up a person in two steps, just like on personpages.com. Step 1 — send a name (plus optional age, city, country) and get back a list of matching people (candidates) with a search_id. This step is free. Step 2 — send { search_id, candidate_id } for the person you meant and get the full profile. Only step 2 is billed. Add "direct": true in step 1 to skip the choice and get a single best-guess profile.
| Field | Type | Required | Description |
|---|---|---|---|
| name | string | Yes | Full name, 2–120 chars. |
| age | integer | No | Age, 13–120. Improves match quality. |
| city | string | No | City name, up to 120 chars. |
| country | string | No | Country name, up to 120 chars. |
{
"ok": true,
"search_id": "a1b2c3d4e5f6",
"candidates": [
{
"candidate_id": "jane-doe-acme-berlin",
"name": "Jane Doe",
"headline": "Product manager at Acme",
"role": "Product Manager",
"employer": "Acme",
"city": "Berlin",
"country": "Germany",
"age_estimate": 34,
"confidence": 87,
"evidence": ["LinkedIn profile lists Acme, Berlin"],
"source_urls": ["https://..."],
"famous": false
}
],
"next": "POST { search_id, candidate_id } to this endpoint to get the full profile."
}{ "search_id": "a1b2c3d4e5f6", "candidate_id": "jane-doe-acme-berlin" }{
"ok": true,
"cached": false,
"profile_url": "https://personpages.com/profile/jane-doe-berlin",
"usage": {
"plan": "pay_as_you_go",
"free_lookups": 50,
"used": 12,
"free_remaining": 38,
"unbilled_usd": 0,
"month_spend_usd": 0,
"resets_at": "2026-08-01T00:00:00.000Z"
},
"profile": {
"slug": "jane-doe-berlin",
"full_name": "Jane Doe",
"employer": "...",
"city": "Berlin",
"country": "Germany",
"...": "all fields returned by the profile page"
}
}cached: when true, the profile already existed and no new generation ran. Cached lookups count toward your 50 free lookups, and cost $0.05 each after that (new lookups cost $0.39).
curl -X POST https://personpages.com/api/public/v1/lookup \
-H "Authorization: Bearer pp_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Jane Doe",
"age": 34,
"city": "Berlin",
"country": "Germany"
}'const res = await fetch("https://personpages.com/api/public/v1/lookup", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.PERSONPAGES_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "Jane Doe",
city: "Berlin",
country: "Germany",
}),
});
const { search_id, candidates } = await res.json();
// Pick the right person (e.g. show the list to your user)
const pick = candidates[0];
const res2 = await fetch("https://personpages.com/api/public/v1/lookup", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.PERSONPAGES_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ search_id, candidate_id: pick.candidate_id }),
});
const data = await res2.json();
console.log(data.profile_url, data.profile);import os, requests
r = requests.post(
"https://personpages.com/api/public/v1/lookup",
headers={"Authorization": f"Bearer {os.environ['PERSONPAGES_KEY']}"},
json={"name": "Jane Doe", "city": "Berlin", "country": "Germany"},
timeout=60,
)
r.raise_for_status()
step1 = r.json()
pick = step1["candidates"][0] # choose the right person
r = requests.post(
"https://personpages.com/api/public/v1/lookup",
headers={"Authorization": f"Bearer {os.environ['PERSONPAGES_KEY']}"},
json={"search_id": step1["search_id"], "candidate_id": pick["candidate_id"]},
timeout=90,
)
data = r.json()
print(data["profile_url"], data["profile"])Errors are returned as JSON with a machine-readable error code and, where useful, a human message.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | Request body wasn't valid JSON. |
| 400 | invalid_input | Body failed validation. See details. |
| 404 | search_not_found | Unknown search_id or candidate_id. |
| 401 | missing_api_key | No Authorization header. |
| 401 | invalid_api_key | Key not recognized. |
| 403 | api_key_disabled | Key exists but is deactivated. |
| 402 | payment_failed | Your card was declined. Update it on the developer page. |
| 429 | free_quota_exceeded | 50 free lookups used and no card on file. |
| 502 | generation_failed | Upstream provider error. Safe to retry. |
| 500 | server_error | Unexpected server error. |
Does the API return the same data as the website?
Yes. The same profile object powers both the public profile page and the API response.
Are new profiles created when I use the API?
Yes. If the person isn't in our index, the API triggers the same generation pipeline as a normal search, and a public profile page is created at the returned profile_url.
Is there a sandbox / test environment?
Test-mode keys start with pp_test_ and are issued when you sign up with a Stripe test card. They hit the same endpoint and behave identically.
Rate limits?
If you need burst-safe rate limits or an SLA, contact us.