---
title: "Find an email or phone for a person you describe"
method: POST
path: "/v1/contacts/lookup"
tags: ["People"]
---

# Find an email or phone for a person you describe

`POST /v1/contacts/lookup`

## Request

- Base URL: `https://api.trayo.ai`
- URL: `https://api.trayo.ai/v1/contacts/lookup`
- Auth: HTTP bearer

**Required permissions:** `body.fields` includes `email`: `people:enrich_email`; `body.fields` includes `phone`: `people:enrich_phone`. All scopes for the selected fields are required. Omitting `body.fields` uses `["email"]` and requires `people:enrich_email`. Malformed values: no additional scopes; unknown fields add no scopes. Request validation still applies.

Give a person — their name, and their LinkedIn profile or where they work — and get their email back in the same response, and their phone as a lookup to poll. The person does not have to exist in your workspace, and this call does not create them: it is `POST /v1/people/{id}/enrich` with the person row taken out of the middle, for the case where you are still deciding whether someone is worth keeping.

**Email is synchronous.** Typically a few seconds, and the server gives up at 120 seconds by default with `503 contact_lookup_failed`, so set your client timeout above that. Import the people you want afterwards with `POST /v1/people` (send the `email` you got back as the person's email).

**Phone is asynchronous.** Ask for it with `fields: ["phone"]` and the response is `202` with `phone.status: "pending"` and a `phone.lookupId`; collect it from `GET /v1/contacts/lookups/{lookupId}`. Phone lookups typically take one to two minutes, but can take up to four minutes. Email asked for in the same call is still answered inline, in the same response.

**What it costs.** One lookup per field when a search starts (a phone counts when it starts, so polling is free), counted against your plan's allowance for that field — email and phone have separate limits. A lookup here and a lookup on a person draw on the same allowance for the same field. Over the limit is `402 lookup_limit_reached` before any search starts. The response carries `quota`: the allowance left per field after this call, the same numbers `GET /v1/whoami` reports.

**Asking twice is free.** The workspace's answer for one identity is remembered — a month for a found address, a week for `not_found` — and a repeat request reads it: `source: "saved"`, no new lookup, and nothing counted against your allowance. So a retry after `not_found` costs nothing.

**A found address is not a verified owner.** `status: "found"` means a search returned the address and nothing suggests it belongs to someone else. `status: "unconfirmed"` means a search returned it but `email.identityConfidence` is `conflict`: the address is still in `value`, and a human should look before anyone sends to it. `email.identityConfidence` (`match`, `conflict` or `unknown`) says why. It compares the address against the `fullName` you sent, always, and against the `company.website` you sent, when you sent one. Include the known employer website even with a LinkedIn profile; without it, a matching name can produce `match` at the wrong employer. `match` does not prove mailbox ownership or current employment, and this field does not block sending.

**`not_found` is an answer.** The lookup finished without a value and returns `200`. A `503` means the question was never answered — nothing charged, nothing stored.

**Partial success is the contract.** When you ask for both fields and one fails because of a plan limit or a lookup error, the call still answers. That field reads `status: "failed"` with `error.code`, `error.retry` and `error.message`, exactly what an error response would have carried. The other field keeps its answer and its `lookupId`. Nothing is charged for a field that failed. The call itself fails only when every field you asked for did.

**What to send.** Include the LinkedIn profile when known to improve the chance of finding contact details. Otherwise, send the company website. A name alone returns `400`.

## Request body

- ContactLookupBodyDto
  - `fullName` string, required: The person's full name, as it reads on their profile.
  - `linkedinUrl` string: The person's LinkedIn profile, as its URL — the `linkedinUrl` a `POST /v1/find` contacts result carries, copied through — or as the bare username. Include it when known to improve the chance of finding contact details.
  - `company` object: Where the person works. Include it when known to help identify the person.
    - `name` string: The employer's name. Used alongside the website, never instead of it.
    - `website` string: The employer's website, as a URL or a domain (`https://zapier.com`, `zapier.com`). Required when you do not send a LinkedIn profile. Include the known employer website even with a profile so `email.identityConfidence` can check the employer too.
  - `fields` string[]: Which values to look up. Defaults to `["email"]`, the synchronous one, so ask for `phone` explicitly. A request that includes `phone` comes back `202` with that field `pending` and a `lookupId` to poll. Phone lookups can take several minutes. Email in the same request is still answered inline.

## Response `200`

The lookup ran, a stored answer was returned, or nothing could run (`not_attempted`). Read `email.status` for the outcome (`unconfirmed` for a found address that may be someone else's), `email.identityConfidence` for whether the address looks like the person you described, and `email.source` for whether this call paid for it.

- ContactLookupResultDto
  - `person` object, required: The identity as the lookup understood it. Check it when the answer surprises you.
    - `fullName` string, required
    - `linkedinUrl` string, nullable, required: The profile the lookup used, canonicalized from whatever form you sent.
    - `company` object, required
      - `name` string, nullable, required
      - `website` string, nullable, required: The domain the lookup used, taken from the website you sent.
  - `email` object, required
    - `value` string, nullable, required: What was found, or null.
    - `status` 'found' | 'not_found' | 'pending' | 'not_attempted' | 'not_requested' | 'failed' | 'unconfirmed', required: `found`: a value was found. `not_found`: the lookup finished without a value. A repeat request returns the saved result without another charge. `pending`: the lookup is running. Poll `lookupId`. `not_attempted`: the lookup is unavailable or the supplied details are insufficient. Nothing was searched or charged. For phone lookups, include `linkedinUrl`. `not_requested`: absent from `fields`. `failed`: this field failed. Read `error` for the reason and retry instructions. Nothing was charged for this field. If every requested field fails, the whole call returns an error. `unconfirmed` (email only): an address was found, but `identityConfidence` is `conflict`, so it may belong to someone else. `value` still holds it; have a human check it before sending. Branch on this field: `found` is never given for a `conflict` address.
    - `foundAt` string, date-time, nullable, required: When the lookup ran. For a saved result, this is the time of the original lookup.
    - `source` 'lookup' | 'saved', nullable, required: `lookup`: this call started a lookup or returned one that is still running. `saved`: this call returned a saved result without another charge. Null when `not_requested`.
    - `lookupId` string, uuid, nullable, required: Set only while `pending`: poll `GET /v1/contacts/lookups/{lookupId}`.
    - `error` object, nullable, required: Set only when `status` is `failed`: the `code`, `retry` and `message` the call would have answered with had this been the only field requested. Branch on it exactly as you would on an error response.
      - `code` string, required: An `error.code` from the table below, scoped to this field.
      - `retry` 'fix_input' | 'retry_later' | 'do_not_retry', required: What to do about it, as on an error response.
      - `message` string, required
    - `identityConfidence` 'match' | 'conflict' | 'unknown', nullable, required: Whether a FOUND address looks like THIS person's. Two comparisons: the address's local-part against the `fullName` you sent, and the address's domain against the `company.website` you sent. The name comparison always runs, because `fullName` is required. The domain comparison runs only when you send a website — `person.company.website` echoes back the domain supplied to that comparison, or null when there was none. Either comparison can have no usable evidence. Apply these rules in order: `conflict` when either comparison disagrees, even if the other agrees; otherwise `unknown` for a similar-looking company domain or a sibling government domain, even when the name matches; otherwise `match` when at least one comparison agrees; otherwise `unknown`. A free-mail address or an absent or unusable employer website provides no employer evidence, so the name alone can still produce `match` or `conflict`. Include the known employer website even when you send a profile. `match` does not prove mailbox ownership or current employment. A `conflict` turns `status` into `unconfirmed`: have a human look before sending; this field does not block sending. Null when no address was found.
  - `phone` object, required: Phone lookups can take several minutes. For `pending`, poll `lookupId`. `not_attempted` means the lookup is unavailable or the supplied details are insufficient. Include `linkedinUrl` when known. No lookup starts and nothing is charged for `not_attempted`.
    - `value` string, nullable, required: What was found, or null.
    - `status` 'found' | 'not_found' | 'pending' | 'not_attempted' | 'not_requested' | 'failed', required: `found`: a value was found. `not_found`: the lookup finished without a value. A repeat request returns the saved result without another charge. `pending`: the lookup is running. Poll `lookupId`. `not_attempted`: the lookup is unavailable or the supplied details are insufficient. Nothing was searched or charged. For phone lookups, include `linkedinUrl`. `not_requested`: absent from `fields`. `failed`: this field failed. Read `error` for the reason and retry instructions. Nothing was charged for this field. If every requested field fails, the whole call returns an error.
    - `foundAt` string, date-time, nullable, required: When the lookup ran. For a saved result, this is the time of the original lookup.
    - `source` 'lookup' | 'saved', nullable, required: `lookup`: this call started a lookup or returned one that is still running. `saved`: this call returned a saved result without another charge. Null when `not_requested`.
    - `lookupId` string, uuid, nullable, required: Set only while `pending`: poll `GET /v1/contacts/lookups/{lookupId}`.
    - `error` object, nullable, required: Set only when `status` is `failed`: the `code`, `retry` and `message` the call would have answered with had this been the only field requested. Branch on it exactly as you would on an error response.
      - `code` string, required: An `error.code` from the table below, scoped to this field.
      - `retry` 'fix_input' | 'retry_later' | 'do_not_retry', required: What to do about it, as on an error response.
      - `message` string, required
  - `quota` object: Email and phone lookup usage and any active allowances AFTER this call, counted separately. `limit` and `remaining` are `null` when no lookup cap applies. If `remaining` is a number, size your next request by it: people past that allowance are skipped. Omitted only when the allowance could not be read; `GET /v1/whoami` (MCP: trayo_whoami) reports the same numbers.
    - `email` object, required
      - `enabled` boolean, required: Whether the plan allows this kind of lookup at all. `false` means every lookup is refused.
      - `limit` integer, nullable, required: Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply.
      - `used` integer, required: Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`.
      - `remaining` integer, nullable, required: Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`.
      - `period` 'lifetime', required: The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped.
    - `phone` object, required
      - `enabled` boolean, required: Whether the plan allows this kind of lookup at all. `false` means every lookup is refused.
      - `limit` integer, nullable, required: Effective allowance for this field. `null` means unlimited. Public REST/MCP lookup quotas, workspace quota and the daily usage limit (`429 quota_daily_limit`, a rolling 24-hour ceiling) are disabled for now. Scope, lifecycle and request validation still apply.
      - `used` integer, required: Recorded lookup usage, including lookups still in flight, even when no cap applies. Read `remaining` rather than subtracting this from `limit`.
      - `remaining` integer, nullable, required: Lookups left against an enabled allowance: `limit - used`, floored at 0. `null` when `limit` is `null`; public REST/MCP usage allowances are unlimited for now. The rolling 24-hour daily usage limit (`429 quota_daily_limit`) is disabled too. This is a snapshot that includes lookups still in flight; concurrent work changes `used`. Scope, lifecycle, per-network protection and row validation can still refuse a call. Handle the actual refusal code and, for a stopped MCP batch, `stoppedBy.retry`.
      - `period` 'lifetime', required: The usage counting period. `lifetime`: recorded lookups do not reset. When a numeric allowance applies, it has no scheduled reset; `null` limits remain uncapped.

## Other responses

- `202`: A phone lookup was started. Same body as `200`; `phone.status` is `pending` and `phone.lookupId` is the id to poll. An email asked for in the same call is answered in this response.
- `400`: Neither `linkedinUrl` nor `company.website`; a `linkedinUrl` that is not a personal profile URL; a missing or empty `fullName`; or an unknown field. `param` names it. `validation_failed` (`retry: fix_input`) — The request did not pass input validation: a missing or malformed body field, a bad `limit`, a path parameter that is not a UUID, a `signalKey` that already exists or is reserved. `message` lists every failed constraint; `param` names the first offending field when one can be identified.
- `401`: Missing, malformed, or incorrect bearer token. Every way authentication can fail returns the identical body, so do not try to infer which one it was, and do not repeat the call with the same key. `unauthorized` (`retry: do_not_retry`) — Missing, malformed, or incorrect bearer token. Identical body for every cause.
- `402`: `lookup_limit_reached` (`retry: do_not_retry`) — This workspace cannot run this contact lookup: the plan's email or phone lookup limit is reached, or the plan does not include that kind of lookup. `message` says which and quotes the limit. Retrying never helps and neither does changing the request — the plan has to change. Nothing was started and nothing was charged.
- `403`: The key grant and its owner's current workspace role must both include every scope required by the operation. `insufficient_scope` (`retry: do_not_retry`) — Scope denial details distinguish missing_key_scope (use a key granting every missing scope), owner_permission_limit (the owner needs the missing workspace permissions), and invalid_key_scope (replace the key with supported scopes). Membership and undeclared-route denials remain generic.
- `429`: Every route is rate limited per workspace across REST, MCP, all keys and OAuth users, `GET /v1/whoami` included, so a balance poll competes with the work it is watching. For `rate_limited`, wait `Retry-After` seconds (also `details.retryAfterSeconds`), then send the same request once; do not retry sooner. `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` describe the workspace's bucket, and they come back on successful responses too: read them and slow down BEFORE you are refused. A burst from one network can also be refused before the key is checked; that 429 carries `Retry-After` and no `RateLimit-*` headers. `rate_limited` (`retry: retry_later`) — The workspace or source network exhausted its request budget for one bucket. `details.retryAfterSeconds` (and `Retry-After` on REST) is the wait: wait that long, then retry the same request once. Do not poll or retry sooner; an early retry is refused again. If you cannot wait, stop and tell the user when to retry; do not fill the wait with other calls. `details.limit` and `details.bucket` name the exhausted budget. When present, RateLimit-* headers describe the active budget and reset.
- `500`: Something failed on our side. Nothing about the request needs changing. `internal_error` (`retry: retry_later`) — Something failed on our side. `message` never describes it — quote `requestId` when reporting. Retry with exponential backoff and GIVE UP after a few attempts: `retry_later` here does not mean the failure is known to be transient, only that nothing about your request needs changing. A request that fails this way repeatedly will not start working.
- `503`: `contact_lookup_failed` (`retry: retry_later`) — The email lookup could not be completed: it failed or ran past its time budget. Nothing was charged, nothing was stored, and no allowance was consumed. Send the same request again shortly. A lookup that RAN and found nothing is a `200` with `status: "not_found"`, not this. `lookup_start_failed` (`retry: retry_later`) — The contact lookup passed every check but could not be started. No lookup is running for it, and a retry of the same request is not charged twice. Send it again shortly.

---

[API](https://skmtc.dev/trayo/apis/trayo-api.md) · [All operations](https://skmtc.dev/trayo/apis/trayo-api/llms.txt) · [OpenAPI document](https://skmtc.dev/trayo/apis/trayo-api/revisions/0af452dce6de?raw)
