---
title: "Look up emails and phones for many people"
method: POST
path: "/v1/people/enrich/batch"
tags: ["People"]
---

# Look up emails and phones for many people

`POST /v1/people/enrich/batch`

## Request

- Base URL: `https://api.trayo.ai`
- URL: `https://api.trayo.ai/v1/people/enrich/batch`
- 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.

Start lookups for up to 200 people in one call, with the same rules as the single route applied per person. Partial success is the contract: a person who exists gets an outcome in `accepted` even if every field was skipped; an id that is unknown, another workspace's, or a duplicate earlier in the batch lands in `skipped` by index.

**This route always answers `200`, never `202` or `402`.** A workspace allowance stop — the plan lookup limit, the workspace quota, or the daily usage limit — is reported per person rather than raised: people reached before the stop are looked up and charged, and the person that hit it and every person after it are reported in `skipped` with the limit as the reason. Nothing after the limit is partially run. `stoppedBy` says so as data: which allowance, how many people were processed and how many were not looked up.

**The batch has a 120s budget for starting lookups.** The whole batch starts together and normally takes a few seconds. The budget is checked once, before anything is reserved: if it has run out, the first person who needed a lookup and every person after are reported in `skipped` with a reason saying the call ran out of time. Nothing was started for them and nothing was spent, so send those ids again in smaller batches. Retain this request's `personIds` array and map `skipped[].index` back to it. Only re-send time-skipped rows this way: other rows in `skipped` may be duplicates, invalid ids, or allowance refusals that need a different remedy. Poll already-started lookups with `GET /v1/people/{id}/enrich`. If a retry makes no progress, stop and keep the unresolved ids for later.

**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.

## Request body

- BatchEnrichBodyDto
  - `personIds` string[], required: 1 to 200 person ids. Over the cap is an error, not a clamp.
  - `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 `200`

The batch was processed. Check `skipped` — a `200` does not mean a lookup started for every person.

- BatchEnrichResultDto: **Partial success is the contract.** A person whose fields were all skipped still lands in `accepted`, with the reasons; only a person the call could not evaluate lands in `skipped`.
  - `accepted` object[], required: One outcome per person that exists in your workspace, in submission order.
    - `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`.
  - `skipped` object[], required: Person ids this call could not act on at all: unknown or another workspace's, a duplicate earlier in the batch, a plan limit that stopped the batch, or the time limit the call reached before it got to them — the reason says which. Rows after a limit are skipped, not partially run; rows the call ran out of time for were never started and never charged, so send them again.
    - `index` integer, required: 0-based position in the submitted `personIds`.
    - `reason` string, required
  - `stoppedBy` object: Present when a workspace allowance ended the batch before every person was evaluated. Branch on this rather than on `skipped[].reason` prose.
    - `reason` 'lookup_limit_reached' | 'quota_daily_limit' | 'quota_exceeded', required: Which workspace allowance ended the batch. `lookup_limit_reached`: the plan's lifetime email/phone lookup allowance is used up — it does not reset, the plan has to change. `quota_exceeded`: the workspace quota — raise it or wait for it to renew. `quota_daily_limit`: the rolling 24-hour usage limit — wait, then retry.
    - `retry` 'retry_later' | 'do_not_retry', required: Whether waiting alone clears this stop — the same `retry` an error envelope for this code carries. `do_not_retry` means re-sending the skipped people changes nothing until the allowance itself changes.
    - `processed` integer, required: How many submitted people this call evaluated before the stop (they are in `accepted`).
    - `skippedOther` integer, required: How many submitted people before the stop were skipped for their own reason — an unknown id or a duplicate. They are in `skipped`; fix and re-send them. `processed + skippedOther + notLookedUp` is the number of people submitted.
    - `notLookedUp` integer, required: How many submitted people the allowance left unevaluated — the person it refused plus everyone after them. They are in `skipped`; nothing was started or charged for them.
    - `message` string, required: The refusal, in words, with what to do next.
  - `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`: The ENVELOPE was wrong: `personIds` missing, empty, over 200, not UUIDs, or an unknown `fields` entry. A problem with one PERSON never produces this status — it is reported in the `200` response's `skipped`. `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.
- `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.

---

[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)
