---
title: "Look up an email and/or phone"
method: POST
path: "/v1/people/{id}/enrich"
tags: ["People"]
---

# Look up an email and/or phone

`POST /v1/people/{id}/enrich`

## Request

- Base URL: `https://api.trayo.ai`
- URL: `https://api.trayo.ai/v1/people/{id}/enrich`
- 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","phone"]` and requires `people:enrich_email` and `people:enrich_phone`. Malformed values: no additional scopes; unknown fields add no scopes. Request validation still applies.

Find this person's email and/or phone the same way the Trayo app's own "Find email" does. Omit `fields` for both.

**It returns `202` at once** with what it started and what it skipped. Over the plan limit, or on a plan with no such lookups, the call is `402 lookup_limit_reached`: nothing was started and nothing was charged.

**A lookup is explicit, asynchronous, idempotent, and governed by your plan.** Nothing is looked up on create or on read; this call is the only trigger, and it reports what it started and what it SKIPPED — a field already in progress, already found, blocked by a written email, or inside its retry cooldown is skipped with the reason, never an error — so sending it twice never double-runs or is charged twice. Poll `GET /v1/people/{id}/enrich` (or read the person) for the result; a lookup takes seconds to a minute.

**Provenance is kept.** A found value carries `source`, `status` and `foundAt`; a value you wrote is `source: "manual"` and is never overwritten by a found one — the email lookup is not even attempted while one is present.

**Plan limits are checked BEFORE anything runs.** Each lookup that starts counts once against a lifetime plan limit. Every response carries `quota` — `limit`, `used` and `remaining` per field, as they stand after the call, the same numbers `GET /v1/whoami` reports — so size each request by `quota.<field>.remaining` rather than learning the limit from a refusal.

## Path parameters

- `id` string, uuid, required

## Request body

- EnrichPersonBodyDto
  - `fields` string[]: Which values to look up. Omit for both. A field that cannot or need not run is reported in `skipped`, never an error.

## Response `202`

Accepted. `started` lists the lookups this call started; `skipped` lists the ones it did not and why; `contact` is the state right now; `quota` is the lookup allowance left after this call.

- EnrichPersonResultDto
  - `personId` string, uuid, required
  - `started` string[], required: The lookups that were started by THIS call. Poll `GET /v1/people/{id}/enrich` for them, or `trayo_get_contacts` on the MCP server.
  - `skipped` object[], required: The lookups this call did NOT start, and why. Never an error.
    - `field` 'email' | 'phone', required
    - `reason` 'manual_present' | 'already_found' | 'in_progress' | 'retry_too_soon' | 'start_failed', required: `manual_present`: you wrote an email, it wins, nothing to find. `already_found`: a value is already on the record. `in_progress`: a lookup is already running. `retry_too_soon`: the last run missed and the cooldown has not passed — `retryAvailableAt` says when. `start_failed`: this field passed every check but could not be started. Nothing is running for it, and re-sending is safe and is not charged twice. (When NO field could be started the call is a `503 lookup_start_failed` instead.)
    - `retryAvailableAt` string, date-time, nullable
  - `contact` object, required: The state immediately after those lookups started.
    - `personId` string, uuid, required
    - `email` object, required
      - `value` string, nullable, required: The email to use: a manual value if one was written, else the found one.
      - `source` 'manual' | 'enrichment', nullable, required: `manual` = written through this API or the app; `enrichment` = found.
      - `status` 'verified' | 'unconfirmed' | 'bounced' | 'stale', nullable, required: State of a FOUND email, and the field to act on. `verified` = a finder returned it and nothing suggests it belongs to someone else. `unconfirmed` = a finder returned it, but `identityConfidence` is `conflict`: the address disagrees with this person's name or company, or another person in the workspace holds the same address. The value is still given; have a human check it before sending. `bounced` after a send bounced (the value is withheld); `stale` when the product no longer trusts it. Neither `verified` nor `unconfirmed` is a mailbox probe. Null for a manual value and when nothing was found.
      - `identityConfidence` 'match' | 'conflict' | 'unknown', nullable, required: Whether a FOUND email looks like THIS person's: `match` when its local-part agrees with their name or its domain with their account's website; `conflict` when either disagrees (a catch-all or a wrong-account hit) or when another person in the workspace holds the same address, and `status` then reads `unconfirmed`; `unknown` when there is nothing to compare against (no name, no account website, a free-mail domain, a sibling domain of the account's, or an account whose website matches none of its people's addresses). Null for a manual value and when nothing was found.
      - `foundAt` string, date-time, nullable, required
      - `lookup` object, required
        - `state` 'not_requested' | 'in_progress' | 'found' | 'not_found' | 'error' | 'bounced', required: `not_requested`: never looked up. `in_progress`: a lookup is running, poll. `found`: a value is on the record. `not_found`: the lookup ran and missed; `retryAvailableAt` says when it may run again. `error`: a run stalled and may be retried now. `bounced`: a found email bounced; the next lookup re-runs from scratch.
        - `retryAvailableAt` string, date-time, nullable, required: Set on `not_found`: the earliest time a new lookup will be accepted. Until then the field is skipped as `retry_too_soon`.
    - `phone` object, required
      - `value` string, nullable, required: The found phone number, or null.
      - `foundAt` string, date-time, nullable, required
      - `lookup` object, required
        - `state` 'not_requested' | 'in_progress' | 'found' | 'not_found' | 'error' | 'bounced', required: `not_requested`: never looked up. `in_progress`: a lookup is running, poll. `found`: a value is on the record. `not_found`: the lookup ran and missed; `retryAvailableAt` says when it may run again. `error`: a run stalled and may be retried now. `bounced`: a found email bounced; the next lookup re-runs from scratch.
        - `retryAvailableAt` string, date-time, nullable, required: Set on `not_found`: the earliest time a new lookup will be accepted. Until then the field is skipped as `retry_too_soon`.
  - `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

- `400`: `id` is not a UUID, or `fields` names something other than `email`/`phone`. `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.
- `404`: `person_not_found` (`retry: do_not_retry`) — No such person in your workspace. Another workspace's person is indistinguishable from a non-existent one.
- `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`: `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)
